From 5230dcb290f968436e03ebf01404c1d255b495f4 Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Mon, 13 Jul 2026 16:17:48 +0200 Subject: [PATCH 1/9] feat: add federated sharing Signed-off-by: Luka Trovic --- appinfo/routes.php | 6 + lib/Controller/CardOcsController.php | 109 +++-- lib/Controller/CommentsApiController.php | 25 +- lib/Controller/StackOcsController.php | 14 +- lib/Service/CardService.php | 2 +- lib/Service/CommentService.php | 5 +- lib/Service/ExternalBoardService.php | 149 +++++- .../card/CardSidebarTabComments.vue | 67 ++- src/services/CardApi.js | 30 +- src/services/CommentApi.js | 7 +- src/services/StackApi.js | 4 +- src/store/card.js | 427 ++++++++++++++++++ src/stores/comment.js | 7 +- 13 files changed, 758 insertions(+), 94 deletions(-) create mode 100644 src/store/card.js diff --git a/appinfo/routes.php b/appinfo/routes.php index 8a2b9be5fe..aa03a00858 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -140,17 +140,23 @@ ['name' => 'board_ocs#index', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'GET'], ['name' => 'board_ocs#read', 'url' => '/api/v{apiVersion}/board/{boardId}', 'verb' => 'GET'], ['name' => 'stack_ocs#index', 'url' => '/api/v{apiVersion}/stacks/{boardId}', 'verb' => 'GET'], + ['name' => 'stack_ocs#getArchived', 'url' => '/api/v{apiVersion}/stacks/{boardId}/archived', 'verb' => 'GET'], ['name' => 'board_ocs#create', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'POST'], ['name' => 'board_ocs#createForTeam', 'url' => '/api/v{apiVersion}/boards/team', 'verb' => 'POST'], ['name' => 'board_ocs#addAcl', 'url' => '/api/v{apiVersion}/boards/{boardId}/acl', 'verb' => 'POST'], ['name' => 'card_ocs#create', 'url' => '/api/v{apiVersion}/cards', 'verb' => 'POST'], ['name' => 'card_ocs#update', 'url' => '/api/v{apiVersion}/cards/{cardId}', 'verb' => 'PUT'], + ['name' => 'card_ocs#delete', 'url' => '/api/v{apiVersion}/cards/{cardId}', 'verb' => 'DELETE'], ['name' => 'card_ocs#assignLabel', 'url' => '/api/v{apiVersion}/cards/{cardId}/label/{labelId}', 'verb' => 'POST'], ['name' => 'card_ocs#assignUser', 'url' => '/api/v{apiVersion}/cards/{cardId}/assign', 'verb' => 'POST'], ['name' => 'card_ocs#unAssignUser', 'url' => '/api/v{apiVersion}/cards/{cardId}/unassign', 'verb' => 'PUT'], ['name' => 'card_ocs#removeLabel', 'url' => '/api/v{apiVersion}/cards/{cardId}/label/{labelId}', 'verb' => 'DELETE'], ['name' => 'card_ocs#reorder', 'url' => '/api/v{apiVersion}/cards/{cardId}/reorder', 'verb' => 'PUT'], + ['name' => 'card_ocs#archive', 'url' => '/api/v{apiVersion}/cards/{cardId}/archive', 'verb' => 'PUT'], + ['name' => 'card_ocs#unarchive', 'url' => '/api/v{apiVersion}/cards/{cardId}/unarchive', 'verb' => 'PUT'], + ['name' => 'card_ocs#done', 'url' => '/api/v{apiVersion}/cards/{cardId}/done', 'verb' => 'PUT'], + ['name' => 'card_ocs#undone', 'url' => '/api/v{apiVersion}/cards/{cardId}/undone', 'verb' => 'PUT'], ['name' => 'card_ocs#assignDependentCard', 'url' => '/api/v{apiVersion}/cards/{cardId}/dependentCards/{dependentCardId}', 'verb' => 'POST'], ['name' => 'card_ocs#removeDependentCard', 'url' => '/api/v{apiVersion}/cards/{cardId}/dependentCards/{dependentCardId}', 'verb' => 'DELETE'], diff --git a/lib/Controller/CardOcsController.php b/lib/Controller/CardOcsController.php index 6dbdb28138..67d932233c 100644 --- a/lib/Controller/CardOcsController.php +++ b/lib/Controller/CardOcsController.php @@ -21,12 +21,6 @@ use OCP\IRequest; class CardOcsController extends OCSController { - /** - * Sentinel order used when the client does not request a specific position. - * Cards created with this order are appended to the end of the stack. - */ - private const DEFAULT_ORDER = 999; - public function __construct( string $appName, IRequest $request, @@ -42,7 +36,7 @@ public function __construct( #[NoAdminRequired] #[PublicPage] - public function create(string $title, int $stackId, ?int $boardId = null, ?string $type = 'plain', ?string $owner = null, ?int $order = self::DEFAULT_ORDER, ?string $description = '', $duedate = null, $startdate = null, ?array $labels = [], ?array $users = [], ?string $color = null) { + public function create(string $title, int $stackId, ?int $boardId = null, ?string $type = 'plain', ?string $owner = null, ?int $order = 999, ?string $description = '', $duedate = null, $startdate = null, ?array $labels = [], ?array $users = [], ?string $color = null) { if ($boardId) { $board = $this->boardService->find($boardId, false); if ($board->getExternalId()) { @@ -54,8 +48,7 @@ public function create(string $title, int $stackId, ?int $boardId = null, ?strin if (!$owner) { $owner = $this->userId; } - // An explicit order means the client wants the card at that position, so shift the surrounding cards - $card = $this->cardService->create($title, $stackId, $type, $order, $owner, $description, $duedate, $startdate, $color, insertAtPosition: $order !== self::DEFAULT_ORDER); + $card = $this->cardService->create($title, $stackId, $type, $order, $owner, $description, $duedate, $startdate, $color); foreach ($labels ?? [] as $label) { $this->assignLabel($boardId, $card->getId(), $label); @@ -120,7 +113,7 @@ public function removeLabel(?int $boardId, int $cardId, int $labelId): DataRespo #[NoAdminRequired] #[PublicPage] - public function update(int $id, string $title, int $stackId, string $type, int $order, string $description, $duedate, $deletedAt, ?int $boardId, array|string|null $owner = null, $archived = null, $startdate = null): DataResponse { + public function update(int $id, string $title, int $stackId, string $type, int $order, string $description, $duedate, $deletedAt, int $boardId, array|string|null $owner = null, $archived = null, $startdate = null): DataResponse { $done = array_key_exists('done', $this->request->getParams()) ? new OptionalNullableValue($this->request->getParam('done', null)) : null; @@ -135,24 +128,24 @@ public function update(int $id, string $title, int $stackId, string $type, int $ } } - if ($boardId) { - $localBoard = $this->boardService->find($boardId, false); - if ($localBoard->getExternalId()) { - return new DataResponse($this->externalBoardService->updateCardOnRemote( - $localBoard, - $id, - $title, - $stackId, - $type, - $owner, - $description, - $order, - $duedate, - $deletedAt, - $archived, - $done - )); - } + $localBoard = $this->boardService->find($boardId, false); + if ($localBoard->getExternalId()) { + return new DataResponse($this->externalBoardService->updateCardOnRemote( + $localBoard, + $id, + $title, + $stackId, + $type, + $owner, + $description, + $order, + $duedate, + $deletedAt, + $archived, + $done, + $startdate, + $color, + )); } return new DataResponse($this->cardService->update($id, @@ -171,6 +164,18 @@ public function update(int $id, string $title, int $stackId, string $type, int $ )); } + #[NoAdminRequired] + #[PublicPage] + public function delete(int $cardId, ?int $boardId = null): DataResponse { + if ($boardId) { + $board = $this->boardService->find($boardId, false); + if ($board->getExternalId()) { + return new DataResponse($this->externalBoardService->deleteCardOnRemote($board, $cardId)); + } + } + return new DataResponse($this->cardService->delete($cardId)); + } + #[NoAdminRequired] #[PublicPage] public function reorder(int $cardId, int $stackId, int $order, ?int $boardId): DataResponse { @@ -183,6 +188,30 @@ public function reorder(int $cardId, int $stackId, int $order, ?int $boardId): D return new DataResponse($this->cardService->reorder($cardId, $stackId, $order)); } + #[NoAdminRequired] + #[PublicPage] + public function archive(int $cardId, int $boardId): DataResponse { + if ($boardId) { + $board = $this->boardService->find($boardId, false); + if ($board->getExternalId()) { + return new DataResponse($this->externalBoardService->archiveCardOnRemote($board, $cardId)); + } + } + return new DataResponse($this->cardService->archive($cardId)); + } + + #[NoAdminRequired] + #[PublicPage] + public function unarchive(int $cardId, int $boardId): DataResponse { + if ($boardId) { + $board = $this->boardService->find($boardId, false); + if ($board->getExternalId()) { + return new DataResponse($this->externalBoardService->unarchiveCardOnRemote($board, $cardId)); + } + } + return new DataResponse($this->cardService->unarchive($cardId)); + } + #[NoAdminRequired] #[PublicPage] public function assignDependentCard(int $cardId, int $dependentCardId, ?int $boardId = null): DataResponse { @@ -206,4 +235,28 @@ public function removeDependentCard(int $cardId, int $dependentCardId, ?int $boa } return new DataResponse($this->cardService->removeDependentCard($cardId, $dependentCardId)); } + + #[NoAdminRequired] + #[PublicPage] + public function done(int $cardId, ?int $boardId): DataResponse { + if ($boardId) { + $board = $this->boardService->find($boardId, false); + if ($board->getExternalId()) { + return new DataResponse($this->externalBoardService->setDoneCardOnRemote($board, $cardId)); + } + } + return new DataResponse($this->cardService->done($cardId)); + } + + #[NoAdminRequired] + #[PublicPage] + public function undone(int $cardId, ?int $boardId): DataResponse { + if ($boardId) { + $board = $this->boardService->find($boardId, false); + if ($board->getExternalId()) { + return new DataResponse($this->externalBoardService->setUndoneCardOnRemote($board, $cardId)); + } + } + return new DataResponse($this->cardService->undone($cardId)); + } } diff --git a/lib/Controller/CommentsApiController.php b/lib/Controller/CommentsApiController.php index 1794db5eae..db9bb8f589 100644 --- a/lib/Controller/CommentsApiController.php +++ b/lib/Controller/CommentsApiController.php @@ -7,9 +7,12 @@ namespace OCA\Deck\Controller; +use OCA\Deck\Service\BoardService; use OCA\Deck\Service\CommentService; +use OCA\Deck\Service\ExternalBoardService; use OCA\Deck\StatusException; use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; use OCP\IRequest; @@ -22,6 +25,9 @@ public function __construct( string $appName, IRequest $request, private CommentService $commentService, + private BoardService $boardService, + private ExternalBoardService $externalBoardService, + private ?string $userId, string $corsMethods = 'PUT, POST, GET, DELETE, PATCH', string $corsAllowedHeaders = 'Authorization, Content-Type, Accept', int $corsMaxAge = 1728000, @@ -33,7 +39,14 @@ public function __construct( * @throws StatusException */ #[NoAdminRequired] - public function list(int $cardId, int $limit = 20, int $offset = 0): DataResponse { + #[PublicPage] + public function list(int $cardId, int $limit = 20, int $offset = 0, ?int $boardId = null): DataResponse { + if ($boardId) { + $board = $this->boardService->find($boardId, false); + if ($board->getExternalId()) { + return new DataResponse($this->externalBoardService->getCardCommentsFromRemote($board, $cardId, $limit, $offset)); + } + } return $this->commentService->list($cardId, $limit, $offset); } @@ -41,7 +54,15 @@ public function list(int $cardId, int $limit = 20, int $offset = 0): DataRespons * @throws StatusException */ #[NoAdminRequired] - public function create(int $cardId, string $message, int $parentId = 0): DataResponse { + #[PublicPage] + public function create(int $cardId, string $message, int $parentId = 0, ?int $boardId = null): DataResponse { + if ($boardId) { + $board = $this->boardService->find($boardId, false); + if ($board->getExternalId()) { + return new DataResponse($this->externalBoardService->createCardCommentOnRemote($board, $cardId, $message, $parentId)); + } + } + return $this->commentService->create($cardId, $message, $parentId); } diff --git a/lib/Controller/StackOcsController.php b/lib/Controller/StackOcsController.php index cada24f8e5..ecf73ae175 100644 --- a/lib/Controller/StackOcsController.php +++ b/lib/Controller/StackOcsController.php @@ -48,7 +48,7 @@ public function create(string $title, int $boardId, int $order = 0):DataResponse } else { $stack = $this->stackService->create($title, $boardId, $order); return new DataResponse($stack); - }; + } } #[NoAdminRequired] @@ -91,4 +91,16 @@ public function reorder(int $stackId, int $order, ?int $boardId):DataResponse { return new DataResponse($stacks); } + #[NoAdminRequired] + #[PublicPage] + public function getArchived(int $boardId): DataResponse { + $board = $this->boardService->find($boardId, false); + if ($board->getExternalId()) { + $stacks = $this->externalBoardService->getArchivedStacksFromRemote($board); + return new DataResponse($stacks); + } + $stacks = $this->stackService->findAllArchived($boardId); + return new DataResponse($stacks); + } + } diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index 695a249d68..930a5eb0d0 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -87,7 +87,7 @@ public function enrichCards(array $cards): array { // TODO We should find a better way just to get the comment count so we can save 1-3 queries per card here $countComments = $this->commentsManager->getNumberOfCommentsForObject('deckCard', (string)$card->getId()); - $lastRead = $countComments > 0 ? $this->commentsManager->getReadMark('deckCard', (string)$card->getId(), $user) : null; + $lastRead = $countComments > 0 && $user ? $this->commentsManager->getReadMark('deckCard', (string)$card->getId(), $user) : null; $countUnreadComments = $lastRead ? $this->commentsManager->getNumberOfCommentsForObject('deckCard', (string)$card->getId(), $lastRead) : 0; $card->setCommentsUnread($countUnreadComments); $card->setCommentsCount($countComments); diff --git a/lib/Service/CommentService.php b/lib/Service/CommentService.php index e5d4bc140e..66487f64f5 100644 --- a/lib/Service/CommentService.php +++ b/lib/Service/CommentService.php @@ -18,6 +18,7 @@ use OCP\Comments\ICommentsManager; use OCP\Comments\MessageTooLongException; use OCP\Comments\NotFoundException as CommentNotFoundException; +use OCP\Federation\ICloudIdManager; use OCP\IUserManager; use OutOfBoundsException; use Psr\Log\LoggerInterface; @@ -30,6 +31,7 @@ public function __construct( private CardMapper $cardMapper, private IUserManager $userManager, private LoggerInterface $logger, + private ICloudIdManager $cloudIdManager, private ?string $userId, ) { } @@ -110,7 +112,7 @@ public function create(int $cardId, string $message, int $replyTo = 0): DataResp } try { - $comment = $this->commentsManager->create('users', $this->userId, Application::COMMENT_ENTITY_TYPE, (string)$cardId); + $comment = $this->commentsManager->create('users', $this->userId ?? $this->permissionService->getUserId(), Application::COMMENT_ENTITY_TYPE, (string)$cardId); $comment->setMessage($message); $comment->setVerb('comment'); $comment->setParentId((string)$replyTo); @@ -163,6 +165,7 @@ private function formatComment(IComment $comment, bool $addReplyTo = false): arr 'objectId' => (int)$comment->getObjectId(), 'message' => $comment->getMessage(), 'actorId' => $comment->getActorId(), + 'actorRemote' => $this->cloudIdManager->isValidCloudId($comment->getActorId()) ? $this->cloudIdManager->resolveCloudId($comment->getActorId())->getRemote() : null, 'actorType' => $comment->getActorType(), 'actorDisplayName' => $actorDisplayName, 'creationDateTime' => $comment->getCreationDateTime()->format(\DateTime::ATOM), diff --git a/lib/Service/ExternalBoardService.php b/lib/Service/ExternalBoardService.php index fd48517ec1..ce8b5ae239 100644 --- a/lib/Service/ExternalBoardService.php +++ b/lib/Service/ExternalBoardService.php @@ -85,7 +85,7 @@ public function LocalizeRemoteStacks(array $stacks, Board $localBoard) { $stack['cards'][$j]['assignedUsers'] = array_map(function ($assignment) use ($localBoard) { $assignment['participant'] = $this->localizeRemoteUser($localBoard, $assignment['participant']); return $assignment; - }, $card['assignedUsers']); + }, $card['assignedUsers'] ?? []); } $stacks[$i] = $stack; } @@ -110,6 +110,36 @@ public function localizeRemoteUsers(array $users, Board $localBoard) { return $localizedUsers; } + public function localizeRemoteComments(Board $localBoard, array $comments): array { + foreach ($comments as $i => $comment) { + $localizedActor = $this->localizeRemoteUser($localBoard, ['uid' => $comment['actorId'], 'remote' => $comment['actorRemote']]); + if ($localizedActor instanceof FederatedUser) { + $comments[$i]['actorDisplayName'] = $localizedActor->getCloudId()->getId(); + $comments[$i]['actorId'] = $localizedActor->getCloudId()->getId(); + $comments[$i]['actorRemote'] = $localizedActor->getCloudId()->getRemote(); + } + if ($localizedActor instanceof User) { + $comments[$i]['actorDisplayName'] = $localizedActor->getDisplayName(); + $comments[$i]['actorId'] = $localizedActor->getUID(); + $comments[$i]['actorRemote'] = null; + } + if ($comment['replyTo']) { + $localizedReplyActor = $this->localizeRemoteUser($localBoard, ['uid' => $comment['replyTo']['actorId'], 'remote' => $comment['replyTo']['actorRemote']]); + if ($localizedReplyActor instanceof FederatedUser) { + $comments[$i]['replyTo']['actorDisplayName'] = $localizedReplyActor->getCloudId()->getId(); + $comments[$i]['replyTo']['actorId'] = $localizedReplyActor->getCloudId()->getId(); + $comments[$i]['replyTo']['actorRemote'] = $localizedReplyActor->getCloudId()->getRemote(); + } + if ($localizedReplyActor instanceof User) { + $comments[$i]['replyTo']['actorDisplayName'] = $localizedReplyActor->getDisplayName(); + $comments[$i]['replyTo']['actorId'] = $localizedReplyActor->getUID(); + $comments[$i]['replyTo']['actorRemote'] = null; + } + } + } + return $comments; + } + public function createCardOnRemote( Board $localBoard, string $title, @@ -154,6 +184,8 @@ public function updateCardOnRemote( ?int $deletedAt = null, ?bool $archived = null, ?OptionalNullableValue $done = null, + ?string $startdate = null, + ?string $color = null, ): array { $this->configService->ensureFederationEnabled(); $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_EDIT, $this->userId, false, false); @@ -173,12 +205,26 @@ public function updateCardOnRemote( 'deletedAt' => $deletedAt, 'archived' => $archived, 'done' => $done->getValue() ?? null, + 'startdate' => $startdate, + 'color' => $color, 'boardId' => $localBoard->getExternalId(), ]; $resp = $this->proxy->put($participantCloudId->getId(), $shareToken, $url, $params); return $this->proxy->getOcsData($resp); } + public function deleteCardOnRemote(Board $localBoard, int $cardId): array { + $this->configService->ensureFederationEnabled(); + $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_EDIT, $this->userId, false, false); + $shareToken = $localBoard->getShareToken(); + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId; + $resp = $this->proxy->delete($ownerCloudId->getId(), $shareToken, $url, [ + 'boardId' => $localBoard->getExternalId(), + ]); + return $this->proxy->getOcsData($resp); + } + public function assignLabelOnRemote(Board $localBoard, int $cardId, int $labelId): array { $this->configService->ensureFederationEnabled(); $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_EDIT, $this->userId, false, false); @@ -351,4 +397,105 @@ public function reorderStackOnRemote(Board $localBoard, int $stackId, int $order $resp = $this->proxy->put($participantCloudId->getId(), $shareToken, $url, $params); return $this->proxy->getOcsData($resp); } + + public function getArchivedStacksFromRemote(Board $localBoard): array { + $this->configService->ensureFederationEnabled(); + $shareToken = $localBoard->getShareToken(); + $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/stacks/' . $localBoard->getExternalId() . '/archived'; + $resp = $this->proxy->get($participantCloudId->getId(), $shareToken, $url); + $ocs = $this->proxy->getOCSData($resp); + return $this->LocalizeRemoteStacks($ocs, $localBoard); + } + + public function archiveCardOnRemote(Board $localBoard, int $cardId): array { + $this->configService->ensureFederationEnabled(); + $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_EDIT, $this->userId, false, false); + $shareToken = $localBoard->getShareToken(); + $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId . '/archive'; + $params = [ + 'boardId' => $localBoard->getExternalId(), + ]; + $resp = $this->proxy->put($participantCloudId->getId(), $shareToken, $url, $params); + return $this->proxy->getOcsData($resp); + } + + public function unarchiveCardOnRemote(Board $localBoard, int $cardId): array { + $this->configService->ensureFederationEnabled(); + $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_EDIT, $this->userId, false, false); + $shareToken = $localBoard->getShareToken(); + $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId . '/unarchive'; + $params = [ + 'boardId' => $localBoard->getExternalId(), + ]; + $resp = $this->proxy->put($participantCloudId->getId(), $shareToken, $url, $params); + return $this->proxy->getOcsData($resp); + } + + public function setDoneCardOnRemote(Board $localBoard, int $cardId): array { + $this->configService->ensureFederationEnabled(); + $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_EDIT, $this->userId, false, false); + $shareToken = $localBoard->getShareToken(); + $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId . '/done'; + $params = [ + 'boardId' => $localBoard->getExternalId(), + ]; + $resp = $this->proxy->put($participantCloudId->getId(), $shareToken, $url, $params); + return $this->proxy->getOcsData($resp); + } + + public function setUndoneCardOnRemote(Board $localBoard, int $cardId): array { + $this->configService->ensureFederationEnabled(); + $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_EDIT, $this->userId, false, false); + $shareToken = $localBoard->getShareToken(); + $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId . '/undone'; + $params = [ + 'boardId' => $localBoard->getExternalId(), + ]; + $resp = $this->proxy->put($participantCloudId->getId(), $shareToken, $url, $params); + return $this->proxy->getOcsData($resp); + } + + public function getCardCommentsFromRemote(Board $localBoard, int $cardId, int $limit = 20, int $offset = 0): array { + $this->configService->ensureFederationEnabled(); + $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_READ, $this->userId, false, false); + $shareToken = $localBoard->getShareToken(); + $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId . '/comments'; + $params = [ + 'boardId' => $localBoard->getExternalId(), + 'limit' => $limit, + 'offset' => $offset, + ]; + $resp = $this->proxy->get($participantCloudId->getId(), $shareToken, $url, $params); + $comments = $this->proxy->getOcsData($resp); + return $this->localizeRemoteComments($localBoard, $comments); + } + + public function createCardCommentOnRemote(Board $localBoard, int $cardId, string $message, int $parentId = 0): DataResponse { + $this->configService->ensureFederationEnabled(); + $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_READ, $this->userId, false, false); + $shareToken = $localBoard->getShareToken(); + $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId . '/comments'; + $params = [ + 'boardId' => $localBoard->getExternalId(), + 'message' => $message, + 'parentId' => $parentId, + ]; + $resp = $this->proxy->post($participantCloudId->getId(), $shareToken, $url, $params); + $newComment = $this->proxy->getOcsData($resp); + return new DataResponse($this->localizeRemoteComments($localBoard, [$newComment])[0]); + } } diff --git a/src/components/card/CardSidebarTabComments.vue b/src/components/card/CardSidebarTabComments.vue index 7d72465c50..24380684eb 100644 --- a/src/components/card/CardSidebarTabComments.vue +++ b/src/components/card/CardSidebarTabComments.vue @@ -3,7 +3,7 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> diff --git a/src/services/CardApi.js b/src/services/CardApi.js index 327b3dbffe..6868b7a0f7 100644 --- a/src/services/CardApi.js +++ b/src/services/CardApi.js @@ -49,11 +49,11 @@ export class CardApi { }) } - deleteCard(cardId) { - return axios.delete(this.url(`/cards/${cardId}`)) + deleteCard(card, boardId) { + return axios.delete(this.ocsUrl(`/cards/${card.id}?boardId=${boardId}`)) .then( (response) => { - return Promise.resolve(response.data) + return Promise.resolve(response.data.ocs.data) }, (err) => { return Promise.reject(err) @@ -139,11 +139,11 @@ export class CardApi { }) } - archiveCard(card) { - return axios.put(this.url(`/cards/${card.id}/archive`)) + archiveCard(card, boardId) { + return axios.put(this.ocsUrl(`/cards/${card.id}/archive`), { boardId }) .then( (response) => { - return Promise.resolve(response.data) + return Promise.resolve(response.data.ocs.data) }, (err) => { return Promise.reject(err) @@ -154,11 +154,11 @@ export class CardApi { }) } - unArchiveCard(card) { - return axios.put(this.url(`/cards/${card.id}/unarchive`)) + unArchiveCard(card, boardId) { + return axios.put(this.ocsUrl(`/cards/${card.id}/unarchive`), { boardId }) .then( (response) => { - return Promise.resolve(response.data) + return Promise.resolve(response.data.ocs.data) }, (err) => { return Promise.reject(err) @@ -169,11 +169,11 @@ export class CardApi { }) } - markCardAsDone(card) { - return axios.put(this.url(`/cards/${card.id}/done`)) + markCardAsDone(card, boardId) { + return axios.put(this.ocsUrl(`/cards/${card.id}/done`), { boardId }) .then( (response) => { - return Promise.resolve(response.data) + return Promise.resolve(response.data.ocs.data) }, (err) => { return Promise.reject(err) @@ -184,11 +184,11 @@ export class CardApi { }) } - markCardAsUndone(card) { - return axios.put(this.url(`/cards/${card.id}/undone`)) + markCardAsUndone(card, boardId) { + return axios.put(this.ocsUrl(`/cards/${card.id}/undone`), { boardId }) .then( (response) => { - return Promise.resolve(response.data) + return Promise.resolve(response.data.ocs.data) }, (err) => { return Promise.reject(err) diff --git a/src/services/CommentApi.js b/src/services/CommentApi.js index 216d6bafec..421533555c 100644 --- a/src/services/CommentApi.js +++ b/src/services/CommentApi.js @@ -12,18 +12,19 @@ export class CommentApi { return generateRemoteUrl(url) } - async loadComments({ cardId, limit, offset }) { + async loadComments({ cardId, limit, offset, boardId }) { const api = await axios.get(generateOcsUrl(`apps/deck/api/v1.0/cards/${cardId}/comments`), { - params: { limit, offset }, + params: { limit, offset, boardId }, headers: { 'OCS-APIRequest': 'true' }, }) return api.data.ocs.data } - async createComment({ cardId, comment, replyTo }) { + async createComment({ cardId, comment, replyTo, boardId }) { const api = await axios.post(generateOcsUrl(`apps/deck/api/v1.0/cards/${cardId}/comments`), { message: `${comment}`, parentId: replyTo ? replyTo.id : null, + boardId: boardId, }) return api.data.ocs.data } diff --git a/src/services/StackApi.js b/src/services/StackApi.js index 67b464d9c6..c5850009ed 100644 --- a/src/services/StackApi.js +++ b/src/services/StackApi.js @@ -47,10 +47,10 @@ export class StackApi { } loadArchivedStacks(boardId) { - return axios.get(this.url(`/stacks/${boardId}/archived`)) + return axios.get(this.ocsUrl(`/stacks/${boardId}/archived`)) .then( (response) => { - return Promise.resolve(response.data) + return Promise.resolve(response.data.ocs.data) }, (err) => { return Promise.reject(err) diff --git a/src/store/card.js b/src/store/card.js new file mode 100644 index 0000000000..a6ad1ae1ef --- /dev/null +++ b/src/store/card.js @@ -0,0 +1,427 @@ +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { CardApi } from './../services/CardApi.js' +import moment from 'moment' +import Vue from 'vue' +import { useTrashbinStore } from '../stores/trashbin.js' + +const apiClient = new CardApi() + +/** + * + */ +export default function cardModuleFactory() { + return { + state: { + cards: [], + }, + getters: { + cardsByStack: (state, getters, rootState) => (id) => { + return state.cards.filter((card) => { + const { tags, users, due, unassigned, completed } = rootState.filter + + if (completed === 'open' && card.done !== null) { + return false + } + if (completed === 'completed' && card.done == null) { + return false + } + let allTagsMatch = true + + if (tags.length > 0) { + tags.forEach((tag) => { + if (card.labels.findIndex((l) => l.id === tag) === -1) { + allTagsMatch = false + } + }) + if (!allTagsMatch) { + return false + } + } + + if (users.length > 0) { + const anyUserMatch = !card?.assignedUsers + ? false + : users.some((user) => card.assignedUsers.findIndex((u) => u.participant.uid === user) !== -1) + if (!anyUserMatch) { + return false + } + } + + if (unassigned && card.assignedUsers.length > 0) { + return false + } + + if (due !== '') { + const datediffHour = ((new Date(card.duedate) - new Date()) / 3600 / 1000) + switch (due) { + case 'noDue': + return (card.duedate === null) + case 'overdue': + return (card.overdue === 3) + case 'dueToday': + return (card.overdue >= 2) + case 'dueWeek': + return (datediffHour <= 7 * 24 && card.duedate !== null) + case 'dueMonth': + return (datediffHour <= 30 * 24 && card.duedate !== null) + } + } + + return true + }) + .filter((card) => card.stackId === id) + .filter((card) => { + if (getters.getSearchQuery === '') { + return true + } + + let hasMatch = true + const matches = getters.getSearchQuery.match(/(?:[^\s"]+|"[^"]*")+/g) + + const filterOutQuotes = (q) => { + if (q[0] === '"' && q[q.length - 1] === '"') { + return q.slice(1, -1) + } + return q + } + for (const match of matches) { + let [filter, query] = match.indexOf(':') !== -1 ? match.split(/:(.*)/) : [null, match] + const isEmptyQuery = typeof query === 'undefined' || filterOutQuotes(query) === '' + + if (filter === 'title') { + if (isEmptyQuery) { + continue + } + hasMatch = hasMatch && card.title.toLowerCase().includes(filterOutQuotes(query).toLowerCase()) + } else if (filter === 'description') { + if (isEmptyQuery) { + hasMatch = hasMatch && !!card.description + continue + } + hasMatch = hasMatch && card.description.toLowerCase().includes(filterOutQuotes(query).toLowerCase()) + } else if (filter === 'list') { + if (isEmptyQuery) { + continue + } + const stack = getters.stackById(card.stackId) + if (!stack) { + return false + } + hasMatch = hasMatch && stack.title.toLowerCase().includes(filterOutQuotes(query).toLowerCase()) + } else if (filter === 'tag') { + if (isEmptyQuery) { + hasMatch = hasMatch && card.labels.length > 0 + continue + } + hasMatch = hasMatch && card.labels.findIndex((label) => label.title.toLowerCase().includes(filterOutQuotes(query).toLowerCase())) !== -1 + } else if (filter === 'date') { + const datediffHour = ((new Date(card.duedate) - new Date()) / 3600 / 1000) + query = filterOutQuotes(query) + switch (query) { + case 'overdue': + hasMatch = hasMatch && (card.overdue === 3) + break + case 'today': + hasMatch = hasMatch && (datediffHour > 0 && datediffHour <= 24 && card.duedate !== null) + break + case 'week': + hasMatch = hasMatch && (datediffHour > 0 && datediffHour <= 7 * 24 && card.duedate !== null) + break + case 'month': + hasMatch = hasMatch && (datediffHour > 0 && datediffHour <= 30 * 24 && card.duedate !== null) + break + case 'none': + hasMatch = hasMatch && (card.duedate === null) + break + } + + if (card.duedate === null || !hasMatch) { + return false + } + const comparator = query[0] + (query[1] === '=' ? '=' : '') + const isValidComparator = ['<', '<=', '>', '>='].indexOf(comparator) !== -1 + const parsedCardDate = moment(card.duedate) + const parsedDate = moment(query.slice(isValidComparator ? comparator.length : 0)) + switch (comparator) { + case '<': + hasMatch = hasMatch && parsedCardDate.isBefore(parsedDate) + break + case '<=': + hasMatch = hasMatch && parsedCardDate.isSameOrBefore(parsedDate) + break + case '>': + hasMatch = hasMatch && parsedCardDate.isAfter(parsedDate) + break + case '>=': + hasMatch = hasMatch && parsedCardDate.isSameOrAfter(parsedDate) + break + default: + hasMatch = hasMatch && parsedCardDate.isSame(parsedDate) + break + } + + } else if (filter === 'assigned') { + if (isEmptyQuery) { + hasMatch = hasMatch && card.assignedUsers.length > 0 + continue + } + hasMatch = hasMatch && card.assignedUsers.findIndex((assignment) => { + return assignment.participant.primaryKey.toLowerCase() === filterOutQuotes(query).toLowerCase() + || assignment.participant.displayname.toLowerCase() === filterOutQuotes(query).toLowerCase() + }) !== -1 + } else { + hasMatch = hasMatch && (card.title.toLowerCase().includes(filterOutQuotes(match).toLowerCase()) + || card.description.toLowerCase().includes(filterOutQuotes(match).toLowerCase()) || card.id === parseInt(filterOutQuotes(match))) + } + if (!hasMatch) { + return false + } + } + return true + }) + .sort((a, b) => a.order - b.order || a.createdAt - b.createdAt) + }, + cardById: state => (id) => { + return state.cards.find((card) => card.id === id) + }, + }, + mutations: { + addCard(state, card) { + card.labels = card.labels || [] + card.assignedUsers = card.assignedUsers || [] + const existingIndex = state.cards.findIndex(_card => _card.id === card.id) + if (existingIndex !== -1) { + const existingCard = state.cards[existingIndex] + Vue.set(state.cards, existingIndex, Object.assign({}, existingCard, card)) + } else { + state.cards.push(card) + } + }, + deleteCard(state, card) { + const existingIndex = state.cards.findIndex(_card => _card.id === card.id) + if (existingIndex !== -1) { + state.cards.splice(existingIndex, 1) + } + }, + updateCard(state, card) { + const existingIndex = state.cards.findIndex(_card => _card.id === card.id) + if (existingIndex !== -1) { + Vue.set(state.cards, existingIndex, Object.assign({}, state.cards[existingIndex], card)) + } + }, + updateCardsReorder(state, cards) { + for (const newCard of cards) { + const existingIndex = state.cards.findIndex(_card => _card.id === newCard.id) + if (existingIndex !== -1) { + Vue.set(state.cards[existingIndex], 'order', newCard.order) + Vue.set(state.cards[existingIndex], 'stackId', newCard.stackId) + Vue.set(state.cards[existingIndex], 'done', newCard.done) + } + } + }, + assignCardToUser(state, user) { + const existingIndex = state.cards.findIndex(_card => _card.id === user.cardId) + if (existingIndex !== -1) { + state.cards[existingIndex].assignedUsers.push(user) + } + }, + removeUserFromCard(state, user) { + const existingIndex = state.cards.findIndex(_card => _card.id === user.cardId) + if (existingIndex !== -1) { + const foundIndex = state.cards[existingIndex].assignedUsers.findIndex(_user => _user.id === user.id) + if (foundIndex !== -1) { + state.cards[existingIndex].assignedUsers.splice(foundIndex, 1) + } + } + }, + updateCardProperty(state, { card, property }) { + const existingIndex = state.cards.findIndex(_card => _card.id === card.id) + if (existingIndex !== -1) { + Vue.set(state.cards[existingIndex], property, card[property]) + Vue.set(state.cards[existingIndex], 'lastModified', Date.now() / 1000) + } + }, + cardSetAttachmentCount(state, { cardId, count }) { + const existingIndex = state.cards.findIndex(_card => _card.id === cardId) + if (existingIndex !== -1) { + Vue.set(state.cards[existingIndex], 'attachmentCount', count) + } + }, + cardIncreaseAttachmentCount(state, cardId) { + const existingIndex = state.cards.findIndex(_card => _card.id === cardId) + if (existingIndex !== -1) { + Vue.set(state.cards[existingIndex], 'attachmentCount', state.cards[existingIndex].attachmentCount + 1) + } + }, + cardDecreaseAttachmentCount(state, cardId) { + const existingIndex = state.cards.findIndex(_card => _card.id === cardId) + if (existingIndex !== -1) { + Vue.set(state.cards[existingIndex], 'attachmentCount', state.cards[existingIndex].attachmentCount - 1) + } + }, + addNewCard(state, card) { + state.cards.push(card) + }, + setCards(state, cards) { + const deletedCards = state.cards.filter(_card => { + return cards.findIndex(c => _card.id === c.id) === -1 + }) + for (const card of deletedCards) { + this.commit('deleteCard', card) + } + for (const card of cards) { + this.commit('addCard', card) + } + }, + }, + actions: { + async cloneCard({ commit }, { cardId, targetStackId }) { + const createdCard = await apiClient.cloneCard(cardId, targetStackId) + commit('addCard', createdCard) + return createdCard + }, + async addCard({ commit }, card) { + const createdCard = await apiClient.addCard(card) + commit('addCard', createdCard) + return createdCard + }, + async updateCardTitle({ commit, getters }, card) { + const stack = getters.stackById(card.stackId) + const updatedCard = await apiClient.updateCard(card, stack.boardId) + commit('updateCardProperty', { property: 'title', card: updatedCard }) + commit('updateCardProperty', { property: 'referenceData', card: updatedCard }) + }, + async moveCard({ commit }, { card, oldBoardId }) { + const updatedCard = await apiClient.updateCard(card, oldBoardId) + commit('deleteCard', updatedCard) + }, + async reorderCard({ commit, getters }, card) { + let i = 0 + const newCards = [] + for (const c of getters.cardsByStack(card.stackId)) { + if (c.id === card.id) { + newCards.push(card) + } + if (i === card.order) { + i++ + } + if (c.id !== card.id) { + newCards.push({ ...c, order: i++ }) + } + } + newCards.push(card) + await commit('updateCardsReorder', newCards) + + const stack = getters.stackById(card.stackId) + apiClient.reorderCard(card, stack.boardId).then((cards) => { + commit('updateCardsReorder', Object.values(cards)) + }) + }, + async deleteCard({ commit, getters }, card) { + const stack = getters.stackById(card.stackId) + await apiClient.deleteCard(card, stack.boardId) + commit('deleteCard', card) + useTrashbinStore().moveCardToTrash(card) + }, + async archiveUnarchiveCard({ commit, getters }, card) { + let call = 'archiveCard' + if (card.archived === false) { + call = 'unArchiveCard' + } + + const stack = getters.stackById(card.stackId) + const updatedCard = await apiClient[call](card, stack.boardId) + commit('updateCard', updatedCard) + }, + async changeCardDoneStatus({ commit, dispatch, rootState, getters }, card) { + let call = 'markCardAsDone' + if (card.done === false) { + call = 'markCardAsUndone' + } + + const stack = getters.stackById(card.stackId) + const updatedCard = await apiClient[call](card, stack.boardId) + commit('updateCardProperty', { property: 'done', card: updatedCard }) + + if (card.done !== false) { + const cardStack = rootState.stack.stacks.find(s => s.id === card.stackId) + const doneStack = rootState.stack.stacks.find( + s => s.boardId === cardStack?.boardId && s.isDoneColumn, + ) + if (doneStack && card.stackId !== doneStack.id) { + await dispatch('reorderCard', { ...updatedCard, stackId: doneStack.id, order: 0 }) + } + } + }, + async assignCardToUser({ commit }, { card, assignee }) { + const boardId = this.state.currentBoard.id + const user = await apiClient.assignUser(card.id, assignee.userId, assignee.type, boardId) + commit('assignCardToUser', user) + }, + async removeUserFromCard({ commit }, { card, assignee }) { + const boardId = this.state.currentBoard.id + const user = await apiClient.removeUser(card.id, assignee.userId, assignee.type, boardId) + commit('removeUserFromCard', user) + }, + async addLabel({ commit }, data) { + data.boardId = this.state.currentBoard.id + await apiClient.assignLabelToCard(data) + commit('updateCardProperty', { property: 'labels', card: data.card }) + }, + async removeLabel({ commit }, data) { + data.boardId = this.state.currentBoard.id + await apiClient.removeLabelFromCard(data) + commit('updateCardProperty', { property: 'labels', card: data.card }) + }, + async assignDependentCard({ commit }, { card, dependentCard }) { + const boardId = this.state.currentBoard.id + const updatedCard = await apiClient.assignDependentCard(card.id, dependentCard.id, boardId) + commit('updateCardProperty', { property: 'dependentCards', card: updatedCard }) + }, + async removeDependentCard({ commit }, { card, dependentCardId }) { + const boardId = this.state.currentBoard.id + const updatedCard = await apiClient.removeDependentCard(card.id, dependentCardId, boardId) + commit('updateCardProperty', { property: 'dependentCards', card: updatedCard }) + }, + async updateCardDesc({ commit, getters }, card) { + const stack = getters.stackById(card.stackId) + const updatedCard = await apiClient.updateCard(card, stack.boardId) + commit('updateCardProperty', { property: 'description', card: updatedCard }) + }, + async updateCardDue({ commit, getters }, card) { + const stack = getters.stackById(card.stackId) + const updatedCard = await apiClient.updateCard(card, stack.boardId) + commit('updateCardProperty', { property: 'duedate', card: updatedCard }) + }, + async updateCardStartDate({ commit, getters }, card) { + const stack = getters.stackById(card.stackId) + const updatedCard = await apiClient.updateCard(card, stack.boardId) + commit('updateCardProperty', { property: 'startdate', card: updatedCard }) + }, + async updateCardDates({ commit, getters }, card) { + const stack = getters.stackById(card.stackId) + const updatedCard = await apiClient.updateCard(card, stack.boardId) + commit('updateCardProperty', { property: 'duedate', card: updatedCard }) + commit('updateCardProperty', { property: 'startdate', card: updatedCard }) + }, + async updateCardColor({ commit, getters }, card) { + const stack = getters.stackById(card.stackId) + const updatedCard = await apiClient.updateCard(card, stack.boardId) + commit('updateCardProperty', { property: 'color', card: updatedCard }) + }, + + addCardData({ commit }, cardData) { + const card = { ...cardData } + commit('addStack', card.relatedStack) + commit('addBoard', card.relatedBoard) + delete card.relatedStack + delete card.relatedBoard + commit('addCard', card) + }, + }, + } +} diff --git a/src/stores/comment.js b/src/stores/comment.js index 3ad0d3ae5a..4bbe9b48d5 100644 --- a/src/stores/comment.js +++ b/src/stores/comment.js @@ -68,11 +68,12 @@ export const useCommentStore = defineStore('comment', { setReplyTo(comment) { this.replyTo = comment }, - async fetchComments({ cardId, offset }) { + async fetchComments({ cardId, offset, boardId }) { const comments = await apiClient.loadComments({ cardId, limit: COMMENT_FETCH_LIMIT, offset: offset || 0, + boardId, }) this.addComments({ cardId, comments }) @@ -86,8 +87,8 @@ export const useCommentStore = defineStore('comment', { await this.fetchComments({ cardId }) await this.fetchComments({ cardId, offset: this.getCommentsForCard(cardId).length }) }, - async createComment({ cardId, comment }) { - await apiClient.createComment({ cardId, comment, replyTo: this.replyTo }) + async createComment({ cardId, comment, boardId }) { + await apiClient.createComment({ cardId, comment, replyTo: this.replyTo, boardId }) await this.fetchComments({ cardId }) }, }, From b21162e1066ac98c3d429e2676ea6abbdd3e7998 Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Fri, 17 Jul 2026 18:18:18 +0200 Subject: [PATCH 2/9] feat: add leaving board on remote & update users look and feel Signed-off-by: Luka Trovic --- appinfo/routes.php | 6 +- lib/Controller/BoardController.php | 7 +- lib/Controller/BoardOcsController.php | 18 + lib/Controller/CommentsApiController.php | 18 +- lib/Db/CardMapper.php | 12 +- lib/Federation/DeckFederationProxy.php | 19 +- lib/Service/BoardService.php | 3 +- lib/Service/CommentService.php | 6 +- lib/Service/ExternalBoardService.php | 70 ++- src/components/board/SharingTabSidebar.vue | 128 ++-- src/components/card/AssignmentSelector.vue | 63 +- src/components/card/CardSidebar.vue | 374 ++++++------ .../card/CardSidebarTabComments.vue | 2 +- src/components/card/CommentItem.vue | 64 +- src/components/cards/AvatarList.vue | 85 ++- src/helpers/xml.js | 1 + src/services/BoardApi.js | 4 +- src/services/CardApi.js | 2 +- src/services/CommentApi.js | 11 +- src/store/main.js | 554 ++++++++++++++++++ src/stores/comment.js | 18 +- 21 files changed, 1040 insertions(+), 425 deletions(-) create mode 100644 src/store/main.js diff --git a/appinfo/routes.php b/appinfo/routes.php index aa03a00858..e208a347ca 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -78,15 +78,14 @@ // api ['name' => 'board_api#index', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'GET'], - ['name' => 'board_api#create', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'POST'], ['name' => 'board_api#get', 'url' => '/api/v{apiVersion}/boards/{boardId}', 'verb' => 'GET'], + ['name' => 'board_api#create', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'POST'], ['name' => 'board_api#delete', 'url' => '/api/v{apiVersion}/boards/{boardId}', 'verb' => 'DELETE'], ['name' => 'board_api#update', 'url' => '/api/v{apiVersion}/boards/{boardId}', 'verb' => 'PUT'], ['name' => 'board_api#undo_delete', 'url' => '/api/v{apiVersion}/boards/{boardId}/undo_delete', 'verb' => 'POST'], ['name' => 'board_api#addAcl', 'url' => '/api/v{apiVersion}/boards/{boardId}/acl', 'verb' => 'POST'], ['name' => 'board_api#deleteAcl', 'url' => '/api/v{apiVersion}/boards/{boardId}/acl/{aclId}', 'verb' => 'DELETE'], ['name' => 'board_api#updateAcl', 'url' => '/api/v{apiVersion}/boards/{boardId}/acl/{aclId}', 'verb' => 'PUT'], - ['name' => 'board_api#clone', 'url' => '/api/v{apiVersion}/boards/{boardId}/clone', 'verb' => 'POST'], ['name' => 'board_import_api#getAllowedSystems', 'url' => '/api/v{apiVersion}/boards/import/getSystems','verb' => 'GET'], ['name' => 'board_import_api#getConfigSchema', 'url' => '/api/v{apiVersion}/boards/import/config/schema/{name}','verb' => 'GET'], @@ -142,8 +141,9 @@ ['name' => 'stack_ocs#index', 'url' => '/api/v{apiVersion}/stacks/{boardId}', 'verb' => 'GET'], ['name' => 'stack_ocs#getArchived', 'url' => '/api/v{apiVersion}/stacks/{boardId}/archived', 'verb' => 'GET'], ['name' => 'board_ocs#create', 'url' => '/api/v{apiVersion}/boards', 'verb' => 'POST'], - ['name' => 'board_ocs#createForTeam', 'url' => '/api/v{apiVersion}/boards/team', 'verb' => 'POST'], + ['name' => 'board_ocs#update', 'url' => '/api/v{apiVersion}/boards/{boardId}', 'verb' => 'PUT'], ['name' => 'board_ocs#addAcl', 'url' => '/api/v{apiVersion}/boards/{boardId}/acl', 'verb' => 'POST'], + ['name' => 'board_ocs#leave', 'url' => '/api/v{apiVersion}/boards/{boardId}/leave', 'verb' => 'POST'], ['name' => 'card_ocs#create', 'url' => '/api/v{apiVersion}/cards', 'verb' => 'POST'], ['name' => 'card_ocs#update', 'url' => '/api/v{apiVersion}/cards/{cardId}', 'verb' => 'PUT'], diff --git a/lib/Controller/BoardController.php b/lib/Controller/BoardController.php index 5c43ab7b12..5383e0adae 100644 --- a/lib/Controller/BoardController.php +++ b/lib/Controller/BoardController.php @@ -67,7 +67,12 @@ public function deleteUndo(int $boardId): Board { #[NoAdminRequired] public function leave(int $boardId) { - return $this->boardService->leave($boardId); + $localBoard = $this->boardService->find($boardId, true, true); + $result = $this->boardService->leave($boardId); + if ($localBoard->getExternalId() !== null) { + $this->externalBoardService->leaveBoardOnRemote($localBoard); + } + return $result; } #[NoAdminRequired] diff --git a/lib/Controller/BoardOcsController.php b/lib/Controller/BoardOcsController.php index fcfe09adbf..4d6d3f13b4 100644 --- a/lib/Controller/BoardOcsController.php +++ b/lib/Controller/BoardOcsController.php @@ -57,6 +57,18 @@ public function createForTeam(string $title, string $teamId, ?string $color = nu return new DataResponse($this->boardService->createForTeam($title, $this->userId, $color, $teamId)); } + #[NoAdminRequired] + #[PublicPage] + public function update(int $boardId, string $title, string $color, bool $archived): DataResponse { + $updatedBoard = $this->boardService->update($boardId, $title, $color, $archived); + + if ($updatedBoard->getExternalId()) { + return $this->externalBoardService->updateBoardOnRemote($updatedBoard, $title, $color, $archived); + } + + return new DataResponse($updatedBoard); + } + #[NoAdminRequired] public function addAcl(int $boardId, int $type, string $participant, bool $permissionEdit, bool $permissionShare, bool $permissionManage, ?string $remote = null): DataResponse { return new DataResponse($this->boardService->addAcl($boardId, $type, $participant, $permissionEdit, $permissionShare, $permissionManage)); @@ -66,4 +78,10 @@ public function addAcl(int $boardId, int $type, string $participant, bool $permi public function updateAcl(int $id, bool $permissionEdit, bool $permissionShare, bool $permissionManage): DataResponse { return new DataResponse($this->boardService->updateAcl($id, $permissionEdit, $permissionShare, $permissionManage)); } + + #[NoAdminRequired] + #[PublicPage] + public function leave(int $boardId): DataResponse { + return new DataResponse($this->boardService->leave($boardId)); + } } diff --git a/lib/Controller/CommentsApiController.php b/lib/Controller/CommentsApiController.php index db9bb8f589..8885660c2f 100644 --- a/lib/Controller/CommentsApiController.php +++ b/lib/Controller/CommentsApiController.php @@ -70,7 +70,14 @@ public function create(int $cardId, string $message, int $parentId = 0, ?int $bo * @throws StatusException */ #[NoAdminRequired] - public function update(int $cardId, int $commentId, string $message): DataResponse { + #[PublicPage] + public function update(int $cardId, int $commentId, string $message, ?int $boardId = null): DataResponse { + if ($boardId) { + $board = $this->boardService->find($boardId, false); + if ($board->getExternalId()) { + return new DataResponse($this->externalBoardService->updateCardCommentOnRemote($board, $cardId, $commentId, $message)); + } + } return $this->commentService->update($cardId, $commentId, $message); } @@ -78,7 +85,14 @@ public function update(int $cardId, int $commentId, string $message): DataRespon * @throws StatusException */ #[NoAdminRequired] - public function delete(int $cardId, int $commentId): DataResponse { + #[PublicPage] + public function delete(int $cardId, int $commentId, ?int $boardId = null): DataResponse { + if ($boardId) { + $board = $this->boardService->find($boardId, false); + if ($board->getExternalId()) { + return new DataResponse($this->externalBoardService->deleteCardCommentOnRemote($board, $cardId, $commentId)); + } + } return $this->commentService->delete($cardId, $commentId); } } diff --git a/lib/Db/CardMapper.php b/lib/Db/CardMapper.php index 47f3e58af3..f47c7a8b70 100644 --- a/lib/Db/CardMapper.php +++ b/lib/Db/CardMapper.php @@ -14,6 +14,7 @@ use OCP\AppFramework\Db\Entity; use OCP\AppFramework\Db\QBMapper; use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Federation\ICloudIdManager; use OCP\ICache; use OCP\ICacheFactory; use OCP\IDBConnection; @@ -36,6 +37,8 @@ class CardMapper extends QBMapper implements IPermissionMapper { private $notificationManager; /** @var ICache */ private $cache; + /** @var ICloudIdManager */ + private $cloudIdManager; private $databaseType; private $database4ByteSupport; @@ -46,6 +49,7 @@ public function __construct( IGroupManager $groupManager, IManager $notificationManager, ICacheFactory $cacheFactory, + ICloudIdManager $cloudIdManager, $databaseType = 'sqlite3', $database4ByteSupport = true, ) { @@ -55,6 +59,7 @@ public function __construct( $this->groupManager = $groupManager; $this->notificationManager = $notificationManager; $this->cache = $cacheFactory->createDistributed('deck-cardMapper'); + $this->cloudIdManager = $cloudIdManager; $this->databaseType = $databaseType; $this->database4ByteSupport = $database4ByteSupport; } @@ -747,10 +752,15 @@ public function findBoardId(int $id): ?int { public function mapOwner(Card &$card) { $userManager = $this->userManager; - $card->resolveRelation('owner', function ($owner) use (&$userManager) { + $cloudIdManager = $this->cloudIdManager; + $card->resolveRelation('owner', function ($owner) use (&$userManager, &$cloudIdManager) { if ($userManager->userExists($owner)) { return new User($owner, $this->userManager); } + if ($cloudIdManager->isValidCloudId($owner)) { + $cloudId = $cloudIdManager->resolveCloudId($owner); + return new FederatedUser($cloudId); + } return null; }); } diff --git a/lib/Federation/DeckFederationProxy.php b/lib/Federation/DeckFederationProxy.php index 2ab77fd515..8386b918ca 100644 --- a/lib/Federation/DeckFederationProxy.php +++ b/lib/Federation/DeckFederationProxy.php @@ -10,6 +10,10 @@ use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\ServerException; use OC\Http\Client\Response; +use OCA\Deck\BadRequestException; +use OCA\Deck\NoPermissionException; +use OCA\Deck\NotFoundException; +use OCA\Deck\StatusException; use OCP\AppFramework\Http; use OCP\Http\Client\IClientService; use OCP\Http\Client\IResponse; @@ -60,7 +64,7 @@ protected function prependProtocolIfNotAvailable(string $url): string { /** * @param 'get'|'post'|'put'|'delete' $verb - * @throws \Exception + * @throws \Exception|StatusException */ protected function request( string $verb, @@ -96,7 +100,18 @@ protected function request( $clientException = new \Exception($e->getMessage(), $status, $e); $this->logger->debug('Client error from remote', ['exception' => $clientException]); - return new Response($e->getResponse(), false); + + switch ($status) { + case 400: + throw new BadRequestException($data['ocs']['meta']['message'] ?? 'Bad request'); + case 401: + case 403: + throw new NoPermissionException($data['ocs']['meta']['message'] ?? 'No permission'); + case 404: + throw new NotFoundException($data['ocs']['meta']['message'] ?? 'Not found'); + default: + return new Response($e->getResponse(), false); + } } catch (ServerException|\Throwable $e) { $serverException = new \Exception($e->getMessage(), $e->getCode(), $e); $this->logger->error('Could not reach remote', ['exception' => $serverException]); diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 2edb86a0b4..80864ca406 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -571,7 +571,8 @@ public function leave(int $boardId): ?Acl { throw new BadRequestException('Board owner cannot leave board'); } - $acl = $this->aclMapper->findParticipantFromBoard($boardId, Acl::PERMISSION_TYPE_USER, $this->userId); + $userId = $this->userId ?? $this->permissionService->getUserId(); + $acl = $this->aclMapper->findParticipantFromBoard($boardId, Acl::PERMISSION_TYPE_USER, $userId); if (!$acl) { throw new BadRequestException('Not a participant of this board'); diff --git a/lib/Service/CommentService.php b/lib/Service/CommentService.php index 66487f64f5..1cf7fdfa41 100644 --- a/lib/Service/CommentService.php +++ b/lib/Service/CommentService.php @@ -130,7 +130,8 @@ public function create(int $cardId, string $message, int $replyTo = 0): DataResp public function update(int $cardId, int $commentId, string $message): DataResponse { $comment = $this->get($cardId, $commentId); - if ($comment->getActorType() !== 'users' || $comment->getActorId() !== $this->userId) { + $userId = $this->userId ?? $this->permissionService->getUserId(); + if ($comment->getActorType() !== 'users' || $comment->getActorId() !== $userId) { throw new NoPermissionException('Only authors are allowed to edit their comment.'); } @@ -150,7 +151,8 @@ public function delete(int $cardId, int $commentId): DataResponse { } catch (CommentNotFoundException $e) { throw new NotFoundException('No comment found.'); } - if ($comment->getActorType() !== 'users' || $comment->getActorId() !== $this->userId) { + $userId = $this->userId ?? $this->permissionService->getUserId(); + if ($comment->getActorType() !== 'users' || $comment->getActorId() !== $userId) { throw new NoPermissionException('Only authors are allowed to edit their comment.'); } $this->commentsManager->delete((string)$commentId); diff --git a/lib/Service/ExternalBoardService.php b/lib/Service/ExternalBoardService.php index ce8b5ae239..7f3b6f9338 100644 --- a/lib/Service/ExternalBoardService.php +++ b/lib/Service/ExternalBoardService.php @@ -43,6 +43,15 @@ public function getExternalBoardFromRemote(Board $localBoard):DataResponse { $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/board/' . $localBoard->getExternalId(); $resp = $this->proxy->get($participantCloudId->getId(), $shareToken, $url); $ocs = $this->proxy->getOCSData($resp); + + // Sync local board data with remote data + if ($ocs['title'] !== $localBoard->getTitle() || $ocs['color'] !== $localBoard->getColor() || $ocs['archived'] !== $localBoard->isArchived()) { + $localBoard->setTitle($ocs['title']); + $localBoard->setColor($ocs['color']); + $localBoard->setArchived($ocs['archived']); + $this->boardMapper->update($localBoard); + } + return new DataResponse($this->LocalizeRemoteBoard($ocs, $localBoard)); } public function getExternalStacksFromRemote(Board $localBoard):DataResponse { @@ -86,6 +95,7 @@ public function LocalizeRemoteStacks(array $stacks, Board $localBoard) { $assignment['participant'] = $this->localizeRemoteUser($localBoard, $assignment['participant']); return $assignment; }, $card['assignedUsers'] ?? []); + $stack['cards'][$j]['owner'] = $this->localizeRemoteUser($localBoard, $card['owner']); } $stacks[$i] = $stack; } @@ -482,7 +492,7 @@ public function getCardCommentsFromRemote(Board $localBoard, int $cardId, int $l return $this->localizeRemoteComments($localBoard, $comments); } - public function createCardCommentOnRemote(Board $localBoard, int $cardId, string $message, int $parentId = 0): DataResponse { + public function createCardCommentOnRemote(Board $localBoard, int $cardId, string $message, int $parentId = 0): array { $this->configService->ensureFederationEnabled(); $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_READ, $this->userId, false, false); $shareToken = $localBoard->getShareToken(); @@ -496,6 +506,62 @@ public function createCardCommentOnRemote(Board $localBoard, int $cardId, string ]; $resp = $this->proxy->post($participantCloudId->getId(), $shareToken, $url, $params); $newComment = $this->proxy->getOcsData($resp); - return new DataResponse($this->localizeRemoteComments($localBoard, [$newComment])[0]); + return $this->localizeRemoteComments($localBoard, [$newComment])[0]; + } + + public function updateCardCommentOnRemote(Board $localBoard, int $cardId, int $commentId, string $message): array { + $this->configService->ensureFederationEnabled(); + $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_READ, $this->userId, false, false); + $shareToken = $localBoard->getShareToken(); + $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId . '/comments/' . $commentId; + $params = [ + 'boardId' => $localBoard->getExternalId(), + 'message' => $message, + ]; + $resp = $this->proxy->put($participantCloudId->getId(), $shareToken, $url, $params); + $updatedComment = $this->proxy->getOcsData($resp); + return $this->localizeRemoteComments($localBoard, [$updatedComment])[0]; + } + + public function deleteCardCommentOnRemote(Board $localBoard, int $cardId, int $commentId): array { + $this->configService->ensureFederationEnabled(); + $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_READ, $this->userId, false, false); + $shareToken = $localBoard->getShareToken(); + $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId . '/comments/' . $commentId; + $params = [ + 'boardId' => $localBoard->getExternalId(), + ]; + $resp = $this->proxy->delete($participantCloudId->getId(), $shareToken, $url, $params); + return $this->proxy->getOcsData($resp); + } + + public function updateBoardOnRemote(Board $localBoard, string $title, string $color, bool $archived): DataResponse { + $this->configService->ensureFederationEnabled(); + $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_MANAGE, $this->userId, false, false); + $shareToken = $localBoard->getShareToken(); + $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/boards/' . $localBoard->getExternalId(); + $params = [ + 'title' => $title, + 'color' => $color, + 'archived' => $archived, + ]; + $resp = $this->proxy->put($participantCloudId->getId(), $shareToken, $url, $params); + $updatedBoard = $this->proxy->getOcsData($resp); + return new DataResponse($this->LocalizeRemoteBoard($updatedBoard, $localBoard)); + } + + public function leaveBoardOnRemote(Board $localBoard): void { + $this->configService->ensureFederationEnabled(); + $shareToken = $localBoard->getShareToken(); + $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/boards/' . $localBoard->getExternalId() . '/leave'; + $this->proxy->post($participantCloudId->getId(), $shareToken, $url); } } diff --git a/src/components/board/SharingTabSidebar.vue b/src/components/board/SharingTabSidebar.vue index 02e0e67b81..31fa3e8825 100644 --- a/src/components/board/SharingTabSidebar.vue +++ b/src/components/board/SharingTabSidebar.vue @@ -4,67 +4,59 @@ --> - diff --git a/src/store/card.js b/src/store/card.js deleted file mode 100644 index a6ad1ae1ef..0000000000 --- a/src/store/card.js +++ /dev/null @@ -1,427 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { CardApi } from './../services/CardApi.js' -import moment from 'moment' -import Vue from 'vue' -import { useTrashbinStore } from '../stores/trashbin.js' - -const apiClient = new CardApi() - -/** - * - */ -export default function cardModuleFactory() { - return { - state: { - cards: [], - }, - getters: { - cardsByStack: (state, getters, rootState) => (id) => { - return state.cards.filter((card) => { - const { tags, users, due, unassigned, completed } = rootState.filter - - if (completed === 'open' && card.done !== null) { - return false - } - if (completed === 'completed' && card.done == null) { - return false - } - let allTagsMatch = true - - if (tags.length > 0) { - tags.forEach((tag) => { - if (card.labels.findIndex((l) => l.id === tag) === -1) { - allTagsMatch = false - } - }) - if (!allTagsMatch) { - return false - } - } - - if (users.length > 0) { - const anyUserMatch = !card?.assignedUsers - ? false - : users.some((user) => card.assignedUsers.findIndex((u) => u.participant.uid === user) !== -1) - if (!anyUserMatch) { - return false - } - } - - if (unassigned && card.assignedUsers.length > 0) { - return false - } - - if (due !== '') { - const datediffHour = ((new Date(card.duedate) - new Date()) / 3600 / 1000) - switch (due) { - case 'noDue': - return (card.duedate === null) - case 'overdue': - return (card.overdue === 3) - case 'dueToday': - return (card.overdue >= 2) - case 'dueWeek': - return (datediffHour <= 7 * 24 && card.duedate !== null) - case 'dueMonth': - return (datediffHour <= 30 * 24 && card.duedate !== null) - } - } - - return true - }) - .filter((card) => card.stackId === id) - .filter((card) => { - if (getters.getSearchQuery === '') { - return true - } - - let hasMatch = true - const matches = getters.getSearchQuery.match(/(?:[^\s"]+|"[^"]*")+/g) - - const filterOutQuotes = (q) => { - if (q[0] === '"' && q[q.length - 1] === '"') { - return q.slice(1, -1) - } - return q - } - for (const match of matches) { - let [filter, query] = match.indexOf(':') !== -1 ? match.split(/:(.*)/) : [null, match] - const isEmptyQuery = typeof query === 'undefined' || filterOutQuotes(query) === '' - - if (filter === 'title') { - if (isEmptyQuery) { - continue - } - hasMatch = hasMatch && card.title.toLowerCase().includes(filterOutQuotes(query).toLowerCase()) - } else if (filter === 'description') { - if (isEmptyQuery) { - hasMatch = hasMatch && !!card.description - continue - } - hasMatch = hasMatch && card.description.toLowerCase().includes(filterOutQuotes(query).toLowerCase()) - } else if (filter === 'list') { - if (isEmptyQuery) { - continue - } - const stack = getters.stackById(card.stackId) - if (!stack) { - return false - } - hasMatch = hasMatch && stack.title.toLowerCase().includes(filterOutQuotes(query).toLowerCase()) - } else if (filter === 'tag') { - if (isEmptyQuery) { - hasMatch = hasMatch && card.labels.length > 0 - continue - } - hasMatch = hasMatch && card.labels.findIndex((label) => label.title.toLowerCase().includes(filterOutQuotes(query).toLowerCase())) !== -1 - } else if (filter === 'date') { - const datediffHour = ((new Date(card.duedate) - new Date()) / 3600 / 1000) - query = filterOutQuotes(query) - switch (query) { - case 'overdue': - hasMatch = hasMatch && (card.overdue === 3) - break - case 'today': - hasMatch = hasMatch && (datediffHour > 0 && datediffHour <= 24 && card.duedate !== null) - break - case 'week': - hasMatch = hasMatch && (datediffHour > 0 && datediffHour <= 7 * 24 && card.duedate !== null) - break - case 'month': - hasMatch = hasMatch && (datediffHour > 0 && datediffHour <= 30 * 24 && card.duedate !== null) - break - case 'none': - hasMatch = hasMatch && (card.duedate === null) - break - } - - if (card.duedate === null || !hasMatch) { - return false - } - const comparator = query[0] + (query[1] === '=' ? '=' : '') - const isValidComparator = ['<', '<=', '>', '>='].indexOf(comparator) !== -1 - const parsedCardDate = moment(card.duedate) - const parsedDate = moment(query.slice(isValidComparator ? comparator.length : 0)) - switch (comparator) { - case '<': - hasMatch = hasMatch && parsedCardDate.isBefore(parsedDate) - break - case '<=': - hasMatch = hasMatch && parsedCardDate.isSameOrBefore(parsedDate) - break - case '>': - hasMatch = hasMatch && parsedCardDate.isAfter(parsedDate) - break - case '>=': - hasMatch = hasMatch && parsedCardDate.isSameOrAfter(parsedDate) - break - default: - hasMatch = hasMatch && parsedCardDate.isSame(parsedDate) - break - } - - } else if (filter === 'assigned') { - if (isEmptyQuery) { - hasMatch = hasMatch && card.assignedUsers.length > 0 - continue - } - hasMatch = hasMatch && card.assignedUsers.findIndex((assignment) => { - return assignment.participant.primaryKey.toLowerCase() === filterOutQuotes(query).toLowerCase() - || assignment.participant.displayname.toLowerCase() === filterOutQuotes(query).toLowerCase() - }) !== -1 - } else { - hasMatch = hasMatch && (card.title.toLowerCase().includes(filterOutQuotes(match).toLowerCase()) - || card.description.toLowerCase().includes(filterOutQuotes(match).toLowerCase()) || card.id === parseInt(filterOutQuotes(match))) - } - if (!hasMatch) { - return false - } - } - return true - }) - .sort((a, b) => a.order - b.order || a.createdAt - b.createdAt) - }, - cardById: state => (id) => { - return state.cards.find((card) => card.id === id) - }, - }, - mutations: { - addCard(state, card) { - card.labels = card.labels || [] - card.assignedUsers = card.assignedUsers || [] - const existingIndex = state.cards.findIndex(_card => _card.id === card.id) - if (existingIndex !== -1) { - const existingCard = state.cards[existingIndex] - Vue.set(state.cards, existingIndex, Object.assign({}, existingCard, card)) - } else { - state.cards.push(card) - } - }, - deleteCard(state, card) { - const existingIndex = state.cards.findIndex(_card => _card.id === card.id) - if (existingIndex !== -1) { - state.cards.splice(existingIndex, 1) - } - }, - updateCard(state, card) { - const existingIndex = state.cards.findIndex(_card => _card.id === card.id) - if (existingIndex !== -1) { - Vue.set(state.cards, existingIndex, Object.assign({}, state.cards[existingIndex], card)) - } - }, - updateCardsReorder(state, cards) { - for (const newCard of cards) { - const existingIndex = state.cards.findIndex(_card => _card.id === newCard.id) - if (existingIndex !== -1) { - Vue.set(state.cards[existingIndex], 'order', newCard.order) - Vue.set(state.cards[existingIndex], 'stackId', newCard.stackId) - Vue.set(state.cards[existingIndex], 'done', newCard.done) - } - } - }, - assignCardToUser(state, user) { - const existingIndex = state.cards.findIndex(_card => _card.id === user.cardId) - if (existingIndex !== -1) { - state.cards[existingIndex].assignedUsers.push(user) - } - }, - removeUserFromCard(state, user) { - const existingIndex = state.cards.findIndex(_card => _card.id === user.cardId) - if (existingIndex !== -1) { - const foundIndex = state.cards[existingIndex].assignedUsers.findIndex(_user => _user.id === user.id) - if (foundIndex !== -1) { - state.cards[existingIndex].assignedUsers.splice(foundIndex, 1) - } - } - }, - updateCardProperty(state, { card, property }) { - const existingIndex = state.cards.findIndex(_card => _card.id === card.id) - if (existingIndex !== -1) { - Vue.set(state.cards[existingIndex], property, card[property]) - Vue.set(state.cards[existingIndex], 'lastModified', Date.now() / 1000) - } - }, - cardSetAttachmentCount(state, { cardId, count }) { - const existingIndex = state.cards.findIndex(_card => _card.id === cardId) - if (existingIndex !== -1) { - Vue.set(state.cards[existingIndex], 'attachmentCount', count) - } - }, - cardIncreaseAttachmentCount(state, cardId) { - const existingIndex = state.cards.findIndex(_card => _card.id === cardId) - if (existingIndex !== -1) { - Vue.set(state.cards[existingIndex], 'attachmentCount', state.cards[existingIndex].attachmentCount + 1) - } - }, - cardDecreaseAttachmentCount(state, cardId) { - const existingIndex = state.cards.findIndex(_card => _card.id === cardId) - if (existingIndex !== -1) { - Vue.set(state.cards[existingIndex], 'attachmentCount', state.cards[existingIndex].attachmentCount - 1) - } - }, - addNewCard(state, card) { - state.cards.push(card) - }, - setCards(state, cards) { - const deletedCards = state.cards.filter(_card => { - return cards.findIndex(c => _card.id === c.id) === -1 - }) - for (const card of deletedCards) { - this.commit('deleteCard', card) - } - for (const card of cards) { - this.commit('addCard', card) - } - }, - }, - actions: { - async cloneCard({ commit }, { cardId, targetStackId }) { - const createdCard = await apiClient.cloneCard(cardId, targetStackId) - commit('addCard', createdCard) - return createdCard - }, - async addCard({ commit }, card) { - const createdCard = await apiClient.addCard(card) - commit('addCard', createdCard) - return createdCard - }, - async updateCardTitle({ commit, getters }, card) { - const stack = getters.stackById(card.stackId) - const updatedCard = await apiClient.updateCard(card, stack.boardId) - commit('updateCardProperty', { property: 'title', card: updatedCard }) - commit('updateCardProperty', { property: 'referenceData', card: updatedCard }) - }, - async moveCard({ commit }, { card, oldBoardId }) { - const updatedCard = await apiClient.updateCard(card, oldBoardId) - commit('deleteCard', updatedCard) - }, - async reorderCard({ commit, getters }, card) { - let i = 0 - const newCards = [] - for (const c of getters.cardsByStack(card.stackId)) { - if (c.id === card.id) { - newCards.push(card) - } - if (i === card.order) { - i++ - } - if (c.id !== card.id) { - newCards.push({ ...c, order: i++ }) - } - } - newCards.push(card) - await commit('updateCardsReorder', newCards) - - const stack = getters.stackById(card.stackId) - apiClient.reorderCard(card, stack.boardId).then((cards) => { - commit('updateCardsReorder', Object.values(cards)) - }) - }, - async deleteCard({ commit, getters }, card) { - const stack = getters.stackById(card.stackId) - await apiClient.deleteCard(card, stack.boardId) - commit('deleteCard', card) - useTrashbinStore().moveCardToTrash(card) - }, - async archiveUnarchiveCard({ commit, getters }, card) { - let call = 'archiveCard' - if (card.archived === false) { - call = 'unArchiveCard' - } - - const stack = getters.stackById(card.stackId) - const updatedCard = await apiClient[call](card, stack.boardId) - commit('updateCard', updatedCard) - }, - async changeCardDoneStatus({ commit, dispatch, rootState, getters }, card) { - let call = 'markCardAsDone' - if (card.done === false) { - call = 'markCardAsUndone' - } - - const stack = getters.stackById(card.stackId) - const updatedCard = await apiClient[call](card, stack.boardId) - commit('updateCardProperty', { property: 'done', card: updatedCard }) - - if (card.done !== false) { - const cardStack = rootState.stack.stacks.find(s => s.id === card.stackId) - const doneStack = rootState.stack.stacks.find( - s => s.boardId === cardStack?.boardId && s.isDoneColumn, - ) - if (doneStack && card.stackId !== doneStack.id) { - await dispatch('reorderCard', { ...updatedCard, stackId: doneStack.id, order: 0 }) - } - } - }, - async assignCardToUser({ commit }, { card, assignee }) { - const boardId = this.state.currentBoard.id - const user = await apiClient.assignUser(card.id, assignee.userId, assignee.type, boardId) - commit('assignCardToUser', user) - }, - async removeUserFromCard({ commit }, { card, assignee }) { - const boardId = this.state.currentBoard.id - const user = await apiClient.removeUser(card.id, assignee.userId, assignee.type, boardId) - commit('removeUserFromCard', user) - }, - async addLabel({ commit }, data) { - data.boardId = this.state.currentBoard.id - await apiClient.assignLabelToCard(data) - commit('updateCardProperty', { property: 'labels', card: data.card }) - }, - async removeLabel({ commit }, data) { - data.boardId = this.state.currentBoard.id - await apiClient.removeLabelFromCard(data) - commit('updateCardProperty', { property: 'labels', card: data.card }) - }, - async assignDependentCard({ commit }, { card, dependentCard }) { - const boardId = this.state.currentBoard.id - const updatedCard = await apiClient.assignDependentCard(card.id, dependentCard.id, boardId) - commit('updateCardProperty', { property: 'dependentCards', card: updatedCard }) - }, - async removeDependentCard({ commit }, { card, dependentCardId }) { - const boardId = this.state.currentBoard.id - const updatedCard = await apiClient.removeDependentCard(card.id, dependentCardId, boardId) - commit('updateCardProperty', { property: 'dependentCards', card: updatedCard }) - }, - async updateCardDesc({ commit, getters }, card) { - const stack = getters.stackById(card.stackId) - const updatedCard = await apiClient.updateCard(card, stack.boardId) - commit('updateCardProperty', { property: 'description', card: updatedCard }) - }, - async updateCardDue({ commit, getters }, card) { - const stack = getters.stackById(card.stackId) - const updatedCard = await apiClient.updateCard(card, stack.boardId) - commit('updateCardProperty', { property: 'duedate', card: updatedCard }) - }, - async updateCardStartDate({ commit, getters }, card) { - const stack = getters.stackById(card.stackId) - const updatedCard = await apiClient.updateCard(card, stack.boardId) - commit('updateCardProperty', { property: 'startdate', card: updatedCard }) - }, - async updateCardDates({ commit, getters }, card) { - const stack = getters.stackById(card.stackId) - const updatedCard = await apiClient.updateCard(card, stack.boardId) - commit('updateCardProperty', { property: 'duedate', card: updatedCard }) - commit('updateCardProperty', { property: 'startdate', card: updatedCard }) - }, - async updateCardColor({ commit, getters }, card) { - const stack = getters.stackById(card.stackId) - const updatedCard = await apiClient.updateCard(card, stack.boardId) - commit('updateCardProperty', { property: 'color', card: updatedCard }) - }, - - addCardData({ commit }, cardData) { - const card = { ...cardData } - commit('addStack', card.relatedStack) - commit('addBoard', card.relatedBoard) - delete card.relatedStack - delete card.relatedBoard - commit('addCard', card) - }, - }, - } -} diff --git a/src/store/main.js b/src/store/main.js deleted file mode 100644 index 7fe19246cc..0000000000 --- a/src/store/main.js +++ /dev/null @@ -1,554 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import 'url-search-params-polyfill' - -import { loadState } from '@nextcloud/initial-state' -import Vue from 'vue' -import Vuex from 'vuex' -import axios from '@nextcloud/axios' -import { generateOcsUrl, generateUrl } from '@nextcloud/router' -import { BoardApi } from '../services/BoardApi.js' -import stackModuleFactory from './stack.js' -import cardModuleFactory from './card.js' -Vue.use(Vuex) - -const apiClient = new BoardApi() -const debug = process.env.NODE_ENV !== 'production' - -export const BOARD_FILTERS = { - ALL: '', - ARCHIVED: 'archived', - SHARED: 'shared', -} - -/** - * - */ -export default function storeFactory() { - return new Vuex.Store({ - modules: { - stack: stackModuleFactory(), - card: cardModuleFactory(), - }, - strict: debug, - state: { - isFullApp: true, - config: loadState('deck', 'config', {}), - showArchived: false, - navShown: localStorage.getItem('deck.navShown') === null || localStorage.getItem('deck.navShown') === 'true', - compactMode: localStorage.getItem('deck.compactMode') === 'true', - showCardCover: localStorage.getItem('deck.showCardCover') === 'true', - sidebarShown: false, - currentBoard: null, - currentCard: null, - hasCardSaveError: false, - boards: loadState('deck', 'initialBoards', {}), - sharees: [], - assignableUsers: [], - boardFilter: BOARD_FILTERS.ALL, - searchQuery: '', - activity: [], - activityLoadMore: true, - filter: { tags: [], users: [], due: '', unassigned: false, completed: 'both' }, - shortcutLock: false, - viewModeByBoard: {}, - }, - getters: { - config: state => (key) => { - if (!state.isFullApp && key === 'cardDetailsInModal') { - return true - } - - return state.config[key] - }, - getSearchQuery: state => { - return state.searchQuery - }, - viewMode: state => { - if (!state.currentBoard) return 'kanban' - if (state.viewModeByBoard[state.currentBoard.id] !== undefined) { - return state.viewModeByBoard[state.currentBoard.id] - } - - const stored = localStorage.getItem(`deck.viewMode.${state.currentBoard.id}`) - return stored !== null ? stored : 'kanban' - }, - getFilter: state => { - return state.filter - }, - boards: state => { - return state.boards - }, - boardById: state => (id) => { - return state.boards.find((board) => board.id === id) - }, - assignables: state => { - return [ - ...state.assignableUsers.map((user) => ({ ...user, type: user.type })), - ...state.currentBoard.acl.filter((acl) => acl.type === 1 && typeof acl.participant === 'object').map((group) => ({ ...group.participant, type: 1 })), - ...state.currentBoard.acl.filter((acl) => acl.type === 7 && typeof acl.participant === 'object').map((circle) => ({ ...circle.participant, type: 7 })), - ] - }, - noneArchivedBoards: state => { - return state.boards.filter(board => { - return board.archived === false && !board.deletedAt - }) - }, - archivedBoards: state => { - return state.boards.filter(board => { - return board.archived === true && !board.deletedAt - }) - }, - sharedBoards: state => { - return state.boards.filter(board => { - return board.shared && !board.deletedAt - }) - }, - filteredBoards: state => { - // filters the boards depending on the active filter - const boards = state.boards.filter(board => { - return (state.boardFilter === BOARD_FILTERS.ALL && board.archived === false) - || (state.boardFilter === BOARD_FILTERS.ARCHIVED && board.archived === true) - || (state.boardFilter === BOARD_FILTERS.SHARED && board.shared === 1) - }) - return boards - }, - currentBoardLabels: state => { - return state.currentBoard ? state.currentBoard.labels : [] - }, - canEdit: state => { - return state.currentBoard ? state.currentBoard.permissions.PERMISSION_EDIT : false - }, - canManage: state => { - return state.currentBoard ? state.currentBoard.permissions.PERMISSION_MANAGE : false - }, - canShare: state => { - return state.currentBoard ? state.currentBoard.permissions.PERMISSION_SHARE : false - }, - isArchived: state => { - return state.currentBoard && state.currentBoard.archived - }, - }, - mutations: { - setFullApp(state, isFullApp) { - Vue.set(state, 'isFullApp', isFullApp) - }, - setHasCardSaveError(state, hasCardSaveError) { - Vue.set(state, 'hasCardSaveError', hasCardSaveError) - }, - SET_CONFIG(state, { key, value }) { - const [scope, id, configKey] = key.split(':', 3) - let indexExisting = -1 - switch (scope) { - case 'board': - indexExisting = state.boards.findIndex((b) => { - return id === '' + b.id - }) - - if (indexExisting > -1) { - Vue.set(state.boards[indexExisting].settings, configKey, value) - } - break - default: - Vue.set(state.config, key, value) - } - }, - setSearchQuery(state, searchQuery) { - state.searchQuery = searchQuery - }, - SET_FILTER(state, filter) { - Object.assign(state.filter, filter) - }, - TOGGLE_FILTER(state, filter) { - Object.keys(filter).forEach((key) => { - switch (key) { - case 'due': - Vue.set(state.filter, key, filter.due) - break - default: - filter[key].forEach((item) => { - if (state.filter[key].indexOf(item) === -1) { - state.filter[key].push(item) - } else { - state.filter[key].splice(state.filter[key].indexOf(item), 1) - } - }) - break - } - }) - }, - toggleShowArchived(state, newState = undefined) { - state.showArchived = newState !== undefined ? newState : !state.showArchived - }, - /* - * Adds or replaces a board in the store. - * Matches a board by it's id. - */ - addBoard(state, board) { - const indexExisting = state.boards.findIndex((b) => { - return board.id === b.id - }) - - if (indexExisting > -1) { - Vue.set(state.boards, indexExisting, board) - } else { - state.boards.push(board) - } - }, - - cloneBoard(state, board) { - const indexExisting = state.boards.findIndex((b) => { - return board.id === b.id - }) - - if (indexExisting > -1) { - Vue.set(state.boards, indexExisting, board) - } else { - state.boards.push(board) - } - }, - - /* - * Removes the board from the store. - */ - removeBoard(state, board) { - state.boards = state.boards.filter((b) => { - return board.id !== b.id - }) - }, - toggleNav(state, navState) { - state.navShown = navState - localStorage.setItem('deck.navShown', navState) - }, - toggleSidebar(state) { - state.sidebarShown = !state.sidebarShown - }, - toggleCompactMode(state) { - state.compactMode = !state.compactMode - localStorage.setItem('deck.compactMode', state.compactMode) - }, - toggleShowCardCover(state) { - state.showCardCover = !state.showCardCover - localStorage.setItem('deck.showCardCover', state.showCardCover) - }, - setBoards(state, boards) { - state.boards = boards - }, - setSharees(state, shareesUsersAndGroups) { - Vue.set(state, 'sharees', shareesUsersAndGroups) - }, - setAssignableUsers(state, users) { - state.assignableUsers = users - }, - setBoardFilter(state, filter) { - state.boardFilter = filter - }, - setCurrentBoard(state, board) { - state.currentBoard = board - }, - setCurrentCard(state, card) { - state.currentCard = card - }, - setBoard(state, board) { - const existingIndex = state.boards.findIndex(b => b?.id === board?.id) - if (existingIndex > -1) { - Vue.set(state.boards, existingIndex, board) - } - }, - - // label mutators - removeLabelFromCurrentBoard(state, labelId) { - const removeIndex = state.currentBoard.labels.findIndex((l) => { - return labelId === l.id - }) - - if (removeIndex > -1) { - state.currentBoard.labels.splice(removeIndex, 1) - } - }, - updateLabelFromCurrentBoard(state, newLabel) { - const labelToUpdate = state.currentBoard.labels.find((l) => { - return newLabel.id === l.id - }) - - labelToUpdate.title = newLabel.title - labelToUpdate.color = newLabel.color - }, - addLabelToCurrentBoard(state, newLabel) { - state.currentBoard.labels.push(newLabel) - }, - - // acl mutators - addAclToCurrentBoard(state, createdAcl) { - state.currentBoard.acl.push(createdAcl) - }, - updateAclFromCurrentBoard(state, acl) { - for (const acl_ in state.currentBoard.acl) { - if (state.currentBoard.acl[acl_].participant.uid === acl.participant.uid) { - Vue.set(state.currentBoard.acl, acl_, acl) - break - } - } - }, - deleteAclFromCurrentBoard(state, acl) { - let removeIndex = -1 - for (const index in state.currentBoard.acl) { - const attr = state.currentBoard.acl[index] - if (acl.id === attr.id) { - removeIndex = index - break - } - } - - if (removeIndex > -1) { - Vue.delete(state.currentBoard.acl, removeIndex) - } - }, - TOGGLE_SHORTCUT_LOCK(state, lock) { - state.shortcutLock = lock - }, - setViewMode(state, mode) { - if (!state.currentBoard) return - Vue.set(state.viewModeByBoard, state.currentBoard.id, mode) - localStorage.setItem(`deck.viewMode.${state.currentBoard.id}`, mode) - }, - }, - actions: { - setFullApp({ commit }, isFullApp) { - commit('setFullApp', isFullApp) - }, - async setConfig({ commit }, config) { - for (const key in config) { - try { - await axios.post(generateOcsUrl(`apps/deck/api/v1.0/config/${key}`), { - value: config[key], - }) - commit('SET_CONFIG', { key, value: config[key] }) - } catch (e) { - console.error(`Error while saving ${key}`, e.response) - throw e - } - } - }, - setFilter({ commit }, filter) { - commit('SET_FILTER', filter) - }, - toggleFilter({ commit }, filter) { - commit('TOGGLE_FILTER', filter) - }, - async loadBoardById({ commit, dispatch }, boardId) { - const filterReset = { tags: [], users: [], due: '', unassigned: false, completed: 'both' } - dispatch('setFilter', filterReset) - commit('setCurrentBoard', null) - const board = await apiClient.loadById(boardId) - commit('setCurrentBoard', board) - commit('setBoard', board) - commit('setAssignableUsers', board.users) - }, - - async refreshBoard({ commit, dispatch }, boardId) { - const board = await apiClient.loadById(boardId) - const etagHasChanged = board.ETag !== this.state.currentBoard.ETag - commit('setCurrentBoard', board) - commit('setAssignableUsers', board.users) - - if (etagHasChanged) { - dispatch('loadStacks', boardId) - } - }, - - toggleShowArchived({ commit }) { - commit('toggleShowArchived') - }, - - /** - * @param commit.commit - * @param commit - * @param state - * @param {Board} board - */ - archiveBoard({ commit }, board) { - const boardCopy = JSON.parse(JSON.stringify(board)) - boardCopy.archived = true - apiClient.updateBoard(boardCopy) - .then((board) => { - commit('addBoard', board) - }) - }, - /** - * @param commit.commit - * @param commit - * @param state - * @param {Board} board - */ - unarchiveBoard({ commit }, board) { - const boardCopy = JSON.parse(JSON.stringify(board)) - boardCopy.archived = false - apiClient.updateBoard(boardCopy) - .then((board) => { - commit('addBoard', board) - }) - }, - /** - * Updates a board API side. - * - * @param commit.commit - * @param commit - * @param commit.state - * @param board The board to update. - * @return {Promise} - */ - async updateBoard({ commit, state }, board) { - const storedBoard = await apiClient.updateBoard(board) - commit('addBoard', storedBoard) - - // keep the currently opened board title in sync after edits - if (state.currentBoard?.id === storedBoard.id) { - commit('setCurrentBoard', storedBoard) - } - }, - async createBoard({ commit }, boardData) { - try { - const board = await apiClient.createBoard(boardData) - commit('addBoard', board) - } catch (err) { - return err - } - }, - async importBoard({ commit }, file) { - try { - const board = await apiClient.importBoard(file) - commit('addBoard', board) - } catch (err) { - return err - } - }, - async cloneBoard({ commit }, { boardData, settings }) { - const { withCards, withAssignments, withLabels, withDueDate, moveCardsToLeftStack, restoreArchivedCards } = settings - - try { - const newBoard = await apiClient.cloneBoard(boardData, withCards, withAssignments, withLabels, withDueDate, moveCardsToLeftStack, restoreArchivedCards) - commit('cloneBoard', newBoard) - return newBoard - } catch (err) { - return err - } - }, - removeBoard({ commit }, board) { - commit('removeBoard', board) - }, - async loadBoards({ commit }) { - const boards = await apiClient.loadBoards() - commit('setBoards', boards) - }, - async loadSharees({ commit }, query) { - if (typeof query === 'undefined') { - return - } - const params = { - search: query, - itemType: 'deck', - shareTypes: [0, 1, 6, 7], - limit: 20, - } - - const response = await axios.get(generateOcsUrl('/core/autocomplete/get'), { params }) - commit('setSharees', response.data.ocs.data) - }, - - setBoardFilter({ commmit }, filter) { - commmit('setBoardFilter', filter) - }, - toggleNav({ commit }, navState) { - commit('toggleNav', navState) - }, - toggleSidebar({ commit }) { - commit('toggleSidebar') - }, - toggleCompactMode({ commit }) { - commit('toggleCompactMode') - }, - toggleShowCardCover({ commit }) { - commit('toggleShowCardCover') - }, - setCurrentBoard({ commit }, board) { - commit('setCurrentBoard', board) - }, - setAssignableUsers({ commit }, board) { - commit('setAssignableUsers', board) - }, - setCurrentCard({ commit }, card) { - commit('setCurrentCard', card) - }, - - // label actions - removeLabelFromCurrentBoard({ commit }, label) { - apiClient.deleteLabel(label) - .then((label) => { - commit('removeLabelFromCurrentBoard', label.id) - }) - }, - updateLabelFromCurrentBoard({ commit }, newLabel) { - apiClient.updateLabel(newLabel) - .then((newLabel) => { - commit('updateLabelFromCurrentBoard', newLabel) - }) - }, - addLabelToCurrentBoard({ commit }, newLabel) { - newLabel.boardId = this.state.currentBoard.id - apiClient.createLabel(newLabel) - .then((newLabel) => { - commit('addLabelToCurrentBoard', newLabel) - }) - }, - async addLabelToCurrentBoardAndCard({ dispatch, commit }, { newLabel, card }) { - newLabel.boardId = this.state.currentBoard.id - const label = await apiClient.createLabel(newLabel) - card.labels.push(label) - commit('addLabelToCurrentBoard', label) - dispatch('addLabel', { - card, - labelId: label.id, - }) - return label - }, - - // acl actions - async addAclToCurrentBoard({ dispatch, commit }, newAcl) { - newAcl.boardId = this.state.currentBoard.id - const result = await apiClient.addAcl(newAcl) - commit('addAclToCurrentBoard', result) - dispatch('refreshBoard', newAcl.boardId) - }, - updateAclFromCurrentBoard({ commit }, acl) { - acl.boardId = this.state.currentBoard.id - apiClient.updateAcl(acl) - .then((acl) => { - commit('updateAclFromCurrentBoard', acl) - }) - }, - deleteAclFromCurrentBoard({ dispatch, commit }, acl) { - acl.boardId = this.state.currentBoard.id - apiClient.deleteAcl(acl) - .then((acl) => { - commit('deleteAclFromCurrentBoard', acl) - dispatch('loadBoardById', acl.boardId) - }) - }, - async transferOwnership({ commit }, { boardId, newOwner }) { - await axios.put(generateUrl(`apps/deck/boards/${boardId}/transferOwner`), { - newOwner, - }) - }, - toggleShortcutLock({ commit }, lock) { - commit('TOGGLE_SHORTCUT_LOCK', lock) - }, - setViewMode({ commit }, mode) { - commit('setViewMode', mode) - }, - }, - }) -} diff --git a/src/stores/card.js b/src/stores/card.js index 666b042a0e..5deed8e912 100644 --- a/src/stores/card.js +++ b/src/stores/card.js @@ -301,7 +301,8 @@ export const useCardStore = defineStore('card', { this.deleteCardFromStore(updatedCard) }, async deleteCard(card) { - await apiClient.deleteCard(card.id) + const stack = useStackStore().stackById(card.stackId) + await apiClient.deleteCard(card, stack.boardId) this.deleteCardFromStore(card) useTrashbinStore().moveCardToTrash(card) }, @@ -329,7 +330,8 @@ export const useCardStore = defineStore('card', { call = 'unArchiveCard' } - const updatedCard = await apiClient[call](card) + const stack = useStackStore().stackById(card.stackId) + const updatedCard = await apiClient[call](card, stack.boardId) this.updateCard(updatedCard) }, async changeCardDoneStatus(card) { @@ -338,7 +340,8 @@ export const useCardStore = defineStore('card', { call = 'markCardAsUndone' } - const updatedCard = await apiClient[call](card) + const stack = useStackStore().stackById(card.stackId) + const updatedCard = await apiClient[call](card, stack.boardId) this.updateCardProperty({ property: 'done', card: updatedCard }) if (card.done !== false) { From 6a568d398ae5a3370d6a0f270d14ca59a3ed45f6 Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Tue, 6 Oct 2026 08:57:56 +0200 Subject: [PATCH 7/9] feat: implement remote attachment creation Signed-off-by: Luka Trovic --- appinfo/routes.php | 1 + lib/Controller/AttachmentOcsController.php | 21 +++- lib/Service/AttachmentService.php | 22 ++++ lib/Service/ExternalBoardService.php | 43 ++------ lib/Service/FilesAppService.php | 58 ++++++++--- lib/Sharing/DeckShareProvider.php | 115 ++++++++++++++++----- 6 files changed, 184 insertions(+), 76 deletions(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index ff86ee54ec..d82d7a8de7 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -169,6 +169,7 @@ ['name' => 'attachment_ocs#getAll', 'url' => '/api/v{apiVersion}/cards/{cardId}/attachments', 'verb' => 'GET'], ['name' => 'attachment_ocs#create', 'url' => '/api/v{apiVersion}/cards/{cardId}/attachment', 'verb' => 'POST'], + ['name' => 'attachment_ocs#acceptRemote', 'url' => '/api/v{apiVersion}/cards/{cardId}/remote-attachment', 'verb' => 'POST'], ['name' => 'attachment_ocs#update', 'url' => '/api/v{apiVersion}/cards/{cardId}/attachments/{attachmentId}', 'verb' => 'PUT'], ['name' => 'attachment_ocs#delete', 'url' => '/api/v{apiVersion}/cards/{cardId}/attachments/{type}:{attachmentId}', 'verb' => 'DELETE'], ['name' => 'attachment_ocs#restore', 'url' => '/api/v{apiVersion}/cards/{cardId}/attachments/{attachmentId}/restore', 'verb' => 'PUT'], diff --git a/lib/Controller/AttachmentOcsController.php b/lib/Controller/AttachmentOcsController.php index 734daef49b..ef89292afe 100644 --- a/lib/Controller/AttachmentOcsController.php +++ b/lib/Controller/AttachmentOcsController.php @@ -12,7 +12,9 @@ use OCA\Deck\NotImplementedException; use OCA\Deck\Service\AttachmentService; use OCA\Deck\Service\BoardService; +use OCA\Deck\Service\ExternalBoardService; use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; use OCP\IRequest; @@ -23,6 +25,7 @@ public function __construct( IRequest $request, private AttachmentService $attachmentService, private BoardService $boardService, + private ExternalBoardService $externalBoardService, ) { parent::__construct($appName, $request); } @@ -37,19 +40,33 @@ private function ensureLocalBoard(?int $boardId): void { } #[NoAdminRequired] + #[PublicPage] public function getAll(int $cardId, ?int $boardId = null): DataResponse { - $this->ensureLocalBoard($boardId); + $board = $this->boardService->find($boardId, false); + if ($board->getExternalId()) { + return new DataResponse($this->externalBoardService->getAttachmentsFromRemote($board, $cardId)); + } $attachment = $this->attachmentService->findAll($cardId, true); return new DataResponse($attachment); } #[NoAdminRequired] public function create(int $cardId, string $type, string $data = '', ?int $boardId = null): DataResponse { - $this->ensureLocalBoard($boardId); + $board = $this->boardService->find($boardId, false); + if ($board->getExternalId()) { + return new DataResponse($this->externalBoardService->createAttachmentForRemote($board, $cardId)); + } $attachment = $this->attachmentService->create($cardId, $type, $data); return new DataResponse($attachment); } + #[NoAdminRequired] + #[PublicPage] + public function acceptRemote(int $cardId, string $token): DataResponse { + $this->attachmentService->acceptRemoteAttachment($cardId, $token); + return new DataResponse([]); + } + #[NoAdminRequired] public function update(int $cardId, int $attachmentId, string $data, string $type = 'file', ?int $boardId = null): DataResponse { $this->ensureLocalBoard($boardId); diff --git a/lib/Service/AttachmentService.php b/lib/Service/AttachmentService.php index a871119f22..6a791a4be6 100644 --- a/lib/Service/AttachmentService.php +++ b/lib/Service/AttachmentService.php @@ -11,6 +11,7 @@ use OCA\Deck\AppInfo\Application; use OCA\Deck\BadRequestException; use OCA\Deck\Cache\AttachmentCacheHelper; +use OCA\Deck\Cron\ScanFederatedAttachment; use OCA\Deck\Db\Acl; use OCA\Deck\Db\Attachment; use OCA\Deck\Db\AttachmentMapper; @@ -19,10 +20,13 @@ use OCA\Deck\InvalidAttachmentType; use OCA\Deck\NoPermissionException; use OCA\Deck\NotFoundException; +use OCA\Deck\Sharing\DeckShareProvider; use OCA\Deck\StatusException; use OCA\Deck\Validators\AttachmentServiceValidator; use OCP\AppFramework\Db\IMapperException; use OCP\AppFramework\Http\Response; +use OCP\BackgroundJob\IJobList; +use OCP\Federation\ICloudIdManager; use OCP\IL10N; use OCP\IUserManager; use Psr\Container\ContainerExceptionInterface; @@ -44,6 +48,9 @@ public function __construct( private readonly IL10N $l10n, private readonly ActivityManager $activityManager, private readonly AttachmentServiceValidator $attachmentServiceValidator, + private readonly DeckShareProvider $deckShareProvider, + private readonly ICloudIdManager $cloudIdManager, + private readonly IJobList $jobList, ) { // Register shipped attachment services // TODO: move this to a plugin based approach once we have different types of attachments @@ -205,6 +212,21 @@ public function create(int $cardId, string $type, string $data = '') { return $attachment; } + public function acceptRemoteAttachment(int $cardId, string $token): void { + $this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_EDIT); + + $userId = $this->permissionService->getUserId(); + if (!$this->cloudIdManager->isValidCloudId($userId)) { + throw new NoPermissionException('Only federated user is allowed'); + } + + $externalShare = $this->deckShareProvider->ensureAcceptRemoteShare($token, $this->cloudIdManager->resolveCloudId($userId)->getRemote()); + $this->jobList->add(ScanFederatedAttachment::class, [ + 'cardId' => $cardId, + 'shareId' => $externalShare->getId(), + ]); + } + /** * Apply import side effects to keep attachment behavior consistent with regular create flow. */ diff --git a/lib/Service/ExternalBoardService.php b/lib/Service/ExternalBoardService.php index cc285a72ea..3bca3f5a84 100644 --- a/lib/Service/ExternalBoardService.php +++ b/lib/Service/ExternalBoardService.php @@ -32,6 +32,7 @@ public function __construct( private BoardMapper $boardMapper, private IURLGenerator $urlGenerator, private DeckShareProvider $deckShareProvider, + private FilesAppService $filesAppService, private ?string $userId, ) { } @@ -600,7 +601,7 @@ public function localizeRemoteAttachments(Board $localBoard, array $attachments) $attachments[$i]['extendedData']['attachmentCreator']['displayName'] = $createdByUser->getCloudId()->getId(); } if (!empty($attachment['extendedData']['shareToken'])) { - $file = $this->deckShareProvider->ensureAcceptRemoteShare($attachment['extendedData']['shareToken'], $ownerCloudId->getRemote()); + $file = $this->deckShareProvider->getRemoteFile($attachment['extendedData']['shareToken'], $ownerCloudId->getRemote()); if (empty($file)) { unset($attachments[$i]); @@ -627,44 +628,22 @@ public function getAttachmentsFromRemote(Board $localBoard, int $cardId): array return $this->localizeRemoteAttachments($localBoard, $attachments); } - public function createAttachmentOnRemote(Board $localBoard, int $cardId, string $type, string $data = '', ?array $uploadedFile = null): array { + public function createAttachmentForRemote(Board $localBoard, int $cardId): array { $this->configService->ensureFederationEnabled(); $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_EDIT, $this->userId, false, false); $shareToken = $localBoard->getShareToken(); $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); - $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId . '/attachment'; - if ($uploadedFile !== null) { - $content = fopen($uploadedFile['tmp_name'], 'rb'); - if ($content === false) { - throw new Exception('Could not read uploaded file'); - } - $params = [ - [ - 'name' => 'boardId', - 'contents' => (string)$localBoard->getExternalId(), - ], - [ - 'name' => 'file', - 'contents' => $content, - 'filename' => $uploadedFile['name'], - ], - [ - 'name' => 'type', - 'contents' => $type, - ], - [ - 'name' => 'data', - 'contents' => $data, - ], - ]; - $resp = $this->proxy->post($participantCloudId->getId(), $shareToken, $url, ['multipart' => $params]); - return $this->proxy->getOcsData($resp); - } + + // Upload and create remote share + $shareWithFederatedId = $ownerCloudId->getId(); + $fileShareToken = $this->filesAppService->createForRemote($shareWithFederatedId); + + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId . '/remote-attachment'; $resp = $this->proxy->post($participantCloudId->getId(), $shareToken, $url, [ 'boardId' => $localBoard->getExternalId(), - 'type' => $type, - 'data' => $data, + 'cardId' => $cardId, + 'token' => $fileShareToken, ]); return $this->proxy->getOcsData($resp); } diff --git a/lib/Service/FilesAppService.php b/lib/Service/FilesAppService.php index 5d241eb6aa..61eacc55dd 100644 --- a/lib/Service/FilesAppService.php +++ b/lib/Service/FilesAppService.php @@ -302,27 +302,53 @@ public function create(Attachment $attachment) { $share->setSharedBy($this->userId); $share = $this->shareManager->createShare($share); - // Create share for federated users - $boardId = $this->cardMapper->findBoardId($attachment->getCardId()); - foreach ($this->permissionService->findUsers($boardId) as $user) { - if (!$user instanceof FederatedUser) { - continue; - } - $remoteShare = $this->shareManager->newShare(); - $remoteShare->setParent((int)$share->getId()); - $remoteShare->setNode($target); - $remoteShare->setShareType(ISHARE::TYPE_REMOTE); - $remoteShare->setSharedWith($user->getUID()); - $remoteShare->setPermissions(Constants::PERMISSION_READ); - $remoteShare->setSharedBy($this->userId); - $this->shareManager->createShare($remoteShare); - } - $attachment->setId((int)$share->getId()); $attachment->setData($target->getName()); return $attachment; } + public function createForRemote(string $federatedCloudId) { + $file = $this->getUploadedFile(); + $fileName = $file['name']; + $this->validateFilename($fileName); + + $userFolder = $this->rootFolder->getUserFolder($this->userId); + try { + $folder = $userFolder->get($this->configService->getAttachmentFolder()); + } catch (NotFoundException) { + $folder = $userFolder->newFolder($this->configService->getAttachmentFolder()); + } + + if ($folder->isShared()) { + $folderName = $userFolder->getNonExistingName($this->configService->getAttachmentFolder()); + $folder = $userFolder->newFolder($folderName); + $this->configService->setAttachmentFolder($this->userId, $folderName); + } + + if (!$folder instanceof Folder || $folder->isShared()) { + throw new NotFoundException('No target folder found'); + } + + $fileName = $folder->getNonExistingName($fileName); + $target = $folder->newFile($fileName); + $content = fopen($file['tmp_name'], 'rb'); + if ($content === false) { + throw new StatusException('Could not read file'); + } + $target->putContent($content); + + $share = $this->shareManager->newShare(); + $share->setNode($target); + $share->setShareType(IShare::TYPE_REMOTE); + $share->setSharedWith($federatedCloudId); + $share->setPermissions(Constants::PERMISSION_READ); + $share->setSharedBy($this->userId); + $share->setShareOwner($this->userId); + + $createdShare = $this->shareManager->createShare($share); + return $createdShare->getToken(); + } + /** * Ensure the file exists in the owner’s Deck attachment folder, link it to the * Reuse file/share when already present diff --git a/lib/Sharing/DeckShareProvider.php b/lib/Sharing/DeckShareProvider.php index fa89ab9dd9..7f4f352bc1 100644 --- a/lib/Sharing/DeckShareProvider.php +++ b/lib/Sharing/DeckShareProvider.php @@ -9,6 +9,7 @@ namespace OCA\Deck\Sharing; +use OC\Federation\CloudId; use OC\Files\Cache\Cache; use OCA\Deck\Cache\AttachmentCacheHelper; use OCA\Deck\Db\Acl; @@ -18,21 +19,29 @@ use OCA\Deck\Db\User; use OCA\Deck\NoPermissionException; use OCA\Deck\Service\PermissionService; -use OCA\Files_Sharing\External\Manager; +use OCA\Files_Sharing\External\ExternalShare; +use OCA\Files_Sharing\External\Manager as ExternalShareManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Constants; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; -use OCP\Files\File; +use OCP\Files\Config\IUserMountCache; +use OCP\Files\Events\InvalidateMountCacheEvent; use OCP\Files\Folder; use OCP\Files\IMimeTypeLoader; use OCP\Files\IRootFolder; use OCP\Files\Node; +use OCP\Http\Client\IClientService; +use OCP\ICertificateManager; +use OCP\IConfig; use OCP\IDBConnection; use OCP\IL10N; use OCP\IPreview; +use OCP\IUserManager; +use OCP\OCM\IOCMDiscoveryService; +use OCP\Server; use OCP\Share\Exceptions\GenericShareException; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IAttributes; @@ -66,9 +75,14 @@ public function __construct( private IL10N $l, private ITimeFactory $timeFactory, private IMimeTypeLoader $mimeTypeLoader, - private Manager $externalShareManager, + private ExternalShareManager $externalShareManager, private IRootFolder $rootFolder, private IPreview $preview, + private IConfig $config, + private \OC\Files\Mount\Manager $mountManager, + private IUserManager $userManager, + private IUserMountCache $userMountCache, + private IEventDispatcher $eventDispatcher, private ?string $userId = null, ) { } @@ -1226,9 +1240,40 @@ public function getUsersForShare(IShare $share): iterable { return []; } - public function ensureAcceptRemoteShare(string $shareToken, string $remote): array|null { + public function getRemoteFile(string $shareToken, string $remote): array|null { + $externalShare = $this->ensureAcceptRemoteShare($shareToken, $remote); + $user = $this->userManager->get($externalShare->getUser()); + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + $node = $userFolder->get($externalShare->getMountpoint()); + $files = $userFolder->getById($node->getId()); + + if (count($files) === 0) { + return [ + 'path' => $externalShare->getMountpoint(), + 'fileid' => $node->getId(), + 'filesize' => $node->getSize(), + 'mimetype' => $node->getMimetype(), + 'info' => pathinfo($node->getName()), + 'hasPreview' => false, + 'isReady' => false, + ]; + } + + $file = array_shift($files); + + return [ + 'path' => $userFolder->getRelativePath($file->getPath()), + 'fileid' => $file->getId(), + 'filesize' => $file->getSize(), + 'mimetype' => $file->getMimeType(), + 'info' => pathinfo($file->getName()), + 'hasPreview' => $this->preview->isAvailable($file), + ]; + } + + public function ensureAcceptRemoteShare(string $shareToken, string $remote): ExternalShare { $qb = $this->dbConnection->getQueryBuilder(); - $qb->select('id', 'accepted') + $qb->select('id', 'accepted', 'user') ->from('share_external') ->where($qb->expr()->eq('refresh_token', $qb->createNamedParameter($shareToken))) ->andWhere($qb->expr()->eq('remote', $qb->createNamedParameter($remote . '/'))) @@ -1242,42 +1287,60 @@ public function ensureAcceptRemoteShare(string $shareToken, string $remote): arr throw new ShareNotFound(); } - $externalShare = $this->externalShareManager->getShare((string)$data['id']); + $user = $this->userManager->get($data['user']); + $externalShare = $this->externalShareManager->getShare((string)$data['id'], $user); if ($externalShare === false) { throw new ShareNotFound(); } - if ((int)$data['accepted'] === 0 && !$this->externalShareManager->acceptShare($externalShare)) { + if ((int)$data['accepted'] === 0 && !$this->externalShareManager->acceptShare($externalShare, $user)) { throw new ShareNotFound(); } - // Update mount point to be under /Deck if it is not already + $externalShare = $this->externalShareManager->getShare((string)$data['id'], $user); + $options = [ + 'remote' => $externalShare->getRemote(), + 'token' => $externalShare->getRefreshToken(), + 'password' => $externalShare->getPassword(), + 'access_token' => $externalShare->getAccessToken(), + 'access_token_expires' => $externalShare->getAccessTokenExpires(), + 'mountpoint' => $externalShare->getMountpoint(), + 'owner' => $externalShare->getOwner(), + 'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates'), + 'HttpClientService' => Server::get(IClientService::class), + 'cloudId' => new CloudId($externalShare->getOwner() . '@' . $externalShare->getRemote(), $externalShare->getOwner(), $externalShare->getRemote()), + 'manager' => $this->externalShareManager, + 'certificateManager' => Server::get(ICertificateManager::class), + 'discoveryService' => Server::get(IOCMDiscoveryService::class), + ]; + $mount = $this->externalShareManager->getMount($options, $user); + + // Update mount point to be under /Deck if (strpos($externalShare->getMountpoint(), '/Deck') !== 0) { - $mount = $this->externalShareManager->getMount(['mountpoint' => $externalShare->getMountpoint()]); - $mount->moveMount('/' . $this->userId . '/files/Deck' . $externalShare->getMountpoint()); - $externalShare->setMountpoint('/Deck' . $externalShare->getMountpoint()); + $target = '/Deck' . $externalShare->getName(); + $targetHash = md5($target); + $qb = $this->dbConnection->getQueryBuilder(); + $qb->update('share_external') + ->set('mountpoint', $qb->createNamedParameter($target)) + ->set('mountpoint_hash', $qb->createNamedParameter($targetHash)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($externalShare->getId()))) + ->executeStatement(); + $this->eventDispatcher->dispatchTyped(new InvalidateMountCacheEvent($user)); + $externalShare->setMountpoint('/Deck' . $externalShare->getName()); + $options['mountpoint'] = $externalShare->getMountpoint(); + $mount = $this->externalShareManager->getMount($options, $user); } - // @TODO: reload the LazyFolder to reflect the new mount point - - // Get the file info for the mount point - $userFolder = $this->rootFolder->getUserFolder($this->userId); + $this->mountManager->addMount($mount); + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); $node = $userFolder->get($externalShare->getMountpoint()); $files = $userFolder->getById($node->getId()); + if (count($files) === 0) { - return null; + $this->userMountCache->addMount($user, '/' . $user->getUID() . '/files' . $externalShare->getMountpoint() . '/', $node->getData(), $mount->getMountProvider(), $mount->getMountId()); } - $file = array_shift($files); - - return [ - 'path' => $userFolder->getRelativePath($file->getPath()), - 'fileid' => $file->getId(), - 'filesize' => $file->getSize(), - 'mimetype' => $file->getMimeType(), - 'info' => pathinfo($file->getName()), - 'hasPreview' => $this->preview->isAvailable($file), - ]; + return $externalShare; } } From 28a0126473801106f854b43cc6bea429d15282a8 Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Wed, 7 Oct 2026 11:40:53 +0200 Subject: [PATCH 8/9] feat: implement remote attachment creation Signed-off-by: Luka Trovic --- appinfo/routes.php | 2 +- lib/Controller/AttachmentOcsController.php | 2 +- lib/Cron/ScanFederatedAttachment.php | 84 ++++++++++++++++++++++ lib/Service/AttachmentService.php | 1 + lib/Sharing/DeckShareProvider.php | 46 ++++++------ 5 files changed, 113 insertions(+), 22 deletions(-) create mode 100644 lib/Cron/ScanFederatedAttachment.php diff --git a/appinfo/routes.php b/appinfo/routes.php index d82d7a8de7..d1c59cc43f 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -169,7 +169,7 @@ ['name' => 'attachment_ocs#getAll', 'url' => '/api/v{apiVersion}/cards/{cardId}/attachments', 'verb' => 'GET'], ['name' => 'attachment_ocs#create', 'url' => '/api/v{apiVersion}/cards/{cardId}/attachment', 'verb' => 'POST'], - ['name' => 'attachment_ocs#acceptRemote', 'url' => '/api/v{apiVersion}/cards/{cardId}/remote-attachment', 'verb' => 'POST'], + ['name' => 'attachment_ocs#createRemote', 'url' => '/api/v{apiVersion}/cards/{cardId}/remote-attachment', 'verb' => 'POST'], ['name' => 'attachment_ocs#update', 'url' => '/api/v{apiVersion}/cards/{cardId}/attachments/{attachmentId}', 'verb' => 'PUT'], ['name' => 'attachment_ocs#delete', 'url' => '/api/v{apiVersion}/cards/{cardId}/attachments/{type}:{attachmentId}', 'verb' => 'DELETE'], ['name' => 'attachment_ocs#restore', 'url' => '/api/v{apiVersion}/cards/{cardId}/attachments/{attachmentId}/restore', 'verb' => 'PUT'], diff --git a/lib/Controller/AttachmentOcsController.php b/lib/Controller/AttachmentOcsController.php index ef89292afe..47a42ddc23 100644 --- a/lib/Controller/AttachmentOcsController.php +++ b/lib/Controller/AttachmentOcsController.php @@ -62,7 +62,7 @@ public function create(int $cardId, string $type, string $data = '', ?int $board #[NoAdminRequired] #[PublicPage] - public function acceptRemote(int $cardId, string $token): DataResponse { + public function createRemote(int $cardId, string $token): DataResponse { $this->attachmentService->acceptRemoteAttachment($cardId, $token); return new DataResponse([]); } diff --git a/lib/Cron/ScanFederatedAttachment.php b/lib/Cron/ScanFederatedAttachment.php new file mode 100644 index 0000000000..a518bec718 --- /dev/null +++ b/lib/Cron/ScanFederatedAttachment.php @@ -0,0 +1,84 @@ +userManager->get($userId); + + if ($user === null) { + return; + } + + $externalShare = $this->externalShareManager->getShare((string)$shareId, $user); + + if ($externalShare === false) { + return; + } + + $this->deckShareProvider->mountExternalShare($externalShare, $user); + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + + $node = $userFolder->get($externalShare->getMountpoint()); + $files = $userFolder->getById($node->getId()); + + if (count($files) === 0) { + // Retry later, maybe the share is not yet mounted + $this->jobList->add(ScanFederatedAttachment::class, [ + 'cardId' => $cardId, + 'shareId' => $shareId, + 'userId' => $userId, + ]); + + // Scan the files to make sure the share is mounted and the files are available + exec('php ' . \OC::$SERVERROOT . '/occ files:scan --path="/' . $userId . '/files' . $externalShare->getMountpoint() . '"'); + return; + } + + $share = $this->shareManager->newShare(); + $share->setNode($node); + $share->setShareType(ISHARE::TYPE_DECK); + $share->setSharedWith((string)$cardId); + $share->setPermissions(Constants::PERMISSION_READ); + $share->setSharedBy($externalShare->getUser()); + $remote = substr($externalShare->getRemote(), 0, strrpos($externalShare->getRemote(), '/')); + $share->setShareOwner($externalShare->getOwner() . '@' . $remote); + $share = $this->shareManager->createShare($share); + + var_dump('Created share for cardId: ' . $cardId . ' and shareId: ' . $shareId . ' with shareId: ' . $share->getId() . ' and nodeId: ' . $node->getId()); + } +} diff --git a/lib/Service/AttachmentService.php b/lib/Service/AttachmentService.php index 6a791a4be6..8aedb303db 100644 --- a/lib/Service/AttachmentService.php +++ b/lib/Service/AttachmentService.php @@ -224,6 +224,7 @@ public function acceptRemoteAttachment(int $cardId, string $token): void { $this->jobList->add(ScanFederatedAttachment::class, [ 'cardId' => $cardId, 'shareId' => $externalShare->getId(), + 'userId' => $externalShare->getUser(), ]); } diff --git a/lib/Sharing/DeckShareProvider.php b/lib/Sharing/DeckShareProvider.php index 7f4f352bc1..87e88e0e88 100644 --- a/lib/Sharing/DeckShareProvider.php +++ b/lib/Sharing/DeckShareProvider.php @@ -21,6 +21,7 @@ use OCA\Deck\Service\PermissionService; use OCA\Files_Sharing\External\ExternalShare; use OCA\Files_Sharing\External\Manager as ExternalShareManager; +use OCA\Files_Sharing\External\Mount; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Utility\ITimeFactory; @@ -39,6 +40,7 @@ use OCP\IDBConnection; use OCP\IL10N; use OCP\IPreview; +use OCP\IUser; use OCP\IUserManager; use OCP\OCM\IOCMDiscoveryService; use OCP\Server; @@ -110,7 +112,7 @@ public function create(IShare $share) { } try { - $this->permissionService->checkPermission(null, $boardId, Acl::PERMISSION_EDIT); + $this->permissionService->checkPermission(null, $boardId, Acl::PERMISSION_EDIT, $share->getSharedBy()); } catch (NoPermissionException $e) { $valid = false; } @@ -1299,22 +1301,6 @@ public function ensureAcceptRemoteShare(string $shareToken, string $remote): Ext } $externalShare = $this->externalShareManager->getShare((string)$data['id'], $user); - $options = [ - 'remote' => $externalShare->getRemote(), - 'token' => $externalShare->getRefreshToken(), - 'password' => $externalShare->getPassword(), - 'access_token' => $externalShare->getAccessToken(), - 'access_token_expires' => $externalShare->getAccessTokenExpires(), - 'mountpoint' => $externalShare->getMountpoint(), - 'owner' => $externalShare->getOwner(), - 'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates'), - 'HttpClientService' => Server::get(IClientService::class), - 'cloudId' => new CloudId($externalShare->getOwner() . '@' . $externalShare->getRemote(), $externalShare->getOwner(), $externalShare->getRemote()), - 'manager' => $this->externalShareManager, - 'certificateManager' => Server::get(ICertificateManager::class), - 'discoveryService' => Server::get(IOCMDiscoveryService::class), - ]; - $mount = $this->externalShareManager->getMount($options, $user); // Update mount point to be under /Deck if (strpos($externalShare->getMountpoint(), '/Deck') !== 0) { @@ -1328,11 +1314,9 @@ public function ensureAcceptRemoteShare(string $shareToken, string $remote): Ext ->executeStatement(); $this->eventDispatcher->dispatchTyped(new InvalidateMountCacheEvent($user)); $externalShare->setMountpoint('/Deck' . $externalShare->getName()); - $options['mountpoint'] = $externalShare->getMountpoint(); - $mount = $this->externalShareManager->getMount($options, $user); } - $this->mountManager->addMount($mount); + $mount = $this->mountExternalShare($externalShare, $user); $userFolder = $this->rootFolder->getUserFolder($user->getUID()); $node = $userFolder->get($externalShare->getMountpoint()); $files = $userFolder->getById($node->getId()); @@ -1343,4 +1327,26 @@ public function ensureAcceptRemoteShare(string $shareToken, string $remote): Ext return $externalShare; } + + public function mountExternalShare(ExternalShare $externalShare, IUser $user): Mount { + $options = [ + 'remote' => $externalShare->getRemote(), + 'token' => $externalShare->getRefreshToken(), + 'password' => $externalShare->getPassword(), + 'access_token' => $externalShare->getAccessToken(), + 'access_token_expires' => $externalShare->getAccessTokenExpires(), + 'mountpoint' => $externalShare->getMountpoint(), + 'owner' => $externalShare->getOwner(), + 'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates'), + 'HttpClientService' => Server::get(IClientService::class), + 'cloudId' => new CloudId($externalShare->getOwner() . '@' . $externalShare->getRemote(), $externalShare->getOwner(), $externalShare->getRemote()), + 'manager' => $this->externalShareManager, + 'certificateManager' => Server::get(ICertificateManager::class), + 'discoveryService' => Server::get(IOCMDiscoveryService::class), + ]; + $mount = $this->externalShareManager->getMount($options, $user); + $this->mountManager->addMount($mount); + + return $mount; + } } From 866cdf7a12d53f30fce6da78240facdecffeab28 Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Tue, 20 Oct 2026 11:02:35 +0200 Subject: [PATCH 9/9] feat: enhance attachment readiness handling & fix unit tests Signed-off-by: Luka Trovic --- appinfo/routes.php | 1 + lib/Controller/AttachmentOcsController.php | 17 ++- lib/Cron/ScanFederatedAttachment.php | 11 +- lib/Service/ExternalBoardService.php | 80 ++++------ lib/Service/FilesAppService.php | 63 +++++--- lib/Service/PermissionService.php | 2 +- lib/Sharing/DeckShareProvider.php | 107 +++++++++++-- src/components/board/SharingTabSidebar.vue | 92 ++++++++---- src/components/card/AssignmentSelector.vue | 52 ++++--- src/components/card/AttachmentList.vue | 140 ++++++++++++++++-- src/components/card/CardSidebar.vue | 74 ++++----- .../card/CardSidebarTabComments.vue | 36 +++-- src/components/card/CommentForm.vue | 24 ++- src/components/card/CommentItem.vue | 65 +++++--- src/components/cards/AvatarList.vue | 87 ++++++----- src/services/AttachmentApi.js | 13 ++ src/stores/attachment.js | 11 ++ src/stores/comment.js | 6 +- tests/unit/Service/AttachmentServiceTest.php | 59 +++++++- .../Service/BatchQueryPerformanceTest.php | 6 + tests/unit/Service/FilesAppServiceTest.php | 1 + 21 files changed, 673 insertions(+), 274 deletions(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index d1c59cc43f..830ebbb153 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -170,6 +170,7 @@ ['name' => 'attachment_ocs#getAll', 'url' => '/api/v{apiVersion}/cards/{cardId}/attachments', 'verb' => 'GET'], ['name' => 'attachment_ocs#create', 'url' => '/api/v{apiVersion}/cards/{cardId}/attachment', 'verb' => 'POST'], ['name' => 'attachment_ocs#createRemote', 'url' => '/api/v{apiVersion}/cards/{cardId}/remote-attachment', 'verb' => 'POST'], + ['name' => 'attachment_ocs#shareRemote', 'url' => '/api/v{apiVersion}/cards/{cardId}/share-attachment', 'verb' => 'POST'], ['name' => 'attachment_ocs#update', 'url' => '/api/v{apiVersion}/cards/{cardId}/attachments/{attachmentId}', 'verb' => 'PUT'], ['name' => 'attachment_ocs#delete', 'url' => '/api/v{apiVersion}/cards/{cardId}/attachments/{type}:{attachmentId}', 'verb' => 'DELETE'], ['name' => 'attachment_ocs#restore', 'url' => '/api/v{apiVersion}/cards/{cardId}/attachments/{attachmentId}/restore', 'verb' => 'PUT'], diff --git a/lib/Controller/AttachmentOcsController.php b/lib/Controller/AttachmentOcsController.php index 47a42ddc23..9f787499b9 100644 --- a/lib/Controller/AttachmentOcsController.php +++ b/lib/Controller/AttachmentOcsController.php @@ -9,6 +9,7 @@ namespace OCA\Deck\Controller; +use OCA\Deck\BadRequestException; use OCA\Deck\NotImplementedException; use OCA\Deck\Service\AttachmentService; use OCA\Deck\Service\BoardService; @@ -67,6 +68,16 @@ public function createRemote(int $cardId, string $token): DataResponse { return new DataResponse([]); } + #[NoAdminRequired] + public function shareRemote(int $cardId, int $boardId, string $path): DataResponse { + $board = $this->boardService->find($boardId, false); + if (!$board->getExternalId()) { + throw new BadRequestException('board is not federated'); + } + $token = $this->externalBoardService->shareAttachmentForRemote($board, $cardId, $path); + return new DataResponse(['token' => $token]); + } + #[NoAdminRequired] public function update(int $cardId, int $attachmentId, string $data, string $type = 'file', ?int $boardId = null): DataResponse { $this->ensureLocalBoard($boardId); @@ -75,8 +86,12 @@ public function update(int $cardId, int $attachmentId, string $data, string $typ } #[NoAdminRequired] + #[PublicPage] public function delete(int $cardId, int $attachmentId, string $type = 'file', ?int $boardId = null): DataResponse { - $this->ensureLocalBoard($boardId); + $board = $this->boardService->find($boardId, false); + if ($board->getExternalId()) { + return new DataResponse($this->externalBoardService->deleteAttachmentOnRemote($board, $cardId, $attachmentId, $type)); + } $attachment = $this->attachmentService->delete($cardId, $attachmentId, $type); return new DataResponse($attachment); } diff --git a/lib/Cron/ScanFederatedAttachment.php b/lib/Cron/ScanFederatedAttachment.php index a518bec718..5cf3bae0e0 100644 --- a/lib/Cron/ScanFederatedAttachment.php +++ b/lib/Cron/ScanFederatedAttachment.php @@ -10,12 +10,12 @@ namespace OCA\Deck\Cron; use OCA\Deck\Sharing\DeckShareProvider; +use OCA\Files_Sharing\External\Manager as ExternalShareManager; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; use OCP\BackgroundJob\QueuedJob; use OCP\Constants; use OCP\Files\IRootFolder; -use OCA\Files_Sharing\External\Manager as ExternalShareManager; use OCP\IUserManager; use OCP\Share\IManager; use OCP\Share\IShare; @@ -74,11 +74,10 @@ protected function run($argument): void { $share->setShareType(ISHARE::TYPE_DECK); $share->setSharedWith((string)$cardId); $share->setPermissions(Constants::PERMISSION_READ); - $share->setSharedBy($externalShare->getUser()); $remote = substr($externalShare->getRemote(), 0, strrpos($externalShare->getRemote(), '/')); - $share->setShareOwner($externalShare->getOwner() . '@' . $remote); - $share = $this->shareManager->createShare($share); - - var_dump('Created share for cardId: ' . $cardId . ' and shareId: ' . $shareId . ' with shareId: ' . $share->getId() . ' and nodeId: ' . $node->getId()); + $federatedId = $externalShare->getOwner() . '@' . $remote; + $share->setSharedBy($federatedId); + $share->setShareOwner($externalShare->getUser()); + $this->shareManager->createShare($share); } } diff --git a/lib/Service/ExternalBoardService.php b/lib/Service/ExternalBoardService.php index 3bca3f5a84..734ec9fb33 100644 --- a/lib/Service/ExternalBoardService.php +++ b/lib/Service/ExternalBoardService.php @@ -74,7 +74,8 @@ public function localizeRemoteUser(Board $localBoard, array $user): array|User|F } // if it's already a valid cloud id the user originates from a third instance and we pass it as is if ($this->cloudIdManager->isValidCloudId($user['uid'])) { - if ($user['remote'] == $this->urlGenerator->getBaseUrl()) { + $cloudId = $this->cloudIdManager->resolveCloudId($user['uid']); + if ($cloudId->getRemote() == $this->urlGenerator->getBaseUrl()) { // local user from remote: return as local user $localuid = $this->cloudIdManager->resolveCloudId($user['uid'])->getUser(); return new User($localuid, $this->userManager); @@ -592,7 +593,7 @@ public function leaveBoardOnRemote(Board $localBoard): void { public function localizeRemoteAttachments(Board $localBoard, array $attachments): array { $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); foreach ($attachments as $i => $attachment) { - $createdByUser = $this->localizeRemoteUser($localBoard, ['uid' => $attachment['createdBy']]); + $createdByUser = $this->localizeRemoteUser($localBoard, ['uid' => $attachment['createdBy'], 'remote' => $attachment['extendedData']['createdByRemote']]); if ($createdByUser instanceof FederatedUser) { $attachments[$i]['createdBy'] = $createdByUser->getCloudId()->getId(); $attachments[$i]['createdByRemote'] = $createdByUser->getCloudId()->getRemote(); @@ -600,8 +601,19 @@ public function localizeRemoteAttachments(Board $localBoard, array $attachments) $attachments[$i]['extendedData']['attachmentCreator']['remote'] = $createdByUser->getCloudId()->getRemote(); $attachments[$i]['extendedData']['attachmentCreator']['displayName'] = $createdByUser->getCloudId()->getId(); } + if ($createdByUser instanceof User) { + $attachments[$i]['createdBy'] = $createdByUser->getUID(); + $attachments[$i]['createdByRemote'] = null; + $attachments[$i]['extendedData']['attachmentCreator']['id'] = $createdByUser->getUID(); + $attachments[$i]['extendedData']['attachmentCreator']['remote'] = null; + $attachments[$i]['extendedData']['attachmentCreator']['displayName'] = $createdByUser->getDisplayName(); + } if (!empty($attachment['extendedData']['shareToken'])) { - $file = $this->deckShareProvider->getRemoteFile($attachment['extendedData']['shareToken'], $ownerCloudId->getRemote()); + if ($createdByUser instanceof User) { + $file = $this->deckShareProvider->getLocalFile($attachment['extendedData']['shareToken']); + } else { + $file = $this->deckShareProvider->getRemoteFile($attachment['extendedData']['shareToken'], $ownerCloudId->getRemote()); + } if (empty($file)) { unset($attachments[$i]); @@ -648,72 +660,34 @@ public function createAttachmentForRemote(Board $localBoard, int $cardId): array return $this->proxy->getOcsData($resp); } - public function updateAttachmentOnRemote(Board $localBoard, int $cardId, int $attachmentId, string $data, string $type = 'file', ?array $uploadedFile = null): array { + public function shareAttachmentForRemote(Board $localBoard, int $cardId, string $path): array { $this->configService->ensureFederationEnabled(); $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_EDIT, $this->userId, false, false); $shareToken = $localBoard->getShareToken(); $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); - $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId . '/attachments/' . $attachmentId; - if ($uploadedFile !== null) { - $content = fopen($uploadedFile['tmp_name'], 'rb'); - if ($content === false) { - throw new Exception('Could not read uploaded file'); - } - $params = [ - [ - 'name' => 'boardId', - 'contents' => (string)$localBoard->getExternalId(), - ], - [ - 'name' => 'file', - 'contents' => $content, - 'filename' => $uploadedFile['name'], - ], - [ - 'name' => 'type', - 'contents' => $type, - ], - [ - 'name' => 'data', - 'contents' => $data, - ], - ]; - $resp = $this->proxy->put($participantCloudId->getId(), $shareToken, $url, ['multipart' => $params]); - return $this->proxy->getOcsData($resp); - } - $params = [ - 'boardId' => $localBoard->getExternalId(), - 'data' => $data, - 'type' => $type, - ]; - $resp = $this->proxy->put($participantCloudId->getId(), $shareToken, $url, $params); - return $this->proxy->getOcsData($resp); - } - public function deleteAttachmentOnRemote(Board $localBoard, int $cardId, int $attachmentId, string $type = 'file'): array { - $this->configService->ensureFederationEnabled(); - $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_EDIT, $this->userId, false, false); - $shareToken = $localBoard->getShareToken(); - $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); - $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId . '/attachments/' . $attachmentId; - $resp = $this->proxy->delete($ownerCloudId->getId(), $shareToken, $url, [ + // Create remote share + $shareWithFederatedId = $ownerCloudId->getId(); + $fileShareToken = $this->filesAppService->createRemoteShareForPath($path, $shareWithFederatedId); + + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId . '/remote-attachment'; + $resp = $this->proxy->post($participantCloudId->getId(), $shareToken, $url, [ 'boardId' => $localBoard->getExternalId(), - 'type' => $type, + 'cardId' => $cardId, + 'token' => $fileShareToken, ]); return $this->proxy->getOcsData($resp); } - public function restoreAttachmentOnRemote(Board $localBoard, int $cardId, int $attachmentId, string $type = 'file'): array { + public function deleteAttachmentOnRemote(Board $localBoard, int $cardId, int $attachmentId, string $type = 'file'): array { $this->configService->ensureFederationEnabled(); $this->permissionService->checkPermission($this->boardMapper, $localBoard->getId(), Acl::PERMISSION_EDIT, $this->userId, false, false); $shareToken = $localBoard->getShareToken(); - $participantCloudId = $this->cloudIdManager->getCloudId($this->userId, null); $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); - $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId . '/attachments/' . $attachmentId . '/restore'; - $resp = $this->proxy->put($participantCloudId->getId(), $shareToken, $url, [ + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId . '/attachments/' . $type . ':' . $attachmentId; + $resp = $this->proxy->delete($ownerCloudId->getId(), $shareToken, $url, [ 'boardId' => $localBoard->getExternalId(), - 'type' => $type, ]); return $this->proxy->getOcsData($resp); } diff --git a/lib/Service/FilesAppService.php b/lib/Service/FilesAppService.php index 61eacc55dd..c331302c26 100644 --- a/lib/Service/FilesAppService.php +++ b/lib/Service/FilesAppService.php @@ -13,7 +13,6 @@ use OCA\Deck\Db\Acl; use OCA\Deck\Db\Attachment; use OCA\Deck\Db\CardMapper; -use OCA\Deck\Db\FederatedUser; use OCA\Deck\NoPermissionException; use OCA\Deck\Sharing\DeckShareProvider; use OCA\Deck\StatusException; @@ -160,11 +159,18 @@ public function extendData(Attachment $attachment) { $requestUserId = $this->permissionService->getUserId(); if ($this->cloudIdManager->isValidCloudId($requestUserId)) { - $shareToken = $this->ensureFederatedAttachmentShare($share, $node, $requestUserId); - $attachment->setExtendedData([ - 'fileid' => $node->getId(), - 'shareToken' => $shareToken, - ]); + if ($share->getSharedBy() === $requestUserId) { + $externalShare = $this->shareProvider->findRootExternalShare($share, $requestUserId); + $attachment->setExtendedData([ + 'data' => $node->getName(), + 'shareToken' => $externalShare ? $externalShare->getRefreshToken() : null, + ]); + } else { + $shareToken = $this->ensureFederatedAttachmentShare($share, $node, $requestUserId); + $attachment->setExtendedData([ + 'shareToken' => $shareToken, + ]); + } } if ($this->userId !== null) { @@ -202,19 +208,18 @@ private function ensureFederatedAttachmentShare(IShare $attachmentShare, Node $f $share->setSharedBy($attachmentShare->getShareOwner()); $share->setShareOwner($attachmentShare->getShareOwner()); - try { - $createdShare = $this->shareManager->createShare($share); - return $createdShare->getToken(); - } catch (GenericShareException $e) { - // Ignore races where another request already created the same federated share. - if (!$this->findFederatedAttachmentShareToken($file->getId(), $federatedCloudId)) { - throw $e; - } - return $this->findFederatedAttachmentShareToken($file->getId(), $federatedCloudId); - } + $createdShare = $this->shareManager->createShare($share); + + $qb = $this->connection->getQueryBuilder(); + $qb->update('share') + ->set('parent', $qb->createNamedParameter($attachmentShare->getId(), IQueryBuilder::PARAM_INT)) + ->andWhere($qb->expr()->eq('id', $qb->createNamedParameter($createdShare->getId(), IQueryBuilder::PARAM_INT))) + ->executeStatement(); + + return $createdShare->getToken(); } - private function findFederatedAttachmentShareToken(int $fileId, string $federatedCloudId): string|null { + private function findFederatedAttachmentShareToken(int $fileId, string $federatedCloudId): ?string { $qb = $this->connection->getQueryBuilder(); $qb->select('id', 'token') ->from('share') @@ -307,7 +312,7 @@ public function create(Attachment $attachment) { return $attachment; } - public function createForRemote(string $federatedCloudId) { + public function createForRemote(string $federatedCloudId): string { $file = $this->getUploadedFile(); $fileName = $file['name']; $this->validateFilename($fileName); @@ -336,17 +341,33 @@ public function createForRemote(string $federatedCloudId) { throw new StatusException('Could not read file'); } $target->putContent($content); + $createdShare = $this->createRemoteShareForNode($target, $federatedCloudId); + return $createdShare->getToken(); + } + + public function createRemoteShareForPath(string $path, string $federatedCloudId): string { + $userFolder = $this->rootFolder->getUserFolder($this->userId); + try { + $node = $userFolder->get($path); + } catch (NotFoundException) { + throw new NotFoundException('No target file found'); + } + $createdShare = $this->createRemoteShareForNode($node, $federatedCloudId); + + return $createdShare->getToken(); + } + + private function createRemoteShareForNode(Node $node, string $federatedCloudId): IShare { $share = $this->shareManager->newShare(); - $share->setNode($target); + $share->setNode($node); $share->setShareType(IShare::TYPE_REMOTE); $share->setSharedWith($federatedCloudId); $share->setPermissions(Constants::PERMISSION_READ); $share->setSharedBy($this->userId); $share->setShareOwner($this->userId); - $createdShare = $this->shareManager->createShare($share); - return $createdShare->getToken(); + return $this->shareManager->createShare($share); } /** diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php index a22c6e12b4..a2dd5ba176 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/PermissionService.php @@ -252,7 +252,7 @@ public function userCan(array $acls, $permission, $userId = null) { } // check for users foreach ($acls as $acl) { - if ($acl->getType() === Acl::PERMISSION_TYPE_USER && $acl->getParticipant() === $userId) { + if (in_array($acl->getType(), [Acl::PERMISSION_TYPE_USER, Acl::PERMISSION_TYPE_REMOTE]) && $acl->getParticipant() === $userId) { return $acl->getPermission($permission); } diff --git a/lib/Sharing/DeckShareProvider.php b/lib/Sharing/DeckShareProvider.php index 87e88e0e88..ad2b25cc48 100644 --- a/lib/Sharing/DeckShareProvider.php +++ b/lib/Sharing/DeckShareProvider.php @@ -28,6 +28,7 @@ use OCP\Constants; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Federation\ICloudIdManager; use OCP\Files\Config\IUserMountCache; use OCP\Files\Events\InvalidateMountCacheEvent; use OCP\Files\Folder; @@ -85,6 +86,7 @@ public function __construct( private IUserManager $userManager, private IUserMountCache $userMountCache, private IEventDispatcher $eventDispatcher, + private ICloudIdManager $cloudIdManager, private ?string $userId = null, ) { } @@ -360,6 +362,7 @@ public function update(IShare $share) { * @inheritDoc */ public function delete(IShare $share) { + $this->deleteRemoteShares($share); $qb = $this->dbConnection->getQueryBuilder(); $qb->delete('share') ->where($qb->expr()->eq('id', $qb->createNamedParameter($share->getId()))); @@ -368,9 +371,70 @@ public function delete(IShare $share) { $qb->executeStatement(); + $this->deleteExternalShare($share); $this->attachmentCacheHelper->clearAttachmentCount((int)$share->getSharedWith()); } + private function deleteRemoteShares(IShare $share) { + $qb = $this->dbConnection->getQueryBuilder(); + $qb->select('id', 'token') + ->from('share') + ->where($qb->expr()->eq('parent', $qb->createNamedParameter($share->getId()))) + ->andWhere($qb->expr()->eq('share_type', $qb->createNamedParameter(IShare::TYPE_REMOTE))); + $stmt = $qb->executeQuery(); + + while ($row = $stmt->fetchAssociative()) { + try { + $remoteShare = $this->shareManager->getShareByToken((string)$row['token']); + $this->shareManager->deleteShare($remoteShare); + } catch (\Exception $e) { + continue; + } + } + } + + private function deleteExternalShare(IShare $share) { + $shareBy = $share->getSharedBy(); + + if (!$this->cloudIdManager->isValidCloudId($shareBy)) { + return; + } + + $externalShare = $this->findRootExternalShare($share, $shareBy); + + if (empty($externalShare)) { + return; + } + + $user = $this->userManager->get($externalShare->getUser()); + $this->externalShareManager->declineShare($externalShare, $user); + } + + public function findRootExternalShare(IShare $share, string $ownerCloudId): ?ExternalShare { + $node = $share->getNode(); + $cloudId = $this->cloudIdManager->resolveCloudId($ownerCloudId); + $mountPoint = str_replace('/' . $share->getShareOwner() . '/files', '', $node->getPath()); + + $qb = $this->dbConnection->getQueryBuilder(); + $qb->select('id', 'refresh_token', 'mountpoint', 'user') + ->from('share_external') + ->andWhere($qb->expr()->eq('remote', $qb->createNamedParameter($cloudId->getRemote() . '/'))) + ->andWhere($qb->expr()->eq('owner', $qb->createNamedParameter($cloudId->getUser()))) + ->andWhere($qb->expr()->eq('mountpoint', $qb->createNamedParameter($mountPoint))) + ->setMaxResults(1); + + $cursor = $qb->executeQuery(); + $data = $cursor->fetch(); + $cursor->closeCursor(); + + if ($data === false) { + return null; + } + + $user = $this->userManager->get($data['user']); + return $this->externalShareManager->getShare((string)$data['id'], $user); + } + /** * @inheritDoc */ @@ -1242,7 +1306,7 @@ public function getUsersForShare(IShare $share): iterable { return []; } - public function getRemoteFile(string $shareToken, string $remote): array|null { + public function getRemoteFile(string $shareToken, string $remote): ?array { $externalShare = $this->ensureAcceptRemoteShare($shareToken, $remote); $user = $this->userManager->get($externalShare->getUser()); $userFolder = $this->rootFolder->getUserFolder($user->getUID()); @@ -1257,7 +1321,6 @@ public function getRemoteFile(string $shareToken, string $remote): array|null { 'mimetype' => $node->getMimetype(), 'info' => pathinfo($node->getName()), 'hasPreview' => false, - 'isReady' => false, ]; } @@ -1273,6 +1336,33 @@ public function getRemoteFile(string $shareToken, string $remote): array|null { ]; } + public function getLocalFile(string $shareToken): ?array { + try { + $share = $this->shareManager->getShareByToken($shareToken); + } catch (ShareNotFound $e) { + return null; + } + + $userFolder = $this->rootFolder->getUserFolder($share->getSharedBy()); + $node = $share->getNode(); + $files = $userFolder->getById($node->getId()); + + if (count($files) === 0) { + return null; + } + + $file = array_shift($files); + + return [ + 'path' => $userFolder->getRelativePath($file->getPath()), + 'fileid' => $file->getId(), + 'filesize' => $file->getSize(), + 'mimetype' => $file->getMimeType(), + 'info' => pathinfo($file->getName()), + 'hasPreview' => $this->preview->isAvailable($file), + ]; + } + public function ensureAcceptRemoteShare(string $shareToken, string $remote): ExternalShare { $qb = $this->dbConnection->getQueryBuilder(); $qb->select('id', 'accepted', 'user') @@ -1316,14 +1406,7 @@ public function ensureAcceptRemoteShare(string $shareToken, string $remote): Ext $externalShare->setMountpoint('/Deck' . $externalShare->getName()); } - $mount = $this->mountExternalShare($externalShare, $user); - $userFolder = $this->rootFolder->getUserFolder($user->getUID()); - $node = $userFolder->get($externalShare->getMountpoint()); - $files = $userFolder->getById($node->getId()); - - if (count($files) === 0) { - $this->userMountCache->addMount($user, '/' . $user->getUID() . '/files' . $externalShare->getMountpoint() . '/', $node->getData(), $mount->getMountProvider(), $mount->getMountId()); - } + $this->mountExternalShare($externalShare, $user); return $externalShare; } @@ -1347,6 +1430,10 @@ public function mountExternalShare(ExternalShare $externalShare, IUser $user): M $mount = $this->externalShareManager->getMount($options, $user); $this->mountManager->addMount($mount); + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + $node = $userFolder->get($externalShare->getMountpoint()); + $this->userMountCache->addMount($user, '/' . $user->getUID() . '/files' . $externalShare->getMountpoint() . '/', $node->getData(), $mount->getMountProvider(), $mount->getMountId()); + return $mount; } } diff --git a/src/components/board/SharingTabSidebar.vue b/src/components/board/SharingTabSidebar.vue index 6f456ecf01..5518de49bd 100644 --- a/src/components/board/SharingTabSidebar.vue +++ b/src/components/board/SharingTabSidebar.vue @@ -4,15 +4,17 @@ --> +