Skip to content

Commit c290077

Browse files
Merge pull request #6676 from pbirrer/feature/create-attachments
feat(attachments): add support for creating new attachments
2 parents 8875d6f + 7610971 commit c290077

11 files changed

Lines changed: 213 additions & 12 deletions

File tree

appinfo/routes.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
['name' => 'Attachment#insertAttachmentFile', 'url' => '/attachment/filepath', 'verb' => 'POST'],
1919
/** @see Controller\AttachmentController::uploadAttachment() */
2020
['name' => 'Attachment#uploadAttachment', 'url' => '/attachment/upload', 'verb' => 'POST'],
21+
/** @see Controller\AttachmentController::createAttachment() */
22+
['name' => 'Attachment#createAttachment', 'url' => '/attachment/create', 'verb' => 'POST'],
2123
/** @see Controller\AttachmentController::getImageFile() */
2224
['name' => 'Attachment#getImageFile', 'url' => '/image', 'verb' => 'GET'],
2325
/** @see Controller\AttachmentController::getMediaFile() */

cypress/e2e/attachments.spec.js

100644100755
Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const attachmentFileNameToId = {}
1212

1313
const ACTION_UPLOAD_LOCAL_FILE = 'insert-attachment-upload'
1414
const ACTION_INSERT_FROM_FILES = 'insert-attachment-insert'
15+
const ACTION_CREATE_NEW_TEXT_FILE = 'insert-attachment-add-text-0'
1516

