diff --git a/.eslintrc.js b/.eslintrc.js index f867aa193..5327e74a8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,6 +3,6 @@ module.exports = { 'nextcloud' ], rules: { - 'valid-jsdoc': ['warn'], + 'valid-jsdoc': ['off'], } } diff --git a/appinfo/routes.php b/appinfo/routes.php index d815fa013..0c247b642 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -122,6 +122,14 @@ return [ ['name' => 'attachment_api#delete', 'url' => '/api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments/{attachmentId}', 'verb' => 'DELETE'], ['name' => 'attachment_api#restore', 'url' => '/api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments/{attachmentId}/restore', 'verb' => 'PUT'], + + ['name' => 'board_api#preflighted_cors', 'url' => '/api/v1.0/{path}','verb' => 'OPTIONS', 'requirements' => ['path' => '.+']], + ], + 'ocs' => [ + ['name' => 'comments_api#list', 'url' => '/api/v1.0/cards/{cardId}/comments', 'verb' => 'GET'], + ['name' => 'comments_api#create', 'url' => '/api/v1.0/cards/{cardId}/comments', 'verb' => 'POST'], + ['name' => 'comments_api#update', 'url' => '/api/v1.0/cards/{cardId}/comments/{commentId}', 'verb' => 'PUT'], + ['name' => 'comments_api#delete', 'url' => '/api/v1.0/cards/{cardId}/comments/{commentId}', 'verb' => 'DELETE'], ] ]; diff --git a/css/icons.scss b/css/icons.scss index 09cb77090..f0e68d979 100644 --- a/css/icons.scss +++ b/css/icons.scss @@ -61,6 +61,7 @@ @include icon-black-white('clone', 'deck', 1); @include icon-black-white('filter', 'deck', 1); @include icon-black-white('attach', 'deck', 1); + @include icon-black-white('reply', 'deck', 1); .icon-toggle-compact-collapsed { @include icon-color('toggle-view-expand', 'deck', $color-black); diff --git a/docs/API.md b/docs/API.md index 3ce5b9a15..fa8982b19 100644 --- a/docs/API.md +++ b/docs/API.md @@ -936,3 +936,232 @@ For now only `deck_file` is supported as an attachment type. ##### 200 Success +# OCS API + +The following endpoints are available tough the Nextcloud OCS endpoint, which is available at `/ocs/v2.php/apps/deck/api/v1.0/`. +This has the benefit that both the web UI as well as external integrations can use the same API. + +## Comments + +### GET /cards/{cardId}/comments - List comments + +#### Request parameters + +string $cardId, int $limit = 20, int $offset = 0 + +| Parameter | Type | Description | +| --------- | ------- | --------------------------------------- | +| cardId | Integer | The id of the card | +| limit | Integer | The maximum number of comments that should be returned, defaults to 20 | +| offset | Integer | The start offset used for pagination, defaults to 0 | + +``` +curl 'https://admin:admin@nextcloud/ocs/v2.php/apps/deck/api/v1.0/cards/12/comments' \ + -H 'Accept: application/json' -H 'OCS-APIRequest: true' +``` + +#### Response + +A list of comments will be provided under the `ocs.data` key. If no or no more comments are available the list will be empty. + +##### 200 Success + +``` +{ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": [ + { + "id": "175", + "objectId": "12", + "message": "This is a comment with a mention to @alice", + "actorId": "admin", + "actorType": "users", + "actorDisplayName": "Administrator", + "creationDateTime": "2020-03-10T10:23:07+00:00", + "mentions": [ + { + "mentionId": "alice", + "mentionType": "user", + "mentionDisplayName": "alice" + } + ] + } + ] + } +} +``` + + +### POST /cards/{cardId}/comments - Create a new comment + +#### Request parameters + +| Parameter | Type | Description | +| --------- | ------- | --------------------------------------- | +| cardId | Integer | The id of the card | +| message | String | The message of the comment, maximum length is limited to 1000 characters | +| parentId | Integer | The start offset used for pagination, defaults to null | + +Mentions will be parsed by the server. The server will return a list of mentions in the response to this request as shown below. + +``` +curl -X POST 'https://admin:admin@nextcloud/ocs/v2.php/apps/deck/api/v1.0/cards/12/comments' \ + -H 'Accept: application/json' -H 'OCS-APIRequest: true' + -H 'Content-Type: application/json;charset=utf-8' + --data '{"message":"My message to @bob","parentId":null}' +``` + +#### Response + +A list of comments will be provided under the `ocs.data` key. If no or no more comments are available the list will be empty. + +##### 200 Success + +``` +{ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": { + "id": "177", + "objectId": "13", + "message": "My message to @bob", + "actorId": "admin", + "actorType": "users", + "actorDisplayName": "Administrator", + "creationDateTime": "2020-03-10T10:30:17+00:00", + "mentions": [ + { + "mentionId": "bob", + "mentionType": "user", + "mentionDisplayName": "bob" + } + ] + } + } +} +``` + +##### 400 Bad request + +A bad request response is returned if invalid input values are provided. The response message will contain details about which part was not valid. + +##### 404 Not found + +A not found response might be returned if: +- The card for the given cardId could not be found +- The parent comment could not be found + + +### PUT /cards/{cardId}/comments/{commentId} - Update a new comment + +#### Request parameters + +| Parameter | Type | Description | +| --------- | ------- | --------------------------------------- | +| cardId | Integer | The id of the card | +| commentId | Integer | The id of the comment | +| message | String | The message of the comment, maximum length is limited to 1000 characters | + +Mentions will be parsed by the server. The server will return a list of mentions in the response to this request as shown below. + +Updating comments is limited to the current user being the same as the comment author specified in the `actorId` of the comment. + +``` +curl -X POST 'https://admin:admin@nextcloud/ocs/v2.php/apps/deck/api/v1.0/cards/12/comments' \ + -H 'Accept: application/json' -H 'OCS-APIRequest: true' + -H 'Content-Type: application/json;charset=utf-8' + --data '{"message":"My message"}' +``` + +#### Response + +A list of comments will be provided under the `ocs.data` key. If no or no more comments are available the list will be empty. + +##### 200 Success + +``` +{ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": { + "id": "177", + "objectId": "13", + "message": "My message", + "actorId": "admin", + "actorType": "users", + "actorDisplayName": "Administrator", + "creationDateTime": "2020-03-10T10:30:17+00:00", + "mentions": [] + } + } +} +``` + +##### 400 Bad request + +A bad request response is returned if invalid input values are provided. The response message will contain details about which part was not valid. + +##### 404 Not found + +A not found response might be returned if: +- The card for the given cardId could not be found +- The comment could not be found + +### DELETE /cards/{cardId}/comments/{commentId} - Delete a comment + +#### Request parameters + +| Parameter | Type | Description | +| --------- | ------- | --------------------------------------- | +| cardId | Integer | The id of the card | +| commentId | Integer | The id of the comment | + +Deleting comments is limited to the current user being the same as the comment author specified in the `actorId` of the comment. + +``` +curl -X DELETE 'https://admin:admin@nextcloud/ocs/v2.php/apps/deck/api/v1.0/cards/12/comments' \ + -H 'Accept: application/json' -H 'OCS-APIRequest: true' + -H 'Content-Type: application/json;charset=utf-8' +``` + +#### Response + +A list of comments will be provided under the `ocs.data` key. If no or no more comments are available the list will be empty. + +##### 200 Success + +``` +{ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": [] + } +} +``` + +##### 400 Bad request + +A bad request response is returned if invalid input values are provided. The response message will contain details about which part was not valid. + +##### 404 Not found + +A not found response might be returned if: +- The card for the given cardId could not be found +- The comment could not be found diff --git a/img/reply.svg b/img/reply.svg new file mode 100644 index 000000000..a198103b8 --- /dev/null +++ b/img/reply.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 935fa7599..3d2885c56 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -56,6 +56,8 @@ class Application extends App { public const APP_ID = 'deck'; + public const COMMENT_ENTITY_TYPE = 'deckCard'; + /** @var IServerContainer */ private $server; @@ -149,7 +151,7 @@ class Application extends App { public function registerCommentsEntity(): void { $this->server->getEventDispatcher()->addListener(CommentsEntityEvent::EVENT_ENTITY, function(CommentsEntityEvent $event) { - $event->addEntityCollection('deckCard', function($name) { + $event->addEntityCollection(self::COMMENT_ENTITY_TYPE, function($name) { /** @var CardMapper */ $cardMapper = $this->getContainer()->query(CardMapper::class); $permissionService = $this->getContainer()->query(PermissionService::class); diff --git a/lib/Controller/CommentsApiController.php b/lib/Controller/CommentsApiController.php new file mode 100644 index 000000000..8f38ddf53 --- /dev/null +++ b/lib/Controller/CommentsApiController.php @@ -0,0 +1,80 @@ + + * + * @author Julius Härtl + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Deck\Controller; + +use OCA\Deck\BadRequestException; +use OCA\Deck\Service\CommentService; +use OCA\Deck\StatusException; +use OCP\AppFramework\ApiController; +use OCP\AppFramework\Http\DataResponse; + +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCSController; +use OCP\IRequest; + +class CommentsApiController extends OCSController { + + /** @var CommentService */ + private $commentService; + + public function __construct( + $appName, IRequest $request, $corsMethods = 'PUT, POST, GET, DELETE, PATCH', $corsAllowedHeaders = 'Authorization, Content-Type, Accept', $corsMaxAge = 1728000, + CommentService $commentService + ) { + parent::__construct($appName, $request, $corsMethods, $corsAllowedHeaders, $corsMaxAge); + $this->commentService = $commentService; + } + + /** + * @NoAdminRequired + * @throws StatusException + */ + public function list(string $cardId, int $limit = 20, int $offset = 0): DataResponse { + return $this->commentService->list($cardId, $limit, $offset); + } + + /** + * @NoAdminRequired + * @throws StatusException + */ + public function create(string $cardId, string $message, string $parentId = '0'): DataResponse { + return $this->commentService->create($cardId, $message, $parentId); + } + + /** + * @NoAdminRequired + * @throws StatusException + */ + public function update(string $cardId, string $commentId, string $message): DataResponse { + return $this->commentService->update($cardId, $commentId, $message); + } + + /** + * @NoAdminRequired + * @throws StatusException + */ + public function delete(string $cardId, string $commentId): DataResponse { + return $this->commentService->delete($cardId, $commentId); + } +} diff --git a/lib/Middleware/ExceptionMiddleware.php b/lib/Middleware/ExceptionMiddleware.php index 96d2a1b65..a4f0610ee 100644 --- a/lib/Middleware/ExceptionMiddleware.php +++ b/lib/Middleware/ExceptionMiddleware.php @@ -28,6 +28,8 @@ use OCA\Deck\StatusException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Middleware; use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCSController; use OCP\ILogger; use OCP\Util; use OCP\IConfig; @@ -65,6 +67,11 @@ class ExceptionMiddleware extends Middleware { if ($this->config->getSystemValue('loglevel', Util::WARN) === Util::DEBUG) { $this->logger->logException($exception); } + + if ($controller instanceof OCSController) { + $exception = new OCSException($exception->getMessage(), $exception->getStatus(), $exception); + throw $exception; + } return new JSONResponse([ 'status' => $exception->getStatus(), 'message' => $exception->getMessage() diff --git a/lib/Service/CommentService.php b/lib/Service/CommentService.php new file mode 100644 index 000000000..554108f67 --- /dev/null +++ b/lib/Service/CommentService.php @@ -0,0 +1,191 @@ + + * + * @author Julius Härtl + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Deck\Service; + + +use OCA\Deck\AppInfo\Application; +use OCA\Deck\BadRequestException; +use OCA\Deck\Db\Acl; +use OCA\Deck\Db\CardMapper; +use OCA\Deck\NoPermissionException; +use OCA\Deck\NotFoundException; +use OCA\Deck\StatusException; +use OCP\AppFramework\Http\DataResponse; +use OCP\Comments\IComment; +use OCP\Comments\ICommentsManager; +use OCP\Comments\MessageTooLongException; +use OCP\Comments\NotFoundException as CommentNotFoundException; +use OCP\ILogger; +use OCP\IUserManager; +use OutOfBoundsException; +use Sabre\DAV\Exception\Forbidden; +use function is_numeric; + +class CommentService { + + /** + * @var ICommentsManager + */ + private $commentsManager; + /** + * @var IUserManager + */ + private $userManager; + /** @var ILogger */ + private $logger; + private $userId; + + public function __construct(ICommentsManager $commentsManager, PermissionService $permissionService, CardMapper $cardMapper, IUserManager $userManager, ILogger $logger, $userId) { + $this->commentsManager = $commentsManager; + $this->permissionService = $permissionService; + $this->cardMapper = $cardMapper; + $this->userManager = $userManager; + $this->logger = $logger; + $this->userId = $userId; + } + + public function list(string $cardId, int $limit = 20, int $offset = 0): DataResponse { + if (!is_numeric($cardId)) { + throw new BadRequestException('A valid card id must be provided'); + } + $this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_READ); + $comments = $this->commentsManager->getForObject(Application::COMMENT_ENTITY_TYPE, $cardId, $limit, $offset); + $result = []; + foreach ($comments as $comment) { + $formattedComment = $this->formatComment($comment); + try { + if ($comment->getParentId() !== '0' && $replyTo = $this->commentsManager->get($comment->getParentId())) { + $formattedComment['replyTo'] = $this->formatComment($replyTo); + } + } catch (CommentNotFoundException $e) { + } + $result[] = $formattedComment; + } + return new DataResponse($result); + } + + /** + * @param string $cardId + * @param string $message + * @param string $replyTo + * @return DataResponse + * @throws BadRequestException + * @throws NotFoundException + */ + public function create(string $cardId, string $message, string $replyTo = '0'): DataResponse { + if (!is_numeric($cardId)) { + throw new BadRequestException('A valid card id must be provided'); + } + $this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_READ); + try { + $comment = $this->commentsManager->create('users', $this->userId, Application::COMMENT_ENTITY_TYPE, $cardId); + $comment->setMessage($message); + $comment->setVerb('comment'); + $comment->setParentId($replyTo); + $this->commentsManager->save($comment); + return new DataResponse($this->formatComment($comment)); + } catch (\InvalidArgumentException $e) { + throw new BadRequestException('Invalid input values'); + } catch (MessageTooLongException $e) { + $msg = 'Message exceeds allowed character limit of '; + throw new BadRequestException($msg . IComment::MAX_MESSAGE_LENGTH); + } catch (CommentNotFoundException $e) { + throw new NotFoundException('Could not create comment.'); + } + } + + public function update(string $cardId, string $commentId, string $message): DataResponse { + if (!is_numeric($cardId)) { + throw new BadRequestException('A valid card id must be provided'); + } + if (!is_numeric($commentId)) { + throw new BadRequestException('A valid comment id must be provided'); + } + $this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_READ); + try { + $comment = $this->commentsManager->get($commentId); + + } catch (CommentNotFoundException $e) { + throw new NotFoundException('No comment found.'); + } + if ($comment->getActorType() !== 'users' || $comment->getActorId() !== $this->userId) { + throw new NoPermissionException('Only authors are allowed to edit their comment.'); + } + $comment->setMessage($message); + $this->commentsManager->save($comment); + return new DataResponse($this->formatComment($comment)); + } + + public function delete(string $cardId, string $commentId): DataResponse { + if (!is_numeric($cardId)) { + throw new BadRequestException('A valid card id must be provided'); + } + if (!is_numeric($commentId)) { + throw new BadRequestException('A valid comment id must be provided'); + } + $this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_READ); + try { + $comment = $this->commentsManager->get($commentId); + + } catch (CommentNotFoundException $e) { + throw new NotFoundException('No comment found.'); + } + if ($comment->getActorType() !== 'users' || $comment->getActorId() !== $this->userId) { + throw new NoPermissionException('Only authors are allowed to edit their comment.'); + } + $this->commentsManager->delete($commentId); + return new DataResponse([]); + } + + private function formatComment(IComment $comment): array { + $user = $this->userManager->get($comment->getActorId()); + $actorDisplayName = $user !== null ? $user->getDisplayName() : $comment->getActorId(); + + return [ + 'id' => $comment->getId(), + 'objectId' => $comment->getObjectId(), + 'message' => $comment->getMessage(), + 'actorId' => $comment->getActorId(), + 'actorType' => $comment->getActorType(), + 'actorDisplayName' => $actorDisplayName, + 'creationDateTime' => $comment->getCreationDateTime()->format(\DateTime::ATOM), + 'mentions' => array_map(function($mention) { + try { + $displayName = $this->commentsManager->resolveDisplayName($mention['type'], $mention['id']); + } catch (OutOfBoundsException $e) { + $this->logger->logException($e); + // No displayname, upon client's discretion what to display. + $displayName = ''; + } + + return [ + 'mentionId' => $mention['id'], + 'mentionType' => $mention['type'], + 'mentionDisplayName' => $displayName + ]; + }, $comment->getMentions()), + ]; + } + +} diff --git a/package-lock.json b/package-lock.json index 1025f2c0e..eeda19e46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5663,6 +5663,11 @@ "integrity": "sha512-5am6HnnfN+urzt4yfg7IgTbotDjIT/u8AJpEt0sIU9FtXfVeezXAPKswrG+xKUCOYAINpSdgZVDU6QFh+cuH3w==", "dev": true }, + "blueimp-md5": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.12.0.tgz", + "integrity": "sha512-zo+HIdIhzojv6F1siQPqPFROyVy7C50KzHv/k/Iz+BtvtVzSHXiMXOpq2wCfNkeBqdCv+V8XOV96tsEt2W/3rQ==" + }, "bn.js": { "version": "4.11.8", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", diff --git a/package.json b/package.json index 030309e24..f6f2c22d8 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@nextcloud/moment": "^1.1.0", "@nextcloud/router": "^1.0.0", "@nextcloud/vue": "^1.4.0", + "blueimp-md5": "^2.12.0", "dompurify": "^2.0.8", "nextcloud-vue-collections": "^0.7.2", "url-search-params-polyfill": "^8.0.0", diff --git a/src/components/ActivityEntry.vue b/src/components/ActivityEntry.vue index 6a5e299ca..2d65d1ece 100644 --- a/src/components/ActivityEntry.vue +++ b/src/components/ActivityEntry.vue @@ -26,9 +26,10 @@
- {{ getTime(activity.datetime) }} + {{ relativeDate(activity.datetime) }}
+

@@ -38,6 +39,7 @@ import RichText from '@juliushaertl/vue-richtext' import { UserBubble } from '@nextcloud/vue' import moment from '@nextcloud/moment' import DOMPurify from 'dompurify' +import relativeDate from '../mixins/relativeDate' const InternalLink = { name: 'InternalLink', @@ -61,6 +63,7 @@ export default { components: { RichText, }, + mixins: [ relativeDate ], props: { activity: { type: Object, @@ -111,15 +114,7 @@ export default { sanitizedMessage() { return DOMPurify.sanitize(this.activity.message, { ALLOWED_TAGS: ['ins', 'del'], ALLOWED_ATTR: ['class'] }) }, - getTime() { - return (timestamp) => { - const diff = moment(this.$root.time).diff(moment(timestamp)) - if (diff >= 0 && diff < 45000) { - return t('core', 'seconds ago') - } - return moment(timestamp).fromNow() - } - }, + }, } diff --git a/src/components/card/CardSidebarTabComments.vue b/src/components/card/CardSidebarTabComments.vue index 4235e2c61..da438562b 100644 --- a/src/components/card/CardSidebarTabComments.vue +++ b/src/components/card/CardSidebarTabComments.vue @@ -7,6 +7,7 @@ +

    @@ -58,6 +59,7 @@ export default { computed: { ...mapState({ currentBoard: state => state.currentBoard, + replyTo: state => state.comment.replyTo, }), ...mapGetters([ 'getCommentsForCard', @@ -98,6 +100,7 @@ export default { comment: content, } await this.$store.dispatch('createComment', commentObj) + this.$store.dispatch('setReplyTo', null) this.newComment = '' await this.loadComments() }, diff --git a/src/components/card/CommentForm.vue b/src/components/card/CommentForm.vue index 64af6929d..430e54925 100644 --- a/src/components/card/CommentForm.vue +++ b/src/components/card/CommentForm.vue @@ -45,7 +45,7 @@ @keydown.enter="handleKeydown" @paste="onPaste" @blur="error = null" - @input="validate" /> + @input="validate()" /> 1000) { @@ -111,14 +111,13 @@ export default { return this.error === null ? content : null }, submit() { - const content = this.validate() + const content = this.validate(true) if (content) { this.$emit('input', content) this.$emit('submit', content) } }, - /** - * All credits for this go to the talk app + /* All credits for this go to the talk app * https://github.com/nextcloud/spreed/blob/e69740b372e17eec4541337b47baa262a5766510/src/components/NewMessageForm/NewMessageForm.vue#L100-L143 */ contentEditableToParsed() { @@ -133,7 +132,11 @@ export default { // adding it. // FIXME user names can contain spaces, in that case they need to be wrapped @"user name" [a-zA-Z0-9\ _\.@\-']+ const mentionValue = mention.firstElementChild.attributes['data-mention-id'].value - mention.replaceWith(' @' + mentionValue + ' ') + if (mentionValue.indexOf(' ') !== -1) { + mention.replaceWith(' @"' + mentionValue + '" ') + } else { + mention.replaceWith(' @' + mentionValue + ' ') + } }) return rawToParsed(node.innerHTML) diff --git a/src/components/card/CommentItem.vue b/src/components/card/CommentItem.vue index 2793623b7..e9171c5ba 100644 --- a/src/components/card/CommentItem.vue +++ b/src/components/card/CommentItem.vue @@ -1,29 +1,45 @@