diff --git a/lib/Service/AttachmentService.php b/lib/Service/AttachmentService.php index 6aaa9eec15f..44fbdf552ed 100755 --- a/lib/Service/AttachmentService.php +++ b/lib/Service/AttachmentService.php @@ -640,6 +640,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/src/editor.js b/src/editor.js index 0976e2c440d..48622fc9a68 100644 --- a/src/editor.js +++ b/src/editor.js @@ -13,6 +13,7 @@ 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' @@ -70,6 +71,11 @@ class TextEditorEmbed { return this } + onAttachmentsUpdated(onAttachmentsUpdatedCallback = () => {}) { + subscribe('text:editor:attachments:updated', onAttachmentsUpdatedCallback) + return this + } + render(el) { el.innerHTML = '' const element = document.createElement('div') @@ -138,6 +144,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() } @@ -201,6 +256,7 @@ window.OCA.Text.createEditor = async function ({ onMentionInsert = undefined, openLinkHandler = undefined, onSearch = undefined, + onAttachmentsUpdated = ({ attachmentSrcs }) => {}, }) { const { default: MarkdownContentEditor } = await import( /* webpackChunkName: "editor" */ './components/Editor/MarkdownContentEditor.vue' @@ -286,6 +342,7 @@ window.OCA.Text.createEditor = async function ({ .onUpdate(onUpdate) .onOutlineToggle(onOutlineToggle) .onSearch(onSearch) + .onAttachmentsUpdated(onAttachmentsUpdated) .render(el) } 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() + }) +} 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 +} 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 { }