From d7accc5eb971a08f65d6a86759f2647df1c42de6 Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Wed, 26 Aug 2026 13:55:03 +0200 Subject: [PATCH] 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 51efc0a480..71b51eebca 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -24,6 +24,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; @@ -52,6 +53,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; @@ -189,6 +191,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 125635382d..95e3f2c168 100644 --- a/src/components/card/Description.vue +++ b/src/components/card/Description.vue @@ -117,7 +117,7 @@ export default { }, 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