Skip to content

Commit 2ad7065

Browse files
authored
Merge pull request #8254 from nextcloud/backport/8238/stable32
[stable32] feat: improve attachments integration with Collectives
2 parents 1c5d2a5 + 4b93032 commit 2ad7065

7 files changed

Lines changed: 199 additions & 1 deletion

File tree

lib/Service/AttachmentService.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,11 @@ private function getShareFolder(string $shareToken): ?Folder {
640640
public function cleanupAttachments(int $fileId): int {
641641
$textFile = $this->rootFolder->getFirstNodeById($fileId);
642642
if ($textFile instanceof File) {
643+
if ($textFile->getStorage()->instanceOfStorage(\OCA\Collectives\Mount\CollectiveStorage::class)) {
644+
// Don't cleanup attachments for Collectives pages
645+
return 0;
646+
}
647+
643648
if ($textFile->getMimeType() === 'text/markdown') {
644649
// get IDs of the files inside the attachment dir
645650
try {

src/editor.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
OPEN_LINK_HANDLER,
1414
} from './components/Editor.provider.ts'
1515
import { ACTION_ATTACHMENT_PROMPT } from './components/Editor/MediaHandler.provider.js'
16+
import { encodeAttachmentFilename } from './helpers/attachmentFilename.ts'
1617
import { openLink } from './helpers/links.js'
1718
// eslint-disable-next-line import/no-unresolved, n/no-missing-import
1819
import 'vite/modulepreload-polyfill'
@@ -70,6 +71,11 @@ class TextEditorEmbed {
7071
return this
7172
}
7273

74+
onAttachmentsUpdated(onAttachmentsUpdatedCallback = () => {}) {
75+
subscribe('text:editor:attachments:updated', onAttachmentsUpdatedCallback)
76+
return this
77+
}
78+
7379
render(el) {
7480
el.innerHTML = ''
7581
const element = document.createElement('div')
@@ -138,6 +144,55 @@ class TextEditorEmbed {
138144
.run()
139145
}
140146

147+
replaceAttachmentFilename(pageId, oldName, newName) {
148+
const oldSrc =
149+
'.attachments.' + pageId + '/' + encodeAttachmentFilename(oldName)
150+
const newSrc =
151+
'.attachments.' + pageId + '/' + encodeAttachmentFilename(newName)
152+
const { view, state } = this.#getEditorComponent().editor
153+
const { doc, schema, tr } = state
154+
let modified = false
155+
156+
doc.descendants((node, pos) => {
157+
if (!node.type === schema.nodes.image || node.attrs.src !== oldSrc) {
158+
return
159+
}
160+
161+
tr.setNodeMarkup(pos, undefined, {
162+
...node.attrs,
163+
src: newSrc,
164+
alt: node.attrs.alt.replace(oldName, newName),
165+
})
166+
modified = true
167+
})
168+
169+
if (modified) {
170+
view.dispatch(tr)
171+
this.save()
172+
}
173+
}
174+
175+
removeAttachmentReferences(pageId, name) {
176+
const src = '.attachments.' + pageId + '/' + encodeAttachmentFilename(name)
177+
const { view, state } = this.#getEditorComponent().editor
178+
const { doc, schema, tr } = state
179+
let modified = false
180+
181+
doc.descendants((node, pos) => {
182+
if (!node.type === schema.nodes.image || node.attrs.src !== src) {
183+
return
184+
}
185+
186+
tr.delete(pos, pos + node.nodeSize)
187+
modified = true
188+
})
189+
190+
if (modified) {
191+
view.dispatch(tr)
192+
this.save()
193+
}
194+
}
195+
141196
focus() {
142197
this.#getEditorComponent().editor?.commands.focus()
143198
}
@@ -201,6 +256,7 @@ window.OCA.Text.createEditor = async function ({
201256
onMentionInsert = undefined,
202257
openLinkHandler = undefined,
203258
onSearch = undefined,
259+
onAttachmentsUpdated = ({ attachmentSrcs }) => {},
204260
}) {
205261
const { default: MarkdownContentEditor } = await import(
206262
/* webpackChunkName: "editor" */ './components/Editor/MarkdownContentEditor.vue'
@@ -286,6 +342,7 @@ window.OCA.Text.createEditor = async function ({
286342
.onUpdate(onUpdate)
287343
.onOutlineToggle(onOutlineToggle)
288344
.onSearch(onSearch)
345+
.onAttachmentsUpdated(onAttachmentsUpdated)
289346
.render(el)
290347
}
291348

src/helpers/attachmentFilename.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
/**
7+
* Encode filename the same way as at `insertAttachment` in MediaHandler.vue
8+
*
9+
* @param filename - The filename to encode
10+
*/
11+
export function encodeAttachmentFilename(filename: string) {
12+
return encodeURIComponent(filename).replace(/[!'()*]/g, (c) => {
13+
return '%' + c.charCodeAt(0).toString(16).toUpperCase()
14+
})
15+
}

src/nodes/Image.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6+
import { emit } from '@nextcloud/event-bus'
67
import TiptapImage from '@tiptap/extension-image'
78
import { defaultMarkdownSerializer } from '@tiptap/pm/markdown'
8-
import { Plugin } from '@tiptap/pm/state'
9+
import { Plugin, PluginKey } from '@tiptap/pm/state'
910
import { VueNodeViewRenderer } from '@tiptap/vue-2'
11+
import extractAttachmentSrcs from '../plugins/extractAttachmentSrcs.ts'
1012
import ImageView from './ImageView.vue'
1113

14+
const imageFileDropPluginKey = new PluginKey('imageFileDrop')
15+
const imageExtractAttachmentsKey = new PluginKey('imageExtractAttachments')
16+
1217
const Image = TiptapImage.extend({
1318
selectable: false,
1419

@@ -41,6 +46,7 @@ const Image = TiptapImage.extend({
4146
addProseMirrorPlugins() {
4247
return [
4348
new Plugin({
49+
key: imageFileDropPluginKey,
4450
props: {
4551
handleDrop: (view, event, slice) => {
4652
// only catch the drop if it contains files
@@ -82,6 +88,31 @@ const Image = TiptapImage.extend({
8288
},
8389
},
8490
}),
91+
new Plugin({
92+
key: imageExtractAttachmentsKey,
93+
state: {
94+
init(_, { doc }) {
95+
const attachmentSrcs = extractAttachmentSrcs(doc)
96+
emit('text:editor:attachments:updated', { attachmentSrcs })
97+
return { attachmentSrcs }
98+
},
99+
apply(tr, value, _oldState, newState) {
100+
if (!tr.docChanged) {
101+
return value
102+
}
103+
const attachmentSrcs = extractAttachmentSrcs(newState.doc)
104+
if (
105+
JSON.stringify(attachmentSrcs)
106+
=== JSON.stringify(value?.attachmentSrcs)
107+
) {
108+
return value
109+
}
110+
111+
emit('text:editor:attachments:updated', { attachmentSrcs })
112+
return { attachmentSrcs }
113+
},
114+
},
115+
}),
85116
]
86117
},
87118

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { Node } from '@tiptap/pm/model'
7+
8+
/**
9+
* Extract attachment src attributes from doc
10+
*
11+
* @param doc - the prosemirror doc
12+
* @return src attributes of attachments found in the doc
13+
*/
14+
export default function extractAttachmentSrcs(doc: Node) {
15+
const attachmentSrcs: string[] = []
16+
17+
doc.descendants((node) => {
18+
if (node.type.name !== 'image' && node.type.name !== 'imageInline') {
19+
return
20+
}
21+
22+
// ignore empty src
23+
if (!node.attrs.src) {
24+
return
25+
}
26+
27+
attachmentSrcs.push(node.attrs.src)
28+
})
29+
30+
return attachmentSrcs
31+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import Image from '../../nodes/Image.js'
7+
import extractAttachmentSrcs from '../../plugins/extractAttachmentSrcs.ts'
8+
import createCustomEditor from '../testHelpers/createCustomEditor.ts'
9+
10+
describe('extractAttachmentSrcs', () => {
11+
it('returns an empty array for an empty doc', () => {
12+
const doc = prepareDoc('')
13+
const attachmentSrcs = extractAttachmentSrcs(doc)
14+
expect(attachmentSrcs).toEqual([])
15+
})
16+
17+
it('returns headings', () => {
18+
const content =
19+
'<figure><img src=".attachments.123/test.pdf"></figure><br><figure><img src=".attachments.456/test2.png"></figure>'
20+
const doc = prepareDoc(content)
21+
const attachmentSrcs = extractAttachmentSrcs(doc)
22+
expect(attachmentSrcs).toEqual([
23+
'.attachments.123/test.pdf',
24+
'.attachments.456/test2.png',
25+
])
26+
})
27+
28+
it('ignores an empty src', () => {
29+
const content = '<img>'
30+
const doc = prepareDoc(content)
31+
const attachmentSrcs = extractAttachmentSrcs(doc)
32+
expect(attachmentSrcs).toEqual([])
33+
})
34+
})
35+
36+
const prepareDoc = (content) => {
37+
const editor = createCustomEditor(content, [Image])
38+
const doc = editor.state.doc
39+
editor.destroy()
40+
return doc
41+
}

tests/stub.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,24 @@ class BaseResponse {
1212
}
1313
}
1414

15+
namespace OC\Files\Storage\Wrapper {
16+
use OCP\Files\Storage\IStorage;
17+
18+
class Wrapper implements IStorage {
19+
public function __construct(array $parameters) {
20+
}
21+
}
22+
}
23+
24+
namespace OCA\Collectives\Mount {
25+
26+
use OC\Files\Storage\Wrapper\Wrapper;
27+
use OCP\Files\Storage\IConstructableStorage;
28+
29+
class CollectiveStorage extends Wrapper implements IConstructableStorage {
30+
}
31+
}
32+
1533
namespace OCA\Files\Event {
1634
class LoadAdditionalScriptsEvent extends \OCP\EventDispatcher\Event {
1735
}

0 commit comments

Comments
 (0)