From 2090963bf9f81fb811c9ee2a2f6e001adaecdaae Mon Sep 17 00:00:00 2001 From: Jonas Date: Sat, 31 Jan 2026 13:11:16 +0100 Subject: [PATCH 1/5] chore(AttachmentService): cleanup annotations and typos Signed-off-by: Jonas --- lib/Service/AttachmentService.php | 138 ++++++++---------------------- 1 file changed, 37 insertions(+), 101 deletions(-) diff --git a/lib/Service/AttachmentService.php b/lib/Service/AttachmentService.php index 4712ced912b..da0d6520087 100755 --- a/lib/Service/AttachmentService.php +++ b/lib/Service/AttachmentService.php @@ -34,7 +34,7 @@ use OCP\Share\IShare; use OCP\Util; -class AttachmentService { +readonly class AttachmentService { public function __construct( private IRootFolder $rootFolder, private ShareManager $shareManager, @@ -271,12 +271,8 @@ public function getAttachmentList(int $documentId, ?string $userId = null, ?Sess /** * Save an uploaded file in the attachment folder * - * @param int $documentId - * @param string $newFileName * @param resource $newFileResource - * @param string $userId * - * @return array * @throws InvalidPathException * @throws NoUserException * @throws NotFoundException @@ -302,12 +298,8 @@ public function uploadAttachment(int $documentId, string $newFileName, $newFileR /** * Save an uploaded file in the attachment folder in a public context * - * @param int|null $documentId - * @param string $newFileName * @param resource $newFileResource - * @param string $shareToken * - * @return array * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException @@ -357,11 +349,6 @@ public function uploadAttachmentPublic(?int $documentId, string $newFileName, $n /** * Copy a file from a user's storage in the attachment folder * - * @param int $documentId - * @param string $path - * @param string $userId - * - * @return array * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException @@ -380,10 +367,6 @@ public function insertAttachmentFile(int $documentId, string $path, string $user /** * create a new file in the attachment folder * - * @param int $documentId - * @param string $userId - * - * @return array * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException @@ -407,11 +390,6 @@ public function createAttachmentFile(int $documentId, string $newFileName, strin } /** - * @param File $originalFile - * @param Folder $saveDir - * @param File $textFile - * - * @return array * @throws NotFoundException * @throws InvalidPathException */ @@ -430,11 +408,6 @@ private function copyFile(File $originalFile, Folder $saveDir, File $textFile): /** * Get unique file name in a directory. Add '(n)' suffix. - * - * @param Folder $dir - * @param string $fileName - * - * @return string */ public static function getUniqueFileName(Folder $dir, string $fileName): string { $extension = pathinfo($fileName, PATHINFO_EXTENSION); @@ -471,10 +444,6 @@ private function hasUpdatePermissions(IShare $share): bool { /** * Get or create file-specific attachment folder * - * @param File $textFile - * @param bool $create - * - * @return Folder * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException @@ -505,6 +474,7 @@ private function getAttachmentDirectoryForFile(File $textFile, bool $create = fa /** * Get a user file from file ID + * * @throws NotFoundException * @throws NotPermittedException * @throws NoUserException @@ -521,9 +491,6 @@ private function getFileFromPath(string $filePath, string $userId): File { } /** - * @param File $file - * - * @return bool * @throws NotFoundException */ private function isDownloadDisabled(File $file): bool { @@ -543,10 +510,6 @@ private function isDownloadDisabled(File $file): bool { /** * Get a user file from file ID * - * @param int $documentId - * @param string $userId - * - * @return File * @throws NoUserException * @throws NotFoundException * @throws NotPermittedException @@ -563,10 +526,6 @@ private function getTextFile(int $documentId, string $userId): File { /** * Get file from share token * - * @param int|null $documentId - * @param string $shareToken - * - * @return File * @throws NotFoundException */ private function getTextFilePublic(?int $documentId, string $shareToken): File { @@ -599,8 +558,6 @@ private function getTextFilePublic(?int $documentId, string $shareToken): File { /** * Get share folder * - * @param string $shareToken - * * @throws NotFoundException */ private function getShareFolder(string $shareToken): ?Folder { @@ -626,11 +583,8 @@ private function getShareFolder(string $shareToken): ?Folder { } /** - * Actually delete attachment files which are not pointed in the markdown content - * - * @param int $fileId + * Actually delete attachment files which are not pointed in the Markdown content * - * @return int The number of deleted files * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException @@ -667,15 +621,11 @@ function ($node) use ($contentAttachmentFileIds, $contentAttachmentNames) { } /** - * Get attachment file ids listed in the markdown file content - * - * @param string $content - * - * @return array + * Get attachment file ids listed in the Markdown file content */ public static function getAttachmentIdsFromContent(string $content): array { $matches = []; - // matches [ANY_CONSIDERED_CORRECT_BY_PHP-MARKDOWN](ANY_URL/f/FILE_ID and captures FILE_ID + // matches [ANY_CONSIDERED_CORRECT_BY_PHP-MARKDOWN](ANY_URL/f/FILE_ID) and captures FILE_ID preg_match_all( '/\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[\])*\])*\])*\])*\])*\])*\]\(\S+\/f\/(\d+)/', $content, @@ -688,16 +638,11 @@ public static function getAttachmentIdsFromContent(string $content): array { } /** - * Get attachment file names listed in the markdown file content - * - * @param string $content - * @param int $fileId - * - * @return array + * Get attachment file names listed in the Markdown file content */ public static function getAttachmentNamesFromContent(string $content, int $fileId): array { $matches = []; - // matches ![ANY_CONSIDERED_CORRECT_BY_PHP-MARKDOWN](.attachments.DOCUMENT_ID/ANY_FILE_NAME) and captures FILE_NAME + // matches ![ANY_CONSIDERED_CORRECT_BY_PHP-MARKDOWN](.attachments.DOCUMENT_ID/ANY_FILE_NAME) and captures ANY_FILE_NAME preg_match_all( '/\!\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[\])*\])*\])*\])*\])*\])*\]\(\.attachments\.' . $fileId . '\/([^)&]+)\)/', $content, @@ -710,9 +655,36 @@ public static function getAttachmentNamesFromContent(string $content, int $fileI } /** - * @param File $source - * @param File $target - * + * @throws InvalidPathException + * @throws NoUserException + * @throws NotFoundException + * @throws NotPermittedException + * @throws LockedException + */ + public function copyAttachments(File $source, File $target): array { + try { + $sourceAttachmentDir = $this->getAttachmentDirectoryForFile($source); + } catch (NotFoundException $e) { + // silently return if no attachment dir was found for source file + return []; + } + // create a new attachment dir next to the new file + $targetAttachmentDir = $this->getAttachmentDirectoryForFile($target, true); + // copy the attachment files + $fileIdMapping = []; + foreach ($sourceAttachmentDir->getDirectoryListing() as $sourceAttachment) { + if ($sourceAttachment instanceof File) { + $newFile = $targetAttachmentDir->newFile($sourceAttachment->getName(), $sourceAttachment->getContent()); + $fileIdMapping[] = [ + $sourceAttachment->getId(), + $newFile->getId() + ]; + } + } + return $fileIdMapping; + } + + /** * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException @@ -737,8 +709,6 @@ public function moveAttachments(File $source, File $target): void { } /** - * @param File $source - * * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException @@ -755,40 +725,6 @@ public function deleteAttachments(File $source): void { $sourceAttachmentDir->delete(); } - /** - * @param File $source - * @param File $target - * - * @return array file id translation map - * @throws InvalidPathException - * @throws NoUserException - * @throws NotFoundException - * @throws NotPermittedException - * @throws LockedException - */ - public function copyAttachments(File $source, File $target): array { - try { - $sourceAttachmentDir = $this->getAttachmentDirectoryForFile($source); - } catch (NotFoundException $e) { - // silently return if no attachment dir was found for source file - return []; - } - // create a new attachment dir next to the new file - $targetAttachmentDir = $this->getAttachmentDirectoryForFile($target, true); - // copy the attachment files - $fileIdMapping = []; - foreach ($sourceAttachmentDir->getDirectoryListing() as $sourceAttachment) { - if ($sourceAttachment instanceof File) { - $newFile = $targetAttachmentDir->newFile($sourceAttachment->getName(), $sourceAttachment->getContent()); - $fileIdMapping[] = [ - $sourceAttachment->getId(), - $newFile->getId() - ]; - } - } - return $fileIdMapping; - } - public static function replaceAttachmentFolderId(File $source, File $target): void { $sourceId = $source->getId(); $targetId = $target->getId(); From 7e774f0ed0f55f624e43a45b9dfdf43ae840ebcf Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 6 Feb 2026 15:13:03 +0100 Subject: [PATCH 2/5] chore(ViewerComponent): adjust property order to please linter Signed-off-by: Jonas --- src/components/ViewerComponent.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ViewerComponent.vue b/src/components/ViewerComponent.vue index b2c0dc44275..5f31c0409c6 100644 --- a/src/components/ViewerComponent.vue +++ b/src/components/ViewerComponent.vue @@ -36,12 +36,12 @@ export default defineComponent({ SourceView, Editor, }, - inheritAttrs: false, provide() { return { isEmbedded: this.isEmbedded, } }, + inheritAttrs: false, props: { filename: { type: String, From 15a94dddba94375a665301d082cbec5ddec3fcca Mon Sep 17 00:00:00 2001 From: Jonas Date: Sat, 31 Jan 2026 20:27:29 +0100 Subject: [PATCH 3/5] feat(editorApi): Add functions for renaming and deleting attachments Required by Collectives for attachment operations in sidebar. Signed-off-by: Jonas --- src/editor.js | 52 ++++++++++++++++++++++++++++++- src/helpers/attachmentFilename.ts | 15 +++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/helpers/attachmentFilename.ts diff --git a/src/editor.js b/src/editor.js index 3ab746fbae1..194579bd62b 100644 --- a/src/editor.js +++ b/src/editor.js @@ -13,11 +13,12 @@ import { OPEN_LINK_HANDLER, } from './components/Editor.provider.ts' import { ACTION_ATTACHMENT_PROMPT } from './components/Editor/MediaHandler.provider.js' +import { encodeAttachmentFilename } from './helpers/attachmentFilename.ts' import { openLink } from './helpers/links.js' // eslint-disable-next-line import/no-unresolved, n/no-missing-import import 'vite/modulepreload-polyfill' -const apiVersion = '1.3' +const apiVersion = '1.4' window.OCA.Text = { ...window.OCA.Text, @@ -141,6 +142,55 @@ class TextEditorEmbed { .run() } + replaceAttachmentFilename(pageId, oldName, newName) { + const oldSrc = + '.attachments.' + pageId + '/' + encodeAttachmentFilename(oldName) + const newSrc = + '.attachments.' + pageId + '/' + encodeAttachmentFilename(newName) + const { view, state } = this.#getEditorComponent().editor + const { doc, schema, tr } = state + let modified = false + + doc.descendants((node, pos) => { + if (!node.type === schema.nodes.image || node.attrs.src !== oldSrc) { + return + } + + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + src: newSrc, + alt: node.attrs.alt.replace(oldName, newName), + }) + modified = true + }) + + if (modified) { + view.dispatch(tr) + this.save() + } + } + + removeAttachmentReferences(pageId, name) { + const src = '.attachments.' + pageId + '/' + encodeAttachmentFilename(name) + const { view, state } = this.#getEditorComponent().editor + const { doc, schema, tr } = state + let modified = false + + doc.descendants((node, pos) => { + if (!node.type === schema.nodes.image || node.attrs.src !== src) { + return + } + + tr.delete(pos, pos + node.nodeSize) + modified = true + }) + + if (modified) { + view.dispatch(tr) + this.save() + } + } + focus() { this.#getEditorComponent().editor?.commands.focus() } diff --git a/src/helpers/attachmentFilename.ts b/src/helpers/attachmentFilename.ts new file mode 100644 index 00000000000..81c66305e60 --- /dev/null +++ b/src/helpers/attachmentFilename.ts @@ -0,0 +1,15 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Encode filename the same way as at `insertAttachment` in MediaHandler.vue + * + * @param filename - The filename to encode + */ +export function encodeAttachmentFilename(filename: string) { + return encodeURIComponent(filename).replace(/[!'()*]/g, (c) => { + return '%' + c.charCodeAt(0).toString(16).toUpperCase() + }) +} From c15dbf36f5f764ec14e3056cd8b27a85b9e1700f Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 6 Feb 2026 14:21:33 +0100 Subject: [PATCH 4/5] feat(AttachmentService): Don't cleanup attachments for Collectives pages Signed-off-by: Jonas --- lib/Service/AttachmentService.php | 5 +++++ tests/stub.php | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/Service/AttachmentService.php b/lib/Service/AttachmentService.php index da0d6520087..b246c9028cc 100755 --- a/lib/Service/AttachmentService.php +++ b/lib/Service/AttachmentService.php @@ -594,6 +594,11 @@ private function getShareFolder(string $shareToken): ?Folder { public function cleanupAttachments(int $fileId): int { $textFile = $this->rootFolder->getFirstNodeById($fileId); if ($textFile instanceof File) { + if ($textFile->getStorage()->instanceOfStorage(\OCA\Collectives\Mount\CollectiveStorage::class)) { + // Don't cleanup attachments for Collectives pages + return 0; + } + if ($textFile->getMimeType() === 'text/markdown') { // get IDs of the files inside the attachment dir try { diff --git a/tests/stub.php b/tests/stub.php index d08c0201581..cd14233ef89 100644 --- a/tests/stub.php +++ b/tests/stub.php @@ -12,6 +12,24 @@ class BaseResponse { } } +namespace OC\Files\Storage\Wrapper { + use OCP\Files\Storage\IStorage; + + class Wrapper implements IStorage { + public function __construct(array $parameters) { + } + } +} + +namespace OCA\Collectives\Mount { + + use OC\Files\Storage\Wrapper\Wrapper; + use OCP\Files\Storage\IConstructableStorage; + + class CollectiveStorage extends Wrapper implements IConstructableStorage { + } +} + namespace OCA\Files\Event { class LoadAdditionalScriptsEvent extends \OCP\EventDispatcher\Event { } From aa67811ecef588e381b6b7c88abe3039d17a7faa Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 6 Feb 2026 14:55:24 +0100 Subject: [PATCH 5/5] feat(attachments): allow to listen for changes via editorApi Required for Collectives to track attachments. Signed-off-by: Jonas --- src/editor.js | 7 ++++ src/nodes/Image.js | 33 ++++++++++++++- src/plugins/extractAttachmentSrcs.ts | 31 ++++++++++++++ .../plugins/extractAttachmentSrcs.spec.js | 41 +++++++++++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 src/plugins/extractAttachmentSrcs.ts create mode 100644 src/tests/plugins/extractAttachmentSrcs.spec.js diff --git a/src/editor.js b/src/editor.js index 194579bd62b..4c19602cac6 100644 --- a/src/editor.js +++ b/src/editor.js @@ -74,6 +74,11 @@ class TextEditorEmbed { return this } + onAttachmentsUpdated(onAttachmentsUpdatedCallback = () => {}) { + subscribe('text:editor:attachments:updated', onAttachmentsUpdatedCallback) + return this + } + render(el) { el.innerHTML = '' const element = document.createElement('div') @@ -256,6 +261,7 @@ window.OCA.Text.createEditor = async function ({ onMentionInsert = undefined, openLinkHandler = undefined, onSearch = undefined, + onAttachmentsUpdated = ({ attachmentSrcs }) => {}, }) { const { default: MarkdownContentEditor } = await import( './components/Editor/MarkdownContentEditor.vue' @@ -339,6 +345,7 @@ window.OCA.Text.createEditor = async function ({ .onTocToggle(onOutlineToggle) .onTocToggle(onTocToggle) .onTocPin(onTocPin) + .onAttachmentsUpdated(onAttachmentsUpdated) .render(el) } diff --git a/src/nodes/Image.js b/src/nodes/Image.js index 231c609f7de..93491b55847 100644 --- a/src/nodes/Image.js +++ b/src/nodes/Image.js @@ -3,12 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { emit } from '@nextcloud/event-bus' import TiptapImage from '@tiptap/extension-image' import { defaultMarkdownSerializer } from '@tiptap/pm/markdown' -import { Plugin } from '@tiptap/pm/state' +import { Plugin, PluginKey } from '@tiptap/pm/state' import { VueNodeViewRenderer } from '@tiptap/vue-2' +import extractAttachmentSrcs from '../plugins/extractAttachmentSrcs.ts' import ImageView from './ImageView.vue' +const imageFileDropPluginKey = new PluginKey('imageFileDrop') +const imageExtractAttachmentsKey = new PluginKey('imageExtractAttachments') + const Image = TiptapImage.extend({ selectable: false, @@ -41,6 +46,7 @@ const Image = TiptapImage.extend({ addProseMirrorPlugins() { return [ new Plugin({ + key: imageFileDropPluginKey, props: { handleDrop: (view, event, slice) => { // only catch the drop if it contains files @@ -82,6 +88,31 @@ const Image = TiptapImage.extend({ }, }, }), + new Plugin({ + key: imageExtractAttachmentsKey, + state: { + init(_, { doc }) { + const attachmentSrcs = extractAttachmentSrcs(doc) + emit('text:editor:attachments:updated', { attachmentSrcs }) + return { attachmentSrcs } + }, + apply(tr, value, _oldState, newState) { + if (!tr.docChanged) { + return value + } + const attachmentSrcs = extractAttachmentSrcs(newState.doc) + if ( + JSON.stringify(attachmentSrcs) + === JSON.stringify(value?.attachmentSrcs) + ) { + return value + } + + emit('text:editor:attachments:updated', { attachmentSrcs }) + return { attachmentSrcs } + }, + }, + }), ] }, diff --git a/src/plugins/extractAttachmentSrcs.ts b/src/plugins/extractAttachmentSrcs.ts new file mode 100644 index 00000000000..abde6604f7b --- /dev/null +++ b/src/plugins/extractAttachmentSrcs.ts @@ -0,0 +1,31 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@tiptap/pm/model' + +/** + * Extract attachment src attributes from doc + * + * @param doc - the prosemirror doc + * @return src attributes of attachments found in the doc + */ +export default function extractAttachmentSrcs(doc: Node) { + const attachmentSrcs: string[] = [] + + doc.descendants((node) => { + if (node.type.name !== 'image' && node.type.name !== 'imageInline') { + return + } + + // ignore empty src + if (!node.attrs.src) { + return + } + + attachmentSrcs.push(node.attrs.src) + }) + + return attachmentSrcs +} diff --git a/src/tests/plugins/extractAttachmentSrcs.spec.js b/src/tests/plugins/extractAttachmentSrcs.spec.js new file mode 100644 index 00000000000..22b64f2aad4 --- /dev/null +++ b/src/tests/plugins/extractAttachmentSrcs.spec.js @@ -0,0 +1,41 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Image from '../../nodes/Image.js' +import extractAttachmentSrcs from '../../plugins/extractAttachmentSrcs.ts' +import createCustomEditor from '../testHelpers/createCustomEditor.ts' + +describe('extractAttachmentSrcs', () => { + it('returns an empty array for an empty doc', () => { + const doc = prepareDoc('') + const attachmentSrcs = extractAttachmentSrcs(doc) + expect(attachmentSrcs).toEqual([]) + }) + + it('returns headings', () => { + const content = + '

' + const doc = prepareDoc(content) + const attachmentSrcs = extractAttachmentSrcs(doc) + expect(attachmentSrcs).toEqual([ + '.attachments.123/test.pdf', + '.attachments.456/test2.png', + ]) + }) + + it('ignores an empty src', () => { + const content = '' + const doc = prepareDoc(content) + const attachmentSrcs = extractAttachmentSrcs(doc) + expect(attachmentSrcs).toEqual([]) + }) +}) + +const prepareDoc = (content) => { + const editor = createCustomEditor(content, [Image]) + const doc = editor.state.doc + editor.destroy() + return doc +}