From 8273e8446024b4802753800509b3c0827965d695 Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Wed, 26 Aug 2026 13:55:03 +0200 Subject: [PATCH 01/10] feat: collaborative description editing Signed-off-by: grnd-alt --- lib/AppInfo/Application.php | 4 + .../RegisterTextContextEventListener.php | 31 +++++ lib/Provider/TextContextProvider.php | 118 ++++++++++++++++++ lib/Provider/TextContextProviderFactory.php | 39 ++++++ src/components/card/Description.vue | 6 +- 5 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 lib/Event/RegisterTextContextEventListener.php create mode 100644 lib/Provider/TextContextProvider.php create mode 100644 lib/Provider/TextContextProviderFactory.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index fa1df26ff0..b2472045e1 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -25,6 +25,7 @@ use OCA\Deck\Event\CardCreatedEvent; use OCA\Deck\Event\CardDeletedEvent; use OCA\Deck\Event\CardUpdatedEvent; +use OCA\Deck\Event\RegisterTextContextEventListener; use OCA\Deck\Event\SessionClosedEvent; use OCA\Deck\Event\SessionCreatedEvent; use OCA\Deck\Federation\DeckFederationProvider; @@ -53,6 +54,7 @@ use OCA\Deck\Teams\DeckTeamResourceProvider; use OCA\Deck\UserMigration\DeckMigrator; use OCA\Text\Event\LoadEditor; +use OCA\Text\Event\RegisterContextEvent; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -191,6 +193,8 @@ public function register(IRegistrationContext $context): void { $context->registerTeamResourceProvider(DeckTeamResourceProvider::class); $context->registerUserMigrator(DeckMigrator::class); + + $context->registerEventListener(RegisterContextEvent::class, RegisterTextContextEventListener::class); } public function registerCommentsEntity(IEventDispatcher $eventDispatcher): void { diff --git a/lib/Event/RegisterTextContextEventListener.php b/lib/Event/RegisterTextContextEventListener.php new file mode 100644 index 0000000000..a61535122a --- /dev/null +++ b/lib/Event/RegisterTextContextEventListener.php @@ -0,0 +1,31 @@ +getContextManager()->registerContext( + 'deck_card', + TextContextProviderFactory::class + ); + } +} diff --git a/lib/Provider/TextContextProvider.php b/lib/Provider/TextContextProvider.php new file mode 100644 index 0000000000..5ea786730f --- /dev/null +++ b/lib/Provider/TextContextProvider.php @@ -0,0 +1,118 @@ +cardId; + } + + public function getType(): string { + return 'deck_card'; + } + + public function toString(): string { + return $this->getType() . ' (' . $this->getId() . ')'; + } + + public function buildDocument(): Document{ + $document = new Document(); + $document->setContextType($this->getType()); + $document->setContextId($this->getId()); + $document->setLastSavedVersion(0); + $document->setLastSavedVersionTime(1); + $document->setLastSavedVersionEtag($this->secureRandom->generate(6)); + + $document->setChecksum($this->computeChecksum()); + $document->setBaseVersionEtag(uniqid()); + return $document; + } + + public function prepareSession(DocumentData $documentData): SessionInfo { + if ($documentData->documentState === null) { + $content = null; + } else { + $content = $this->cardService->find($this->cardId)->getDescription(); + } + $readonly = !$this->permissionService->checkPermission($this->cardMapper, $this->cardId, Acl::PERMISSION_EDIT, $this->userId); + return new SessionInfo( + content: $content, + readOnly: $readonly, + lock: null, + hasOwner: true, + ); + } + + public function isReadOnly(): bool { + return !$this->permissionService->checkPermission($this->cardMapper, $this->cardId, Acl::PERMISSION_EDIT, $this->userId); + } + + public function updateDocument(Document $document): ?Document { + if ($this->computeChecksum() !== $document->getChecksum()) { + $card = $this->cardService->find($this->cardId); + throw new DocumentSaveConflictException($card->getDescription()); + } + return null; + } + + public function getFile(): ?File{ + return null; + } + + public function loadContent(): ?string { + $card = $this->cardService->find($this->cardId); + if ($card === null) { + return null; + } + return $card->getDescription(); + } + + public function saveWithLock(string $content, callable $doWhileLocked): void { + $card = $this->cardService->find($this->cardId); + if ($card === null) { + return; + } + $card->setDescription($content); + $this->cardService->update($card->getId(), $card->getTitle(), $card->getStackId(), $card->getType(), $card->getOwner(), $card->getDescription(), $card->getOrder()); + } + + public function cleanup(): void { + } + + private function computeChecksum(): string { + $card = $this->cardService->find($this->cardId); + if ($card === null) { + return ''; + } + return hash('crc32', $card->getDescription()); + } + +} diff --git a/lib/Provider/TextContextProviderFactory.php b/lib/Provider/TextContextProviderFactory.php new file mode 100644 index 0000000000..78b16d8f36 --- /dev/null +++ b/lib/Provider/TextContextProviderFactory.php @@ -0,0 +1,39 @@ +secureRandom, + cardService: $this->cardService, + cardMapper: $this->cardMapper, + permissionService: $this->permissionService, + userId: $this->userId, + cardId: $id, + ); + } +} diff --git a/src/components/card/Description.vue b/src/components/card/Description.vue index 8da0a60195..12f4fbe1d8 100644 --- a/src/components/card/Description.vue +++ b/src/components/card/Description.vue @@ -118,7 +118,7 @@ export default { emits: ['change'], data() { return { - textAppAvailable: !!window.OCA?.Text?.createEditor, + textAppAvailable: !!window.OCA?.Text?.createCollaborativeEditor, editor: null, keyExitState: 0, descriptionOld: '', @@ -205,9 +205,9 @@ export default { this.descriptionLastEdit = 0 this.descriptionOld = this.card.description this.description = this.card.description - this.editor = await window.OCA.Text.createEditor({ + this.editor = await window.OCA.Text.createCollaborativeEditor({ + context: { type: 'deck_card', id: this.card.id }, el: this.$refs.editor, - content: this.card.description, readOnly: !this.canEdit, onLoaded: () => { this.descriptionLastEdit = 0 From 3abdd9e00ff2e6bc3eb3d25b7b0ea903649c851f Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Wed, 14 Oct 2026 12:05:15 +0200 Subject: [PATCH 02/10] feature(description): conflict dialog Signed-off-by: grnd-alt --- lib/AppInfo/Application.php | 2 + lib/Listeners/TextDocumentContentListener.php | 46 +++++++++++++++ lib/Provider/TextContextProvider.php | 34 ++++------- lib/Provider/TextContextProviderFactory.php | 6 +- .../TextDocumentContentListenerTest.php | 56 +++++++++++++++++++ 5 files changed, 121 insertions(+), 23 deletions(-) create mode 100644 lib/Listeners/TextDocumentContentListener.php create mode 100644 tests/unit/Listeners/TextDocumentContentListenerTest.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index b2472045e1..bd52bf816d 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -38,6 +38,7 @@ use OCA\Deck\Listeners\ResourceAdditionalScriptsListener; use OCA\Deck\Listeners\ResourceListener; use OCA\Deck\Listeners\ResourceTypeRegisterListener; +use OCA\Deck\Listeners\TextDocumentContentListener; use OCA\Deck\Middleware\DefaultBoardMiddleware; use OCA\Deck\Middleware\ExceptionMiddleware; use OCA\Deck\Middleware\FederationMiddleware; @@ -195,6 +196,7 @@ public function register(IRegistrationContext $context): void { $context->registerUserMigrator(DeckMigrator::class); $context->registerEventListener(RegisterContextEvent::class, RegisterTextContextEventListener::class); + $context->registerEventListener(CardUpdatedEvent::class, TextDocumentContentListener::class); } public function registerCommentsEntity(IEventDispatcher $eventDispatcher): void { diff --git a/lib/Listeners/TextDocumentContentListener.php b/lib/Listeners/TextDocumentContentListener.php new file mode 100644 index 0000000000..8836651aaf --- /dev/null +++ b/lib/Listeners/TextDocumentContentListener.php @@ -0,0 +1,46 @@ + + */ +class TextDocumentContentListener implements IEventListener { + public function __construct( + private readonly IEventDispatcher $eventDispatcher, + private readonly TextContextProviderFactory $contextFactory, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof CardUpdatedEvent) || !class_exists(DocumentContentUpdated::class)) { + return; + } + + $before = $event->getCardBefore(); + $card = $event->getCard(); + if ($before === null || $before->getDescription() === $card->getDescription()) { + return; + } + + $this->eventDispatcher->dispatchTyped( + new DocumentContentUpdated($this->contextFactory->buildForCard($card->getId())) + ); + } +} diff --git a/lib/Provider/TextContextProvider.php b/lib/Provider/TextContextProvider.php index 5ea786730f..7873ed8083 100644 --- a/lib/Provider/TextContextProvider.php +++ b/lib/Provider/TextContextProvider.php @@ -8,20 +8,19 @@ namespace OCA\Deck\Provider; -use Co\Http\Server; use OC\Security\SecureRandom; use OCA\Deck\Db\Acl; use OCA\Deck\Db\CardMapper; use OCA\Deck\Service\CardService; use OCA\Deck\Service\PermissionService; -use OCA\Text\Context\DocumentData; use OCA\Text\Context\IContext; use OCA\Text\Context\SessionInfo; use OCA\Text\Db\Document; -use OCA\Text\Exception\DocumentSaveConflictException; use OCP\Files\File; class TextContextProvider implements IContext { + public const CONTEXT_TYPE = 'deck_card'; + public function __construct( private readonly SecureRandom $secureRandom, private readonly CardService $cardService, @@ -36,7 +35,7 @@ public function getId(): int { } public function getType(): string { - return 'deck_card'; + return self::CONTEXT_TYPE; } public function toString(): string { @@ -56,18 +55,12 @@ public function buildDocument(): Document{ return $document; } - public function prepareSession(DocumentData $documentData): SessionInfo { - if ($documentData->documentState === null) { - $content = null; - } else { - $content = $this->cardService->find($this->cardId)->getDescription(); - } + public function prepareSession(): SessionInfo { $readonly = !$this->permissionService->checkPermission($this->cardMapper, $this->cardId, Acl::PERMISSION_EDIT, $this->userId); return new SessionInfo( - content: $content, readOnly: $readonly, lock: null, - hasOwner: true, + canAttachFiles: false, ); } @@ -75,12 +68,8 @@ public function isReadOnly(): bool { return !$this->permissionService->checkPermission($this->cardMapper, $this->cardId, Acl::PERMISSION_EDIT, $this->userId); } - public function updateDocument(Document $document): ?Document { - if ($this->computeChecksum() !== $document->getChecksum()) { - $card = $this->cardService->find($this->cardId); - throw new DocumentSaveConflictException($card->getDescription()); - } - return null; + public function updateDocument(Document $document): void { + $document->setChecksum($this->computeChecksum()); } public function getFile(): ?File{ @@ -95,16 +84,17 @@ public function loadContent(): ?string { return $card->getDescription(); } - public function saveWithLock(string $content, callable $doWhileLocked): void { + public function cleanup(): void { + } + + public function save(string $content, callable $afterSave): void { $card = $this->cardService->find($this->cardId); if ($card === null) { return; } $card->setDescription($content); $this->cardService->update($card->getId(), $card->getTitle(), $card->getStackId(), $card->getType(), $card->getOwner(), $card->getDescription(), $card->getOrder()); - } - - public function cleanup(): void { + $afterSave(); } private function computeChecksum(): string { diff --git a/lib/Provider/TextContextProviderFactory.php b/lib/Provider/TextContextProviderFactory.php index 78b16d8f36..878a0bef01 100644 --- a/lib/Provider/TextContextProviderFactory.php +++ b/lib/Provider/TextContextProviderFactory.php @@ -27,13 +27,17 @@ public function __construct( } public function build(IUser|IShare $auth, string $type, int $id): IContext { + return $this->buildForCard($id); + } + + public function buildForCard(int $cardId): TextContextProvider { return new TextContextProvider( secureRandom: $this->secureRandom, cardService: $this->cardService, cardMapper: $this->cardMapper, permissionService: $this->permissionService, userId: $this->userId, - cardId: $id, + cardId: $cardId, ); } } diff --git a/tests/unit/Listeners/TextDocumentContentListenerTest.php b/tests/unit/Listeners/TextDocumentContentListenerTest.php new file mode 100644 index 0000000000..fcf6f86d01 --- /dev/null +++ b/tests/unit/Listeners/TextDocumentContentListenerTest.php @@ -0,0 +1,56 @@ +markTestSkipped('Text app not available'); + } + $this->dispatcher = $this->createMock(IEventDispatcher::class); + $this->factory = $this->createMock(TextContextProviderFactory::class); + $this->listener = new TextDocumentContentListener($this->dispatcher, $this->factory); + } + + private function event(string $before, string $after): CardUpdatedEvent { + $old = new Card(); + $old->setId(5); + $old->setDescription($before); + $new = new Card(); + $new->setId(5); + $new->setDescription($after); + return new CardUpdatedEvent($new, $old); + } + + public function testDispatchesOnDescriptionChange(): void { + $context = $this->createMock(TextContextProvider::class); + $this->factory->expects($this->once())->method('buildForCard')->with(5)->willReturn($context); + $this->dispatcher->expects($this->once())->method('dispatchTyped') + ->with($this->callback(fn ($e) => $e instanceof DocumentContentUpdated && $e->getContext() === $context)); + $this->listener->handle($this->event('a', 'b')); + } + + public function testIgnoresUnchangedDescription(): void { + $this->dispatcher->expects($this->never())->method('dispatchTyped'); + $this->listener->handle($this->event('a', 'a')); + } +} From 7fe8e40dc7b7c56d89c083ef9b13dd5d222c36fe Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Wed, 14 Oct 2026 16:47:39 +0200 Subject: [PATCH 03/10] fix(description): render basic markdown editor correctly Signed-off-by: grnd-alt --- src/components/card/Description.vue | 35 ++++++++++------------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/src/components/card/Description.vue b/src/components/card/Description.vue index 12f4fbe1d8..b192325a2e 100644 --- a/src/components/card/Description.vue +++ b/src/components/card/Description.vue @@ -94,7 +94,7 @@ markdownIt.use(MarkdownItLinkAttributes, { export default { name: 'Description', components: { - VueEasymde: () => import('vue-easymde/dist/VueEasyMDE.common.js'), + VueEasymde, NcActions, NcActionButton, NcModal, @@ -176,21 +176,23 @@ export default { }, watch: { card(newCard) { + if (this.textAppAvailable) { + return + } if (newCard.description !== this.descriptionOld) { clearTimeout(this.descriptionSaveTimeout) this.descriptionLastEdit = 0 this.descriptionOld = newCard.description this.description = newCard.description - if (this.editor) { - this.editor.setContent(this.description) - } showWarning(t('deck', 'The description has been changed by another user.'), { timeout: 3000 }) } }, }, mounted() { - this.setupEditor() + if (this.textAppAvailable) { + this.setupEditor() + } }, async beforeUnmount() { await this.destroyEditor() @@ -202,31 +204,18 @@ export default { ...mapActions(useSettingsStore, ['setHasCardSaveError']), async setupEditor() { await this.destroyEditor() - this.descriptionLastEdit = 0 - this.descriptionOld = this.card.description - this.description = this.card.description this.editor = await window.OCA.Text.createCollaborativeEditor({ context: { type: 'deck_card', id: this.card.id }, el: this.$refs.editor, readOnly: !this.canEdit, - onLoaded: () => { - this.descriptionLastEdit = 0 - }, - onUpdate: ({ markdown }) => { - if (this.description === markdown) { - return - } - this.description = markdown - this.updateDescription() - }, - onFileInsert: () => { - this.showAttachmentModal() - }, }) }, async destroyEditor() { - await this.saveDescription() + clearTimeout(this.descriptionSaveTimeout) + if (!this.textAppAvailable) { + await this.saveDescription() + } try { this?.editor?.destroy() } catch (e) { @@ -287,8 +276,8 @@ export default { const newContent = descString + '\n' + attachmentString this.$refs.markdownEditor.easymde.value(newContent) this.description = newContent + this.updateDescription() } - this.updateDescription() this.modalShow = false }, clickedPreview(e) { From d5c361339310947f81b30ac4f13b8ab5405f86a1 Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Wed, 14 Oct 2026 17:14:39 +0200 Subject: [PATCH 04/10] fix(description): show last saved date for description Signed-off-by: grnd-alt --- lib/Provider/TextContextProvider.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/Provider/TextContextProvider.php b/lib/Provider/TextContextProvider.php index 7873ed8083..4ddec4b76c 100644 --- a/lib/Provider/TextContextProvider.php +++ b/lib/Provider/TextContextProvider.php @@ -47,7 +47,7 @@ public function buildDocument(): Document{ $document->setContextType($this->getType()); $document->setContextId($this->getId()); $document->setLastSavedVersion(0); - $document->setLastSavedVersionTime(1); + $document->setLastSavedVersionTime($this->getLastModified()); $document->setLastSavedVersionEtag($this->secureRandom->generate(6)); $document->setChecksum($this->computeChecksum()); @@ -70,6 +70,12 @@ public function isReadOnly(): bool { public function updateDocument(Document $document): void { $document->setChecksum($this->computeChecksum()); + $document->setLastSavedVersionTime($this->getLastModified()); + } + + private function getLastModified(): int { + $card = $this->cardService->find($this->cardId); + return (int)($card?->getLastModified() ?: time()); } public function getFile(): ?File{ From a4bae8d7ee160ca9ee89ad083b67b58b739e91c0 Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Wed, 14 Oct 2026 17:10:53 +0200 Subject: [PATCH 05/10] fix(description): exclusively update description when saving from Text Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: grnd-alt --- lib/Provider/TextContextProvider.php | 7 +-- lib/Service/CardService.php | 71 ++++++++++++++++++++------ tests/unit/Service/CardServiceTest.php | 35 +++++++++++++ 3 files changed, 92 insertions(+), 21 deletions(-) diff --git a/lib/Provider/TextContextProvider.php b/lib/Provider/TextContextProvider.php index 4ddec4b76c..16e6e29c05 100644 --- a/lib/Provider/TextContextProvider.php +++ b/lib/Provider/TextContextProvider.php @@ -94,12 +94,7 @@ public function cleanup(): void { } public function save(string $content, callable $afterSave): void { - $card = $this->cardService->find($this->cardId); - if ($card === null) { - return; - } - $card->setDescription($content); - $this->cardService->update($card->getId(), $card->getTitle(), $card->getStackId(), $card->getType(), $card->getOwner(), $card->getDescription(), $card->getOrder()); + $this->cardService->updateDescription($this->cardId, $content); $afterSave(); } diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index 7ebc8d8a6f..695a249d68 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -284,21 +284,7 @@ public function update(int $id, string $title, int $stackId, string $type, strin } $changes = new ChangeSet($card); - if ($card->getLastEditor() !== $this->userId && $card->getLastEditor() !== null) { - $this->activityManager->triggerEvent( - ActivityManager::DECK_OBJECT_CARD, - $card, - ActivityManager::SUBJECT_CARD_UPDATE_DESCRIPTION, - [ - 'before' => $card->getDescriptionPrev(), - 'after' => $card->getDescription() - ], - $card->getLastEditor() - ); - - $card->setDescriptionPrev($card->getDescription()); - $card->setLastEditor($this->userId); - } + $this->trackDescriptionEditor($card); $card->setTitle($title); $card->setStackId($stackId); $card->setType($type); @@ -441,6 +427,61 @@ public function rename(int $id, string $title): Card { return $update; } + /** + * Update only the description, leaving all other card fields untouched. + * + * @throws StatusException + * @throws \OCA\Deck\NoPermissionException + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + * @throws BadRequestException + */ + public function updateDescription(int $id, string $description): Card { + $this->cardServiceValidator->check(compact('id')); + + $this->permissionService->checkPermission($this->cardMapper, $id, Acl::PERMISSION_EDIT); + if ($this->boardService->isArchived($this->cardMapper, $id)) { + throw new StatusException('Operation not allowed. This board is archived.'); + } + $card = $this->cardMapper->find($id); + if ($card->getArchived()) { + throw new StatusException('Operation not allowed. This card is archived.'); + } + $changes = new ChangeSet($card); + $this->trackDescriptionEditor($card); + if ($card->getDescriptionPrev() === null) { + $card->setDescriptionPrev($card->getDescription()); + } + $card->setDescription($description); + $update = $this->cardMapper->update($card); + $this->changeHelper->cardChanged($card->getId()); + + $this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card, $changes->getBefore())); + + return $update; + } + + /** + * Publish description changes as activity + */ + private function trackDescriptionEditor(Card $card): void { + if ($card->getLastEditor() !== $this->userId && $card->getLastEditor() !== null) { + $this->activityManager->triggerEvent( + ActivityManager::DECK_OBJECT_CARD, + $card, + ActivityManager::SUBJECT_CARD_UPDATE_DESCRIPTION, + [ + 'before' => $card->getDescriptionPrev(), + 'after' => $card->getDescription() + ], + $card->getLastEditor() + ); + + $card->setDescriptionPrev($card->getDescription()); + $card->setLastEditor($this->userId); + } + } + /** * @return list * @throws StatusException diff --git a/tests/unit/Service/CardServiceTest.php b/tests/unit/Service/CardServiceTest.php index 94a96a7a98..5666718bb0 100644 --- a/tests/unit/Service/CardServiceTest.php +++ b/tests/unit/Service/CardServiceTest.php @@ -36,6 +36,7 @@ use OCA\Deck\Db\LabelMapper; use OCA\Deck\Db\Stack; use OCA\Deck\Db\StackMapper; +use OCA\Deck\Event\CardUpdatedEvent; use OCA\Deck\Model\CardDetails; use OCA\Deck\Model\OptionalNullableValue; use OCA\Deck\Notification\NotificationHelper; @@ -604,6 +605,40 @@ public function testRenameArchived() { $this->cardService->rename(123, 'newtitle'); } + public function testUpdateDescriptionKeepsOtherFields() { + $duedate = new \DateTime('2017-01-01T00:00:00+00:00'); + $startdate = new \DateTime('2016-12-01T00:00:00+00:00'); + $done = new \DateTime('2017-01-02T00:00:00+00:00'); + $card = new Card(); + $card->setId(123); + $card->setTitle('title'); + $card->setDescription('old'); + $card->setArchived(false); + $card->setDuedate($duedate); + $card->setStartdate($startdate); + $card->setDone($done); + $this->cardMapper->expects($this->once())->method('find')->willReturn($card); + $this->cardMapper->expects($this->once())->method('update')->willReturnArgument(0); + $this->eventDispatcher->expects($this->once())->method('dispatchTyped') + ->with($this->isInstanceOf(CardUpdatedEvent::class)); + $actual = $this->cardService->updateDescription(123, 'new'); + $this->assertEquals('new', $actual->getDescription()); + $this->assertEquals('old', $actual->getDescriptionPrev()); + $this->assertEquals('title', $actual->getTitle()); + $this->assertEquals($duedate, $actual->getDuedate()); + $this->assertEquals($startdate, $actual->getStartdate()); + $this->assertEquals($done, $actual->getDone()); + } + + public function testUpdateDescriptionArchived() { + $card = new Card(); + $card->setArchived(true); + $this->cardMapper->expects($this->once())->method('find')->willReturn($card); + $this->cardMapper->expects($this->never())->method('update'); + $this->expectException(StatusException::class); + $this->cardService->updateDescription(123, 'new'); + } + public static function dataReorder() { return [ [0, 0, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]], From 4191505be72d909103f4b14b9da99d46ad9776ea Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Thu, 15 Oct 2026 09:43:12 +0200 Subject: [PATCH 06/10] fix(psalm): stub Text app classes used by the context provider Assisted-by: ClaudeCode:claude-sonnet-5-5 Signed-off-by: grnd-alt --- .../RegisterTextContextEventListener.php | 1 + lib/Provider/TextContextProvider.php | 8 +-- tests/stub.phpstub | 58 +++++++++++++++++++ 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/lib/Event/RegisterTextContextEventListener.php b/lib/Event/RegisterTextContextEventListener.php index a61535122a..c09480abe1 100644 --- a/lib/Event/RegisterTextContextEventListener.php +++ b/lib/Event/RegisterTextContextEventListener.php @@ -14,6 +14,7 @@ use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; +/** @template-implements IEventListener */ class RegisterTextContextEventListener implements IEventListener{ public function __construct( private readonly TextContextProviderFactory $textContextProviderFactory, diff --git a/lib/Provider/TextContextProvider.php b/lib/Provider/TextContextProvider.php index 16e6e29c05..4a8731f894 100644 --- a/lib/Provider/TextContextProvider.php +++ b/lib/Provider/TextContextProvider.php @@ -75,7 +75,7 @@ public function updateDocument(Document $document): void { private function getLastModified(): int { $card = $this->cardService->find($this->cardId); - return (int)($card?->getLastModified() ?: time()); + return (int)($card->getLastModified() ?: time()); } public function getFile(): ?File{ @@ -84,9 +84,6 @@ public function getFile(): ?File{ public function loadContent(): ?string { $card = $this->cardService->find($this->cardId); - if ($card === null) { - return null; - } return $card->getDescription(); } @@ -100,9 +97,6 @@ public function save(string $content, callable $afterSave): void { private function computeChecksum(): string { $card = $this->cardService->find($this->cardId); - if ($card === null) { - return ''; - } return hash('crc32', $card->getDescription()); } diff --git a/tests/stub.phpstub b/tests/stub.phpstub index 73673f72e5..7d71caadc0 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -732,4 +732,62 @@ namespace OCA\NotifyPush\Queue { namespace OCA\Text\Event { class LoadEditor extends \OCP\EventDispatcher\Event {} + + class RegisterContextEvent extends \OCP\EventDispatcher\Event { + public function getContextManager(): \OCA\Text\Context\ContextManager { + } + } + + class DocumentContentUpdated extends \OCP\EventDispatcher\Event { + public function __construct(\OCA\Text\Context\IContext $context) { + } + } +} + +namespace OCA\Text\Context { + class ContextManager { + public function registerContext(string $type, string $factoryClass): void { + } + } + + class SessionInfo { + public ?string $content = null; + + public function __construct( + public bool $readOnly, + public bool $canAttachFiles, + public ?\OCP\Files\Lock\ILock $lock, + ) { + } + } + + interface IContext { + public function getId(): int; + public function getType(): string; + public function toString(): string; + public function buildDocument(): \OCA\Text\Db\Document; + public function prepareSession(): SessionInfo; + public function isReadOnly(): bool; + public function updateDocument(\OCA\Text\Db\Document $document): void; + public function getFile(): ?\OCP\Files\File; + public function loadContent(): ?string; + public function save(string $content, callable $afterSave): void; + public function cleanup(): void; + } + + interface IContextFactory { + public function build(\OCP\IUser|\OCP\Share\IShare $auth, string $type, int $id): IContext; + } +} + +namespace OCA\Text\Db { + /** + * @method void setContextType(string $contextType) + * @method void setContextId(int $contextId) + * @method void setLastSavedVersion(int $version) + * @method void setLastSavedVersionTime(int $time) + * @method void setChecksum(?string $checksum) + */ + class Document extends \OCP\AppFramework\Db\Entity { + } } From 7724cb89fc0ae8dc799850c395e792b6bdbaca97 Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Thu, 15 Oct 2026 09:59:56 +0200 Subject: [PATCH 07/10] fix(text): translate card lookup errors to Text context exceptions Assisted-by: ClaudeCode:claude-sonnet-5-5 Signed-off-by: grnd-alt --- lib/Provider/TextContextProvider.php | 37 ++++++++- .../unit/Provider/TextContextProviderTest.php | 78 +++++++++++++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 tests/unit/Provider/TextContextProviderTest.php diff --git a/lib/Provider/TextContextProvider.php b/lib/Provider/TextContextProvider.php index 4a8731f894..afc7a1905c 100644 --- a/lib/Provider/TextContextProvider.php +++ b/lib/Provider/TextContextProvider.php @@ -10,13 +10,18 @@ use OC\Security\SecureRandom; use OCA\Deck\Db\Acl; +use OCA\Deck\Db\Card; use OCA\Deck\Db\CardMapper; +use OCA\Deck\NoPermissionException; use OCA\Deck\Service\CardService; use OCA\Deck\Service\PermissionService; use OCA\Text\Context\IContext; use OCA\Text\Context\SessionInfo; use OCA\Text\Db\Document; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\Files\File; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; class TextContextProvider implements IContext { public const CONTEXT_TYPE = 'deck_card'; @@ -73,8 +78,26 @@ public function updateDocument(Document $document): void { $document->setLastSavedVersionTime($this->getLastModified()); } + /** + * @throws NotFoundException + * @throws NotPermittedException + */ + private function findCard(): Card { + try { + return $this->cardService->find($this->cardId); + } catch (DoesNotExistException $e) { + throw new NotFoundException($e->getMessage(), 0, $e); + } catch (NoPermissionException $e) { + throw new NotPermittedException($e->getMessage(), 0, $e); + } + } + + /** + * @throws NotFoundException + * @throws NotPermittedException + */ private function getLastModified(): int { - $card = $this->cardService->find($this->cardId); + $card = $this->findCard(); return (int)($card->getLastModified() ?: time()); } @@ -82,8 +105,12 @@ public function getFile(): ?File{ return null; } + /** + * @throws NotFoundException + * @throws NotPermittedException + */ public function loadContent(): ?string { - $card = $this->cardService->find($this->cardId); + $card = $this->findCard(); return $card->getDescription(); } @@ -95,8 +122,12 @@ public function save(string $content, callable $afterSave): void { $afterSave(); } + /** + * @throws NotFoundException + * @throws NotPermittedException + */ private function computeChecksum(): string { - $card = $this->cardService->find($this->cardId); + $card = $this->findCard(); return hash('crc32', $card->getDescription()); } diff --git a/tests/unit/Provider/TextContextProviderTest.php b/tests/unit/Provider/TextContextProviderTest.php new file mode 100644 index 0000000000..72f0408fd2 --- /dev/null +++ b/tests/unit/Provider/TextContextProviderTest.php @@ -0,0 +1,78 @@ +markTestSkipped('Text app not available'); + } + $this->cardService = $this->createMock(CardService::class); + $this->provider = new TextContextProvider( + $this->createMock(SecureRandom::class), + $this->cardService, + $this->createMock(CardMapper::class), + $this->createMock(PermissionService::class), + 'admin', + 5, + ); + } + + public function testLoadContent(): void { + $card = new Card(); + $card->setDescription('hello'); + $this->cardService->method('find')->with(5)->willReturn($card); + + $this->assertSame('hello', $this->provider->loadContent()); + } + + public function testLoadContentCardMissing(): void { + $this->cardService->method('find')->willThrowException(new DoesNotExistException('missing')); + + $this->expectException(NotFoundException::class); + $this->provider->loadContent(); + } + + public function testLoadContentNoPermission(): void { + $this->cardService->method('find')->willThrowException(new NoPermissionException('denied')); + + $this->expectException(NotPermittedException::class); + $this->provider->loadContent(); + } + + public function testBuildDocumentCardMissing(): void { + $this->cardService->method('find')->willThrowException(new DoesNotExistException('missing')); + + $this->expectException(NotFoundException::class); + $this->provider->buildDocument(); + } + + public function testBuildDocumentNoPermission(): void { + $this->cardService->method('find')->willThrowException(new NoPermissionException('denied')); + + $this->expectException(NotPermittedException::class); + $this->provider->buildDocument(); + } +} From 71f501d95d75324d36e0117c3838dac8a667b5f4 Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Thu, 15 Oct 2026 12:09:10 +0200 Subject: [PATCH 08/10] fix(text): register description listener only when Text is available Assisted-by: ClaudeCode:claude-sonnet-5-5 Signed-off-by: grnd-alt --- lib/AppInfo/Application.php | 8 +++++++- lib/Event/RegisterTextContextEventListener.php | 7 ++++--- lib/Provider/TextContextProvider.php | 4 ++-- lib/Provider/TextContextProviderFactory.php | 2 +- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index bd52bf816d..bd1399b7e9 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -54,6 +54,7 @@ use OCA\Deck\Sharing\Listener; use OCA\Deck\Teams\DeckTeamResourceProvider; use OCA\Deck\UserMigration\DeckMigrator; +use OCA\Text\Event\DocumentContentUpdated; use OCA\Text\Event\LoadEditor; use OCA\Text\Event\RegisterContextEvent; use OCP\AppFramework\App; @@ -109,6 +110,12 @@ public function boot(IBootContext $context): void { $context->injectFn($this->registerCommentsEntity(...)); $context->injectFn($this->registerCollaborationResources(...)); + if (class_exists(DocumentContentUpdated::class)) { + $context->injectFn(function (IEventDispatcher $eventDispatcher) { + $eventDispatcher->addServiceListener(CardUpdatedEvent::class, TextDocumentContentListener::class); + }); + } + $context->injectFn(function (IManager $shareManager) { $shareManager->registerShareProvider(DeckShareProvider::class); }); @@ -196,7 +203,6 @@ public function register(IRegistrationContext $context): void { $context->registerUserMigrator(DeckMigrator::class); $context->registerEventListener(RegisterContextEvent::class, RegisterTextContextEventListener::class); - $context->registerEventListener(CardUpdatedEvent::class, TextDocumentContentListener::class); } public function registerCommentsEntity(IEventDispatcher $eventDispatcher): void { diff --git a/lib/Event/RegisterTextContextEventListener.php b/lib/Event/RegisterTextContextEventListener.php index c09480abe1..4a9bced537 100644 --- a/lib/Event/RegisterTextContextEventListener.php +++ b/lib/Event/RegisterTextContextEventListener.php @@ -15,11 +15,12 @@ use OCP\EventDispatcher\IEventListener; /** @template-implements IEventListener */ -class RegisterTextContextEventListener implements IEventListener{ +class RegisterTextContextEventListener implements IEventListener { public function __construct( private readonly TextContextProviderFactory $textContextProviderFactory, - ){} - public function handle(Event $event): void{ + ) { + } + public function handle(Event $event): void { if (!$event instanceof RegisterContextEvent) { return; } diff --git a/lib/Provider/TextContextProvider.php b/lib/Provider/TextContextProvider.php index afc7a1905c..65cfa3ab28 100644 --- a/lib/Provider/TextContextProvider.php +++ b/lib/Provider/TextContextProvider.php @@ -47,7 +47,7 @@ public function toString(): string { return $this->getType() . ' (' . $this->getId() . ')'; } - public function buildDocument(): Document{ + public function buildDocument(): Document { $document = new Document(); $document->setContextType($this->getType()); $document->setContextId($this->getId()); @@ -101,7 +101,7 @@ private function getLastModified(): int { return (int)($card->getLastModified() ?: time()); } - public function getFile(): ?File{ + public function getFile(): ?File { return null; } diff --git a/lib/Provider/TextContextProviderFactory.php b/lib/Provider/TextContextProviderFactory.php index 878a0bef01..fd4b78c68c 100644 --- a/lib/Provider/TextContextProviderFactory.php +++ b/lib/Provider/TextContextProviderFactory.php @@ -16,7 +16,7 @@ use OCP\IUser; use OCP\Share\IShare; -class TextContextProviderFactory implements IContextFactory{ +class TextContextProviderFactory implements IContextFactory { public function __construct( private readonly SecureRandom $secureRandom, private readonly CardService $cardService, From 80daf4dba655ffd4d6138dd0a876a0411127fcf3 Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Thu, 15 Oct 2026 12:54:08 +0200 Subject: [PATCH 09/10] fix(cypress): wait for the text save request instead of the deck api save Signed-off-by: grnd-alt --- cypress/e2e/cardFeatures.js | 2 +- src/components/card/Description.vue | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cypress/e2e/cardFeatures.js b/cypress/e2e/cardFeatures.js index def7e258db..94f59ecdb7 100644 --- a/cypress/e2e/cardFeatures.js +++ b/cypress/e2e/cardFeatures.js @@ -270,7 +270,7 @@ describe('Card', function () { it('Shows the modal with the editor', () => { cy.get('.card:contains("Hello world")').should('be.visible').click() - cy.intercept({ method: 'PUT', url: '**/ocs/v2.php/apps/deck/api/v1.0/cards/*' }).as('save') + cy.intercept({ method: 'POST', url: '**/apps/text/session/*/save' }).as('save') cy.get('.modal__card').should('be.visible') cy.get('.app-sidebar-header__mainname').contains('Hello world') cy.get('.modal__card .ProseMirror h1').contains('Hello world').should('be.visible') diff --git a/src/components/card/Description.vue b/src/components/card/Description.vue index b192325a2e..01f4c3f881 100644 --- a/src/components/card/Description.vue +++ b/src/components/card/Description.vue @@ -7,8 +7,8 @@
{{ t('deck', 'Description') }} - {{ t('deck', '(Unsaved)') }} - {{ t('deck', '(Saving…)') }} + {{ t('deck', '(Unsaved)') }} + {{ t('deck', '(Saving…)') }} Date: Mon, 19 Oct 2026 12:21:45 +0200 Subject: [PATCH 10/10] fix(description): use non-collaborative Text editor for cards without an id Assisted-by: ClaudeCode:claude-sonnet-5-5 Signed-off-by: grnd-alt --- cypress/e2e/cardFeatures.js | 5 ++-- src/components/card/Description.vue | 37 ++++++++++++++++++++++++----- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/cypress/e2e/cardFeatures.js b/cypress/e2e/cardFeatures.js index 94f59ecdb7..1fa0209ad7 100644 --- a/cypress/e2e/cardFeatures.js +++ b/cypress/e2e/cardFeatures.js @@ -276,6 +276,7 @@ describe('Card', function () { cy.get('.modal__card .ProseMirror h1').contains('Hello world').should('be.visible') cy.get('.modal__card .ProseMirror h1') .click() + .parent() .type(' writing more text{enter}- List item{enter}with entries{enter}{enter}Paragraph') cy.wait('@save', { timeout: 7000 }) @@ -284,7 +285,7 @@ describe('Card', function () { // Scroll to the bottom to ensure all content is loaded and visible cy.get('.modal__card .app-sidebar-tabs, .modal__card .app-sidebar__tab--active').first().scrollTo('bottom', { ensureScrollable: false }) - cy.contains('.modal__card .ProseMirror p', 'Paragraph').scrollIntoView().should('be.visible') + cy.contains('.modal__card .ProseMirror p', 'Paragraph').should('be.visible') cy.get('.modal__card .ProseMirror h1').contains('Hello world writing more text').should('be.visible') cy.get('.modal__card .ProseMirror li').eq(0).contains('List item').should('be.visible') @@ -298,7 +299,7 @@ describe('Card', function () { cy.get('.card:contains("Hello world")').should('be.visible').click() cy.get('.modal__card').should('be.visible') cy.get('.modal__card .ProseMirror h1') - .click() + .click().parent() .type('{enter}/create') cy.get('.suggestion-list__item.is-selected').should('be.visible').contains('Create a new deck card') cy.get('.suggestion-list__item.is-selected .link-picker__item').click() diff --git a/src/components/card/Description.vue b/src/components/card/Description.vue index 01f4c3f881..6ae9252926 100644 --- a/src/components/card/Description.vue +++ b/src/components/card/Description.vue @@ -7,8 +7,8 @@
{{ t('deck', 'Description') }} - {{ t('deck', '(Unsaved)') }} - {{ t('deck', '(Saving…)') }} + {{ t('deck', '(Unsaved)') }} + {{ t('deck', '(Saving…)') }} { const url = OC.MimeType.getIconUrl(mimetype) @@ -177,7 +183,7 @@ export default { }, watch: { card(newCard) { - if (this.textAppAvailable) { + if (this.collaborative) { return } if (newCard.description !== this.descriptionOld) { @@ -191,6 +197,10 @@ export default { }, }, mounted() { + if (!this.collaborative) { + this.descriptionOld = this.card.description + this.description = this.card.description + } if (this.textAppAvailable) { this.setupEditor() } @@ -205,16 +215,31 @@ export default { ...mapActions(useSettingsStore, ['setHasCardSaveError']), async setupEditor() { await this.destroyEditor() - this.editor = await window.OCA.Text.createCollaborativeEditor({ - context: { type: 'deck_card', id: this.card.id }, + if (this.collaborative) { + this.editor = await window.OCA.Text.createCollaborativeEditor({ + context: { type: 'deck_card', id: this.card.id }, + el: this.$refs.editor, + readOnly: !this.canEdit, + }) + return + } + this.editor = await window.OCA.Text.createMarkdownContentEditor({ el: this.$refs.editor, + content: this.card.description, readOnly: !this.canEdit, + onUpdate: ({ markdown }) => { + if (this.description === markdown) { + return + } + this.description = markdown + this.updateDescription() + }, }) }, async destroyEditor() { clearTimeout(this.descriptionSaveTimeout) - if (!this.textAppAvailable) { + if (!this.collaborative) { await this.saveDescription() } try {