diff --git a/lib/Service/AttachmentService.php b/lib/Service/AttachmentService.php
index 4712ced912b..b246c9028cc 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
+ * Actually delete attachment files which are not pointed in the Markdown content
*
- * @param int $fileId
- *
- * @return int The number of deleted files
* @throws NotFoundException
* @throws NotPermittedException
* @throws InvalidPathException
@@ -640,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 {
@@ -667,15 +626,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 +643,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  and captures FILE_NAME
+ // matches  and captures ANY_FILE_NAME
preg_match_all(
'/\!\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[\])*\])*\])*\])*\])*\])*\]\(\.attachments\.' . $fileId . '\/([^)&]+)\)/',
$content,
@@ -710,9 +660,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 +714,6 @@ public function moveAttachments(File $source, File $target): void {
}
/**
- * @param File $source
- *
* @throws NotFoundException
* @throws NotPermittedException
* @throws InvalidPathException
@@ -755,40 +730,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();
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,
diff --git a/src/editor.js b/src/editor.js
index 3ab746fbae1..4c19602cac6 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,
@@ -73,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')
@@ -141,6 +147,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()
}
@@ -206,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'
@@ -289,6 +345,7 @@ window.OCA.Text.createEditor = async function ({
.onTocToggle(onOutlineToggle)
.onTocToggle(onTocToggle)
.onTocPin(onTocPin)
+ .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 {
}