Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions lib/Service/AttachmentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
57 changes: 57 additions & 0 deletions src/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -286,6 +342,7 @@ window.OCA.Text.createEditor = async function ({
.onUpdate(onUpdate)
.onOutlineToggle(onOutlineToggle)
.onSearch(onSearch)
.onAttachmentsUpdated(onAttachmentsUpdated)
.render(el)
}

Expand Down
15 changes: 15 additions & 0 deletions src/helpers/attachmentFilename.ts
Original file line number Diff line number Diff line change
@@ -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()
})
}
33 changes: 32 additions & 1 deletion src/nodes/Image.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
},
},
}),
]
},

Expand Down
31 changes: 31 additions & 0 deletions src/plugins/extractAttachmentSrcs.ts
Original file line number Diff line number Diff line change
@@ -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
}
41 changes: 41 additions & 0 deletions src/tests/plugins/extractAttachmentSrcs.spec.js
Original file line number Diff line number Diff line change
@@ -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 =
'<figure><img src=".attachments.123/test.pdf"></figure><br><figure><img src=".attachments.456/test2.png"></figure>'
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 = '<img>'
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
}
18 changes: 18 additions & 0 deletions tests/stub.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
}
Expand Down
Loading