1617
/**
1718
* @param {string} name name of file
@@ -115,16 +116,17 @@ const checkAttachment = (documentId, fileName, fileId, index, isImage = true) =>
115116
* @param {string} requestAlias Alias of the request we are waiting for
116117
* @param {number|undefined} index of the attachment
117118
* @param {boolean} isImage is the attachment an image or a media file?
119+
* @param {Function} check function used to check document for attachment
118120
*/
119-
const waitForRequestAndCheckAttachment = (requestAlias, index, isImage = true) => {
121+
const waitForRequestAndCheckAttachment = (requestAlias, index, isImage = true, check = checkAttachment) => {
120122
return cy.wait('@' + requestAlias)
121123
.then((req) => {
122124
// the name of the created file on NC side is returned in the response
123125
const fileId = req.response.body.id
124126
const fileName = req.response.body.name
125127
const documentId = req.response.body.documentId
126128

127-
return checkAttachment(documentId, fileName, fileId, index, isImage)
129+
return check(documentId, fileName, fileId, index, isImage)
128130
})
129131
}
130132

@@ -279,6 +281,26 @@ describe('Test all attachment insertion methods', () => {
279281
cy.closeFile()
280282
})
281283

284+
it('Create a new text file as an attachment', () => {
285+
const check = (documentId, fileName) => {
286+
cy.log('Check the attachment is visible and well formed', documentId, fileName)
287+
return cy.get(`.text-editor [basename="${fileName}"]`)
288+
.find('.text-editor__wrapper')
289+
.should('be.visible')
290+
}
291+
292+
cy.visit('/apps/files')
293+
cy.openFile('test.md')
294+
295+
cy.log('Create a new text file as an attachment')
296+
const requestAlias = 'create-attachment-request'
297+
cy.intercept({ method: 'POST', url: '**/text/attachment/create' }).as(requestAlias)
298+
clickOnAttachmentAction(ACTION_CREATE_NEW_TEXT_FILE)
299+
.then(() => {
300+
return waitForRequestAndCheckAttachment(requestAlias, undefined, false, check)
301+
})
302+
})
303+
282304
it('test if attachment files are in the attachment folder', () => {
283305
cy.visit('/apps/files')
284306

lib/Controller/AttachmentController.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,30 @@ public function uploadAttachment(string $token = ''): DataResponse {
144144
}
145145
}
146146

147+
#[NoAdminRequired]
148+
#[PublicPage]
149+
#[RequireDocumentSession]
150+
public function createAttachment(string $token = ''): DataResponse {
151+
$documentId = $this->getSession()->getDocumentId();
152+
try {
153+
$userId = $this->getSession()->getUserId();
154+
$newFileName = $this->request->getParam('fileName', 'text.md');
155+
$createResult = $this->attachmentService->createAttachmentFile($documentId, $newFileName, $userId);
156+
if (isset($createResult['error'])) {
157+
return new DataResponse($createResult, Http::STATUS_BAD_REQUEST);
158+
} else {
159+
return new DataResponse($createResult);
160+
}
161+
} catch (InvalidPathException $e) {
162+
$this->logger->error('File creation error', ['exception' => $e]);
163+
$error = $e->getMessage() ?: 'Upload error';
164+
return new DataResponse(['error' => $error], Http::STATUS_BAD_REQUEST);
165+
} catch (Exception $e) {
166+
$this->logger->error('File creation error', ['exception' => $e]);
167+
return new DataResponse(['error' => 'File creation error'], Http::STATUS_BAD_REQUEST);
168+
}
169+
}
170+
147171
private function getUploadedFile(string $key): array {
148172
$file = $this->request->getUploadedFile($key);
149173
$error = null;

lib/Service/AttachmentService.php

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,35 @@ public function insertAttachmentFile(int $documentId, string $path, string $user
331331
return $this->copyFile($originalFile, $saveDir, $textFile);
332332
}
333333

334+
/**
335+
* create a new file in the attachment folder
336+
*
337+
* @param int $documentId
338+
* @param string $userId
339+
*
340+
* @return array
341+
* @throws NotFoundException
342+
* @throws NotPermittedException
343+
* @throws InvalidPathException
344+
* @throws NoUserException
345+
*/
346+
public function createAttachmentFile(int $documentId, string $newFileName, string $userId): array {
347+
$textFile = $this->getTextFile($documentId, $userId);
348+
if (!$textFile->isUpdateable()) {
349+
throw new NotPermittedException('No write permissions');
350+
}
351+
$saveDir = $this->getAttachmentDirectoryForFile($textFile, true);
352+
$fileName = self::getUniqueFileName($saveDir, $newFileName);
353+
$newFile = $saveDir->newFile($fileName);
354+
return [
355+
'name' => $newFile->getName(),
356+
'dirname' => $saveDir->getName(),
357+
'id' => $newFile->getId(),
358+
'documentId' => $textFile->getId(),
359+
'mimetype' => $newFile->getMimetype(),
360+
];
361+
}
362+
334363
/**
335364
* @param File $originalFile
336365
* @param Folder $saveDir
@@ -552,23 +581,44 @@ public function cleanupAttachments(int $fileId): int {
552581
// this only happens if the attachment dir was deleted by the user while editing the document
553582
return 0;
554583
}
555-
$attachmentsByName = [];
556-
foreach ($attachmentDir->getDirectoryListing() as $attNode) {
557-
$attachmentsByName[$attNode->getName()] = $attNode;
558-
}
559-
584+
$contentAttachmentFileIds = self::getAttachmentIdsFromContent($textFile->getContent());
560585
$contentAttachmentNames = self::getAttachmentNamesFromContent($textFile->getContent(), $fileId);
561586

562-
$toDelete = array_diff(array_keys($attachmentsByName), $contentAttachmentNames);
563-
foreach ($toDelete as $name) {
564-
$attachmentsByName[$name]->delete();
587+
$toDelete = array_filter($attachmentDir->getDirectoryListing(),
588+
function ($node) use ($contentAttachmentFileIds, $contentAttachmentNames) {
589+
return !in_array($node->getName(), $contentAttachmentNames) &&
590+
!in_array($node->getId(), $contentAttachmentFileIds);
591+
}
592+
);
593+
foreach ($toDelete as $node) {
594+
$node->delete();
565595
}
566596
return count($toDelete);
567597
}
568598
}
569599
return 0;
570600
}
571601

602+
/**
603+
* Get attachment file ids listed in the markdown file content
604+
*
605+
* @param string $content
606+
*
607+
* @return array
608+
*/
609+
public static function getAttachmentIdsFromContent(string $content): array {
610+
$matches = [];
611+
// matches [ANY_CONSIDERED_CORRECT_BY_PHP-MARKDOWN](ANY_URL/f/FILE_ID and captures FILE_ID
612+
preg_match_all(
613+
'/\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[\])*\])*\])*\])*\])*\])*\]\(\S+\/f\/(\d+)/',
614+
$content,
615+
$matches,
616+
PREG_SET_ORDER
617+
);
618+
return array_map(static function (array $match) {
619+
return intval($match[1]);
620+
}, $matches);
621+
}
572622

573623
/**
574624
* Get attachment file names listed in the markdown file content

src/components/Editor/MediaHandler.provider.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
export const STATE_UPLOADING = Symbol('state:uploading-state')
77
export const ACTION_ATTACHMENT_PROMPT = Symbol('editor:action:attachment-prompt')
88
export const ACTION_CHOOSE_LOCAL_ATTACHMENT = Symbol('editor:action:upload-attachment')
9+
export const ACTION_CREATE_ATTACHMENT = Symbol('editor:action:create-attachment')
910

1011
export const useUploadingStateMixin = {
1112
inject: {
@@ -29,3 +30,9 @@ export const useActionChooseLocalAttachmentMixin = {
2930
$callChooseLocalAttachment: { from: ACTION_CHOOSE_LOCAL_ATTACHMENT, default: () => {} },
3031
},
3132
}
33+
34+
export const useActionCreateAttachmentMixin = {
35+
inject: {
36+
$callCreateAttachment: { from: ACTION_CREATE_ATTACHMENT, default: () => (template) => {} },
37+
},
38+
}

src/components/Editor/MediaHandler.vue

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import { getCurrentUser } from '@nextcloud/auth'
2828
import { showError } from '@nextcloud/dialogs'
2929
import { emit } from '@nextcloud/event-bus'
30+
import { generateUrl } from '@nextcloud/router'
3031
import { logger } from '../../helpers/logger.js'
3132
import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile'
3233
@@ -39,6 +40,7 @@ import {
3940
import {
4041
ACTION_ATTACHMENT_PROMPT,
4142
ACTION_CHOOSE_LOCAL_ATTACHMENT,
43+
ACTION_CREATE_ATTACHMENT,
4244
STATE_UPLOADING,
4345
} from './MediaHandler.provider.js'
4446
@@ -57,6 +59,9 @@ export default {
5759
[ACTION_CHOOSE_LOCAL_ATTACHMENT]: {
5860
get: () => this.chooseLocalFile,
5961
},
62+
[ACTION_CREATE_ATTACHMENT]: {
63+
get: () => this.createAttachment,
64+
},
6065
[STATE_UPLOADING]: {
6166
get: () => this.state,
6267
},
@@ -173,6 +178,26 @@ export default {
173178
this.state.isUploadingAttachments = false
174179
})
175180
},
181+
createAttachment(template) {
182+
this.state.isUploadingAttachments = true
183+
return this.$syncService.createAttachment(template).then((response) => {
184+
this.insertAttachmentPreview(response.data?.id)
185+
}).catch((error) => {
186+
logger.error('Failed to create attachment', { error })
187+
showError(t('text', 'Failed to create attachment'))
188+
}).then(() => {
189+
this.state.isUploadingAttachments = false
190+
})
191+
},
192+
insertAttachmentPreview(fileId) {
193+
const url = new URL(generateUrl(`/f/${fileId}`), window.origin)
194+
const href = url.href.replaceAll(' ', '%20')
195+
this.$editor
196+
.chain()
197+
.focus()
198+
.insertPreview(href)
199+
.run()
200+
},
176201
insertAttachment(name, fileId, mimeType, position = null, dirname = '') {
177202
// inspired by the fixedEncodeURIComponent function suggested in
178203
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent

src/components/Menu/ActionAttachmentUpload.vue

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,29 +34,49 @@
3434
</template>
3535
{{ t('text', 'Insert from Files') }}
3636
</NcActionButton>
37+
<template v-if="templates.length">
38+
<NcActionSeparator />
39+
<NcActionButton v-for="(template, index) in templates"
40+
:key="`${template.app}-${index}`"
41+
close-after-click
42+
:disabled="isUploadingAttachments"
43+
:data-text-action-entry="`${actionEntry.key}-add-${template.app}-${index}`"
44+
@click="createAttachment(template)">
45+
<template #icon>
46+
<NcIconSvgWrapper v-if="template.iconSvgInline" :svg="template.iconSvgInline" />
47+
<Plus v-else />
48+
</template>
49+
{{ template.actionLabel }}
50+
</NcActionButton>
51+
</template>
3752
</NcActions>
3853
</template>
3954

4055
<script>
41-
import { NcActions, NcActionButton } from '@nextcloud/vue'
42-
import { Loading, Folder, Upload } from '../icons.js'
56+
import { NcActions, NcActionSeparator, NcActionButton, NcIconSvgWrapper } from '@nextcloud/vue'
57+
import { loadState } from '@nextcloud/initial-state'
58+
import { Loading, Folder, Upload, Plus } from '../icons.js'
4359
import { useIsPublicMixin, useEditorUpload } from '../Editor.provider.js'
4460
import { BaseActionEntry } from './BaseActionEntry.js'
4561
import { useMenuIDMixin } from './MenuBar.provider.js'
4662
import {
4763
useActionAttachmentPromptMixin,
4864
useUploadingStateMixin,
4965
useActionChooseLocalAttachmentMixin,
66+
useActionCreateAttachmentMixin,
5067
} from '../Editor/MediaHandler.provider.js'
5168
5269
export default {
5370
name: 'ActionAttachmentUpload',
5471
components: {
5572
NcActions,
73+
NcActionSeparator,
5674
NcActionButton,
75+
NcIconSvgWrapper,
5776
Loading,
5877
Folder,
5978
Upload,
79+
Plus,
6080
},
6181
extends: BaseActionEntry,
6282
mixins: [
@@ -65,6 +85,7 @@ export default {
6585
useActionAttachmentPromptMixin,
6686
useUploadingStateMixin,
6787
useActionChooseLocalAttachmentMixin,
88+
useActionCreateAttachmentMixin,
6889
useMenuIDMixin,
6990
],
7091
computed: {
@@ -76,6 +97,14 @@ export default {
7697
isUploadingAttachments() {
7798
return this.$uploadingState.isUploadingAttachments
7899
},
100+
templates() {
101+
return loadState('files', 'templates', [])
102+
},
103+
},
104+
methods: {
105+
createAttachment(template) {
106+
this.$callCreateAttachment(template)
107+
},
79108
},
80109
}
81110
</script>

src/components/icons.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import MDI_Upload from 'vue-material-design-icons/Upload.vue'
6464
import MDI_Warn from 'vue-material-design-icons/Alert.vue'
6565
import MDI_Web from 'vue-material-design-icons/Web.vue'
6666
import MDI_TranslateVariant from 'vue-material-design-icons/TranslateVariant.vue'
67+
import MDI_Plus from 'vue-material-design-icons/Plus.vue'
6768

6869
const DEFAULT_ICON_SIZE = 20
6970

@@ -148,3 +149,4 @@ export const UnfoldMoreHorizontal = makeIcon(MDI_UnfoldMoreHorizontal)
148149
export const Upload = makeIcon(MDI_Upload)
149150
export const Warn = makeIcon(MDI_Warn)
150151
export const Web = makeIcon(MDI_Web)
152+
export const Plus = makeIcon(MDI_Plus)

src/services/SessionApi.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,15 @@ export class Connection {
176176
})
177177
}
178178

179+
createAttachment(template) {
180+
return this.#post(_endpointUrl('attachment/create'), {
181+
documentId: this.#document.id,
182+
sessionId: this.#session.id,
183+
sessionToken: this.#session.token,
184+
fileName: `${template.app}${template.extension}`,
185+
})
186+
}
187+
179188
insertAttachmentFile(filePath) {
180189
return this.#post(_endpointUrl('attachment/filepath'), {
181190
documentId: this.#document.id,

src/services/SyncService.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,10 @@ class SyncService {
375375
return this.#connection.insertAttachmentFile(filePath)
376376
}
377377

378+
createAttachment(template) {
379+
return this.#connection.createAttachment(template)
380+
}
381+
378382
on(event, callback) {
379383
this._bus.on(event, callback)
380384
return this

0 commit comments

Comments
 (0)