From 57a82e5d3aeb6806a7ac3de01de8a658d87b7dda Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Thu, 24 Sep 2026 14:05:03 +0200 Subject: [PATCH 1/2] feat(files): discover contextual templates in destination folders Resolve .Templates from the destination up to its mount root, respect read permissions, and refresh the picker context for each creation. Assisted-by: Codex:gpt-6-astra --- .../lib/Controller/TemplateController.php | 11 ++- apps/files/openapi.json | 20 +++++ apps/files/src/components/TemplatePreview.vue | 11 ++- apps/files/src/newMenu/newFromTemplate.ts | 16 ++-- apps/files/src/services/Templates.js | 12 +-- apps/files/src/views/TemplatePicker.spec.ts | 69 ++++++++++++++++ apps/files/src/views/TemplatePicker.vue | 40 +++++---- .../Controller/TemplateControllerTest.php | 11 +++ lib/composer/composer/autoload_classmap.php | 1 + lib/composer/composer/autoload_static.php | 1 + .../Template/TemplateDirectoryResolver.php | 47 +++++++++++ .../Files/Template/TemplateManager.php | 66 ++++++++------- .../Files/Template/ITemplateManager.php | 7 +- openapi.json | 20 +++++ .../TemplateDirectoryResolverTest.php | 81 +++++++++++++++++++ .../Files/Template/TemplateManagerTest.php | 49 +++++++++++ 16 files changed, 388 insertions(+), 74 deletions(-) create mode 100644 apps/files/src/views/TemplatePicker.spec.ts create mode 100644 lib/private/Files/Template/TemplateDirectoryResolver.php create mode 100644 tests/lib/Files/Template/TemplateDirectoryResolverTest.php diff --git a/apps/files/lib/Controller/TemplateController.php b/apps/files/lib/Controller/TemplateController.php index 70c98e43e808f..d9e0c79f9d5ae 100644 --- a/apps/files/lib/Controller/TemplateController.php +++ b/apps/files/lib/Controller/TemplateController.php @@ -106,30 +106,33 @@ public function setPath(string $templatePath): DataResponse { /** * List the available templates * + * @param ?string $targetPath Destination folder relative to the user root + * * @return DataResponse, array{}> * * 200: Available templates returned */ #[NoAdminRequired] - public function list(): DataResponse { + public function list(?string $targetPath = null): DataResponse { /* Convert embedded Template instances to arrays to match return type */ return new DataResponse(array_map(static function (array $templateFileCreator) { $templateFileCreator['templates'] = array_map(static fn (Template $template) => $template->jsonSerialize(), $templateFileCreator['templates']); return $templateFileCreator; - }, $this->templateManager->listTemplates())); + }, $this->templateManager->listTemplates($targetPath))); } /** * List the fields for the template specified by the given file ID * * @param int $fileId File ID of the template + * @param ?string $targetPath Destination folder relative to the user root * @return DataResponse, array{}> * * 200: Fields returned */ #[NoAdminRequired] - public function listTemplateFields(int $fileId): DataResponse { - $fields = $this->templateManager->listTemplateFields($fileId); + public function listTemplateFields(int $fileId, ?string $targetPath = null): DataResponse { + $fields = $this->templateManager->listTemplateFields($fileId, $targetPath); return new DataResponse( array_merge([], ...$fields), diff --git a/apps/files/openapi.json b/apps/files/openapi.json index 3249dc36d20da..30808d1e9887b 100644 --- a/apps/files/openapi.json +++ b/apps/files/openapi.json @@ -1337,6 +1337,16 @@ } ], "parameters": [ + { + "name": "targetPath", + "in": "query", + "description": "Destination folder relative to the user root", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -1439,6 +1449,16 @@ "format": "int64" } }, + { + "name": "targetPath", + "in": "query", + "description": "Destination folder relative to the user root", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, { "name": "OCS-APIRequest", "in": "header", diff --git a/apps/files/src/components/TemplatePreview.vue b/apps/files/src/components/TemplatePreview.vue index c3af69053088d..e42279b55f78c 100644 --- a/apps/files/src/components/TemplatePreview.vue +++ b/apps/files/src/components/TemplatePreview.vue @@ -56,6 +56,11 @@ export default { default: false, }, + selectionKey: { + type: String, + default: null, + }, + fileid: { type: [String, Number], required: true, @@ -104,7 +109,7 @@ export default { }, id() { - return `template-picker-${this.fileid}` + return `template-picker-${encodeURIComponent(this.selectionKey ?? this.fileid)}` }, realPreviewUrl() { @@ -130,7 +135,7 @@ export default { methods: { onCheck() { - this.$emit('check', this.fileid) + this.$emit('check', this.selectionKey ?? this.fileid) }, onFailure() { @@ -143,7 +148,7 @@ export default { onClick() { if (this.checked) { - this.$emit('confirm-click', this.fileid) + this.$emit('confirm-click', this.selectionKey ?? this.fileid) } }, }, diff --git a/apps/files/src/newMenu/newFromTemplate.ts b/apps/files/src/newMenu/newFromTemplate.ts index 761319897ed30..ddcb6844ea7c0 100644 --- a/apps/files/src/newMenu/newFromTemplate.ts +++ b/apps/files/src/newMenu/newFromTemplate.ts @@ -16,13 +16,10 @@ import { newNodeName } from '../utils/newNodeDialog.ts' // async to reduce bundle size const TemplatePickerVue = defineAsyncComponent(() => import('../views/TemplatePicker.vue')) -let TemplatePicker: ComponentInstance & { open: (n: string, t: TemplateFile) => void } | null = null +let TemplatePicker: ComponentInstance & { open: (n: string, t: TemplateFile, parent: IFolder) => void } | null = null -/** - * - * @param context - */ -async function getTemplatePicker(context: IFolder) { +/** Get the shared picker instance. */ +async function getTemplatePicker() { if (TemplatePicker === null) { // Create document root const mountingPoint = document.createElement('div') @@ -35,9 +32,6 @@ async function getTemplatePicker(context: IFolder) { TemplatePickerVue, { ref: 'picker', - props: { - parent: context, - }, }, ), methods: { open(...args) { this.$refs.picker.open(...args) } }, @@ -75,7 +69,7 @@ export function registerTemplateEntries() { }, order: 11, async handler(context: IFolder, content: INode[]) { - const templatePicker = getTemplatePicker(context) + const templatePicker = getTemplatePicker() const name = await newNodeName(`${provider.label}${provider.extension}`, content, { label: t('files', 'Filename'), name: provider.label, @@ -84,7 +78,7 @@ export function registerTemplateEntries() { if (name !== null) { // Create the file const picker = await templatePicker - picker.open(name.trim(), provider) + picker.open(name.trim(), provider, context) } }, } satisfies NewMenuEntry) diff --git a/apps/files/src/services/Templates.js b/apps/files/src/services/Templates.js index 2dcfa83cfb15c..99c7efb3b452b 100644 --- a/apps/files/src/services/Templates.js +++ b/apps/files/src/services/Templates.js @@ -8,18 +8,20 @@ import { generateOcsUrl } from '@nextcloud/router' /** * + * @param {string} targetPath Destination folder relative to the user root */ -export async function getTemplates() { - const response = await axios.get(generateOcsUrl('apps/files/api/v1/templates')) +export async function getTemplates(targetPath) { + const response = await axios.get(generateOcsUrl('apps/files/api/v1/templates'), { params: { targetPath } }) return response.data.ocs.data } /** * - * @param fileId + * @param {number} fileId Template file ID + * @param {string} targetPath Destination folder relative to the user root */ -export async function getTemplateFields(fileId) { - const response = await axios.get(generateOcsUrl(`apps/files/api/v1/templates/fields/${fileId}`)) +export async function getTemplateFields(fileId, targetPath) { + const response = await axios.get(generateOcsUrl(`apps/files/api/v1/templates/fields/${fileId}`), { params: { targetPath } }) return response.data.ocs.data } diff --git a/apps/files/src/views/TemplatePicker.spec.ts b/apps/files/src/views/TemplatePicker.spec.ts new file mode 100644 index 0000000000000..ff597794f8c95 --- /dev/null +++ b/apps/files/src/views/TemplatePicker.spec.ts @@ -0,0 +1,69 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getCurrentUser } from '@nextcloud/auth' +import { enableAutoDestroy, shallowMount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import TemplatePreview from '../components/TemplatePreview.vue' +import TemplatePicker from './TemplatePicker.vue' +import { createFromTemplate, getTemplateFields, getTemplates } from '../services/Templates.js' + +vi.mock('@nextcloud/auth', () => ({ getCurrentUser: vi.fn(() => null) })) +vi.mock('../services/Templates.js', () => ({ getTemplates: vi.fn(), getTemplateFields: vi.fn(), createFromTemplate: vi.fn() })) +enableAutoDestroy(afterEach) + +const templates = [ + { fileid: 12, templateType: 'user', templateId: '/.Templates/Letter.md', filename: '/.Templates/Letter.md', basename: 'Letter.md', mime: 'text/markdown' }, + { fileid: 12, templateType: 'organization', templateId: '12', filename: '12', basename: 'Letter.md', mime: 'text/markdown' }, +] +const provider = { app: 'text', label: 'Text', extension: '.md', templates, mimetypes: ['text/markdown'] } + +describe('Template destination context', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getCurrentUser).mockReturnValue({ uid: 'alice' } as ReturnType) + vi.mocked(getTemplates).mockResolvedValue([provider]) + vi.mocked(getTemplateFields).mockResolvedValue([]) + }) + + it('refreshes the destination each time the singleton opens', async () => { + const wrapper = shallowMount(TemplatePicker, { stubs: { TemplatePreview: { render: (h) => h('div'), methods: { focus: vi.fn() } } } }) + await wrapper.vm.open('First.md', provider, { path: '/Team' }) + wrapper.vm.close() + await wrapper.vm.open('Second.md', provider, { path: '/Elsewhere' }) + expect(getTemplates).toHaveBeenNthCalledWith(1, '/Team') + expect(getTemplates).toHaveBeenNthCalledWith(2, '/Elsewhere') + expect(wrapper.vm.parent.path).toBe('/Elsewhere') + }) + + it('distinguishes the same file offered by different providers', async () => { + const wrapper = shallowMount(TemplatePicker, { stubs: { TemplatePreview: { render: (h) => h('div'), methods: { focus: vi.fn() } } } }) + await wrapper.vm.open('Letter.md', provider, { path: '/Team' }) + wrapper.vm.onCheck(wrapper.vm.templateKey(templates[1])) + expect(wrapper.vm.selectedTemplate.templateType).toBe('organization') + wrapper.vm.onCheck(wrapper.vm.templateKey(templates[0])) + expect(wrapper.vm.selectedTemplate.templateType).toBe('user') + }) + + it('uses the destination for fields and creation even if the URL differs', async () => { + const wrapper = shallowMount(TemplatePicker, { stubs: { TemplatePreview: { render: (h) => h('div'), methods: { focus: vi.fn() } } } }) + await wrapper.vm.open('Letter.md', provider, { path: '/Team' }) + wrapper.vm.onCheck(wrapper.vm.templateKey(templates[0])) + vi.mocked(createFromTemplate).mockResolvedValue({ fileid: 99, filename: '/Team/Letter.md', mime: 'text/markdown', lastmod: 1, size: 1, permissions: 31 }) + vi.spyOn(wrapper.vm, 'handleFileCreation').mockImplementation(() => {}) + await wrapper.vm.onSubmit() + expect(getTemplateFields).toHaveBeenCalledWith(12, '/Team') + expect(createFromTemplate).toHaveBeenCalledWith('/Team/Letter.md', '/.Templates/Letter.md', 'user', []) + }) +}) + +it('emits the provider-specific selection key from preview cards', async () => { + const wrapper = shallowMount(TemplatePreview, { propsData: { ...templates[0], selectionKey: 'organization:12', checked: true } }) + await wrapper.find('input').trigger('change') + await wrapper.find('label').trigger('click') + expect(wrapper.find('input').attributes('id')).toBe('template-picker-organization%3A12') + expect(wrapper.emitted('check')).toEqual([['organization:12']]) + expect(wrapper.emitted('confirm-click')).toEqual([['organization:12']]) +}) diff --git a/apps/files/src/views/TemplatePicker.vue b/apps/files/src/views/TemplatePicker.vue index 6c6964653b82b..8deddf64eb404 100644 --- a/apps/files/src/views/TemplatePicker.vue +++ b/apps/files/src/views/TemplatePicker.vue @@ -27,9 +27,10 @@ @@ -85,20 +86,11 @@ export default defineComponent({ TemplatePreview, }, - props: { - /** - * The parent folder where to create the node - */ - parent: { - type: Object, - default: () => null, - }, - }, - data() { return { + parent: null as Node | null, // Check empty template by default - checked: -1, + checked: -1 as string | number, loading: false, name: null as string | null, opened: false, @@ -133,7 +125,7 @@ export default defineComponent({ return null } - return this.provider.templates!.find((template) => template.fileid === this.checked) + return this.provider.templates!.find((template) => this.templateKey(template) === this.checked) }, /** @@ -164,13 +156,19 @@ export default defineComponent({ methods: { t, + templateKey(template) { + return JSON.stringify([template.templateType, template.templateId]) + }, + /** * Open the picker * * @param name the file name to create * @param provider the template provider picked + * @param parent the destination folder for this opening */ - async open(name: string, provider) { + async open(name: string, provider, parent: Node) { + this.parent = parent this.checked = this.emptyTemplate.fileid this.name = name this.provider = provider @@ -181,9 +179,9 @@ export default defineComponent({ return } - const templates = await getTemplates() + const templates = await getTemplates(this.parent.path) const fetchedProvider = templates.find((fetchedProvider) => fetchedProvider.app === provider.app && fetchedProvider.label === provider.label) - if (fetchedProvider === null) { + if (fetchedProvider === undefined) { throw new Error('Failed to match provider in results') } this.provider = fetchedProvider @@ -219,18 +217,18 @@ export default defineComponent({ * * @param fileid the selected template file id */ - onCheck(fileid: number) { + onCheck(fileid: string | number) { this.checked = fileid }, - onConfirmClick(fileid: number) { + onConfirmClick(fileid: string | number) { if (fileid === this.checked) { this.onSubmit() } }, async createFile(templateFields = []) { - const currentDirectory = new URL(window.location.href).searchParams.get('dir') || '/' + const currentDirectory = this.parent?.path || '/' // If the file doesn't have an extension, add the default one if (this.nameWithoutExt === this.name) { @@ -318,7 +316,7 @@ export default defineComponent({ // selected and it's not the blank template let fields = [] if (fileId && fileId !== this.emptyTemplate.fileid) { - fields = await getTemplateFields(fileId) + fields = await getTemplateFields(fileId, this.parent?.path) } if (fields.length > 0) { diff --git a/apps/files/tests/Controller/TemplateControllerTest.php b/apps/files/tests/Controller/TemplateControllerTest.php index ce12935ffc5a7..93aa38a4211d4 100644 --- a/apps/files/tests/Controller/TemplateControllerTest.php +++ b/apps/files/tests/Controller/TemplateControllerTest.php @@ -119,4 +119,15 @@ public function testClearSelection(): void { $this->manager->expects(self::once())->method('setTemplatePath')->with(''); self::assertSame(['template_path' => '', 'available' => false], $this->controller->setPath('')->getData()); } + + public function testPassesDestinationToTemplateListing(): void { + $this->manager->expects(self::once())->method('listTemplates')->with('/Team/Reports')->willReturn([]); + self::assertSame([], $this->controller->list('/Team/Reports')->getData()); + } + + public function testPassesDestinationToTemplateFields(): void { + $this->manager->expects(self::once())->method('listTemplateFields')->with(12, '/Team/Reports')->willReturn([]); + self::assertSame([], $this->controller->listTemplateFields(12, '/Team/Reports')->getData()); + } + } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 255465c0ffb2e..4960421dacd3c 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -2000,6 +2000,7 @@ 'OC\\Files\\Stream\\HashWrapper' => $baseDir . '/lib/private/Files/Stream/HashWrapper.php', 'OC\\Files\\Stream\\Quota' => $baseDir . '/lib/private/Files/Stream/Quota.php', 'OC\\Files\\Stream\\SeekableHttpStream' => $baseDir . '/lib/private/Files/Stream/SeekableHttpStream.php', + 'OC\\Files\\Template\\TemplateDirectoryResolver' => $baseDir . '/lib/private/Files/Template/TemplateDirectoryResolver.php', 'OC\\Files\\Template\\TemplateManager' => $baseDir . '/lib/private/Files/Template/TemplateManager.php', 'OC\\Files\\Type\\Detection' => $baseDir . '/lib/private/Files/Type/Detection.php', 'OC\\Files\\Type\\Loader' => $baseDir . '/lib/private/Files/Type/Loader.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 046d2cd20b693..4ef88bb607516 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -2041,6 +2041,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Files\\Stream\\HashWrapper' => __DIR__ . '/../../..' . '/lib/private/Files/Stream/HashWrapper.php', 'OC\\Files\\Stream\\Quota' => __DIR__ . '/../../..' . '/lib/private/Files/Stream/Quota.php', 'OC\\Files\\Stream\\SeekableHttpStream' => __DIR__ . '/../../..' . '/lib/private/Files/Stream/SeekableHttpStream.php', + 'OC\\Files\\Template\\TemplateDirectoryResolver' => __DIR__ . '/../../..' . '/lib/private/Files/Template/TemplateDirectoryResolver.php', 'OC\\Files\\Template\\TemplateManager' => __DIR__ . '/../../..' . '/lib/private/Files/Template/TemplateManager.php', 'OC\\Files\\Type\\Detection' => __DIR__ . '/../../..' . '/lib/private/Files/Type/Detection.php', 'OC\\Files\\Type\\Loader' => __DIR__ . '/../../..' . '/lib/private/Files/Type/Loader.php', diff --git a/lib/private/Files/Template/TemplateDirectoryResolver.php b/lib/private/Files/Template/TemplateDirectoryResolver.php new file mode 100644 index 0000000000000..6227df4896662 --- /dev/null +++ b/lib/private/Files/Template/TemplateDirectoryResolver.php @@ -0,0 +1,47 @@ + */ + public function resolve(Folder $userFolder, string $targetPath): array { + $folders = []; + try { + $folder = $userFolder->get($targetPath); + if (!$folder instanceof Folder || !$folder->isReadable()) { + return []; + } + $mount = $folder->getMountPoint()->getMountPoint(); + while ($folder->isReadable() + && $userFolder->getRelativePath($folder->getPath()) !== null + && $folder->getMountPoint()->getMountPoint() === $mount) { + try { + $templates = $folder->get('.Templates'); + if ($templates instanceof Folder && $templates->isReadable()) { + $folders[] = $templates; + } + } catch (NotFoundException|NotPermittedException $e) { + } + if ($folder->getPath() === $userFolder->getPath()) { + break; + } + $folder = $folder->getParent(); + } + } catch (NotFoundException|NotPermittedException|InvalidPathException $e) { + } + return $folders; + } +} diff --git a/lib/private/Files/Template/TemplateManager.php b/lib/private/Files/Template/TemplateManager.php index 7453daf029c78..6f8bd63331927 100644 --- a/lib/private/Files/Template/TemplateManager.php +++ b/lib/private/Files/Template/TemplateManager.php @@ -119,18 +119,18 @@ public function listCreators(): array { } #[Override] - public function listTemplates(): array { - return array_values(array_map(function (TemplateFileCreator $entry) { + public function listTemplates(?string $targetPath = null): array { + return array_values(array_map(function (TemplateFileCreator $entry) use ($targetPath) { return array_merge($entry->jsonSerialize(), [ - 'templates' => $this->getTemplateFiles($entry) + 'templates' => $this->getTemplateFiles($entry, $targetPath) ]); }, $this->listCreators())); } #[Override] - public function listTemplateFields(int $fileId): array { + public function listTemplateFields(int $fileId, ?string $targetPath = null): array { foreach ($this->listCreators() as $creator) { - $fields = $this->getTemplateFields($creator, $fileId); + $fields = $this->getTemplateFields($creator, $fileId, $targetPath); if (empty($fields)) { continue; } @@ -168,6 +168,10 @@ public function createFromTemplate(string $filePath, string $templateId = '', st } } + if ($template !== null && (!$template instanceof File || !$template->isReadable())) { + throw new NotPermittedException('Template is not readable'); + } + $filename = basename($filePath); $this->filenameValidator->validateFilename($filename); $targetFile = $folder->newFile($filename, ($template instanceof File ? $template->fopen('rb') : null)); @@ -200,10 +204,10 @@ private function getTemplateFolder(): Folder { /** * @return list