From f04ac7fc2122cc1d434fe25bc348d7ae138f7e04 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 6 Aug 2026 16:29:54 +0200 Subject: [PATCH 1/2] feat(notes): offer templates when creating a note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "New note" always produced an empty note. Anyone who writes the same shape of note repeatedly — meeting minutes, a journal entry, a checklist — had to copy an old one and clear it out. Clicking "New note" now opens a picker of the user's templates, the same way the Files app does, with "Blank note" as the first card. The templates come from the Files app's own OCS endpoint, so the source is the user's configured Templates folder and nothing new has to be managed: whatever appears in the Files "New" menu appears here. That endpoint answers with one entry per registered template creator, each carrying the templates matching its mimetypes, so the text/markdown and text/plain ones are kept and flattened; the Office formats are dropped, since a .docx would be meaningless as a note. Deliberately no server-side changes. NotesController::create() already accepts `content`, so the template's text is read over WebDAV — its path is exactly what the endpoint reports as templateId — and handed to the existing create call. That keeps the note naming, the category folder handling and the file suffix logic in one place instead of duplicating them behind a second creation path. The picker is skipped entirely when the user has no templates: on an instance without a Templates folder it would be a dialog with a single "Blank note" card, which is just an extra click before every note. A template that cannot be read falls back to an empty note with a warning rather than failing the creation. Verified against the server rather than assumed: the endpoint is an OCS route, Request::passesCSRFCheck() treats OCS-APIRequest as an alternative to the request token that @nextcloud/axios already sends, and Request::getFormat() falls back to the Accept header, so the reply is JSON. Co-Authored-By: Claude Opus 5 (1M context) --- src/TemplateService.js | 78 +++++++++++ src/components/NotesView.vue | 62 ++++++++- src/components/TemplatePicker.vue | 211 ++++++++++++++++++++++++++++++ 3 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 src/TemplateService.js create mode 100644 src/components/TemplatePicker.vue diff --git a/src/TemplateService.js b/src/TemplateService.js new file mode 100644 index 000000000..81a27acaf --- /dev/null +++ b/src/TemplateService.js @@ -0,0 +1,78 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { getClient, getRootPath } from '@nextcloud/files/dav' +import { generateOcsUrl } from '@nextcloud/router' +import logger from './Logger.js' + +/** + * Templates a note can be made from. Anything else the server offers — the + * Office formats in particular — is a binary file that would be meaningless as + * a note. + */ +const NOTE_MIMETYPES = ['text/markdown', 'text/plain'] + +/** + * Templates the user can start a note from. + * + * These come from the Files app's template endpoint, which is the same source + * the Files "New" menu reads, so whatever lives in the user's configured + * Templates folder shows up here too. The endpoint answers with one entry per + * registered template *creator* (Text, Office, …), each carrying the templates + * that match its mimetypes; we keep the text ones and flatten them. + * + * Never throws: templates are a convenience, and failing to list them must not + * stop somebody from creating a note. + * + * @return {Promise>} templates, or an empty list + */ +export async function fetchNoteTemplates() { + try { + const response = await axios.get(generateOcsUrl('apps/files/api/v1/templates')) + const creators = response.data?.ocs?.data ?? [] + + return creators + .filter((creator) => (creator.mimetypes ?? []).some((mime) => NOTE_MIMETYPES.includes(mime))) + .flatMap((creator) => (creator.templates ?? []).map((template) => ({ + ...template, + // the creator's icon is the sensible stand-in when a template has + // no preview of its own + iconSvgInline: creator.iconSvgInline, + }))) + } catch (error) { + logger.warn('Listing note templates has failed', { error }) + return [] + } +} + +/** + * Read a template's content so it can be used as the body of a new note. + * + * `templateId` is the template's path relative to the user's files root, which + * is what the endpoint above reports, so it can be fetched over WebDAV + * directly rather than through a Notes endpoint. + * + * @param {string} templateId path of the template, relative to the user root + * @return {Promise} the template's text + */ +export async function fetchTemplateContent(templateId) { + const path = '/' + String(templateId).replace(/^\/+/, '') + const content = await getClient().getFileContents(`${getRootPath()}${path}`, { format: 'text' }) + + return typeof content === 'string' ? content : '' +} + +/** + * Strip the extension so "Meeting notes.md" reads as "Meeting notes". + * + * @param {object} template a template as returned by fetchNoteTemplates() + * @return {string} label to show in the picker + */ +export function templateLabel(template) { + const basename = template.basename ?? '' + + return basename.replace(/\.[^.]+$/, '') || basename +} diff --git a/src/components/NotesView.vue b/src/components/NotesView.vue index 1a1792098..d81414262 100644 --- a/src/components/NotesView.vue +++ b/src/components/NotesView.vue @@ -66,11 +66,19 @@ + + + + From 455bb6c112b8163f37ffc3a36e10fec1a5346c3a Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 6 Aug 2026 18:52:57 +0200 Subject: [PATCH 2/2] fix(notes): build template thumbnail URLs instead of trusting previewUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker fell back to an icon for every template, so no thumbnails ever appeared. The template endpoint reports both `hasPreview` and `previewUrl`, and I used the latter. But TemplateManager::getUserTemplates() only ever calls setHasPreview() — setCustomPreviewUrl() is reserved for templates an app registers — so for the user's own templates `previewUrl` is null by construction. The card saw no URL and rendered the creator icon. The URL is now built from the file id against core's preview endpoint, the same way the Files app does it: /core/preview?fileId=…&x=256&y=256&a=1&mimeFallback=1 `a=1` keeps the aspect ratio rather than cropping the top off the note, and `mimeFallback=1` returns a mimetype icon rather than a 404 for anything the preview backend cannot render. A URL that still fails to load is remembered per template so the card falls back to the creator icon. An app-provided `previewUrl` is still preferred when one is present. Thumbnails do exist for these files: MarkDown and TXT are both in PreviewManager's default provider list, which is what hasPreview reflects. Verified that generateUrl() substitutes placeholders inside the query string and not just the path — _generateUrlPath() runs the {param} replacement over the whole string with encodeURIComponent. Co-Authored-By: Claude Opus 5 (1M context) --- src/TemplateService.js | 36 ++++++++++++++++++++++++++++++- src/components/TemplatePicker.vue | 8 ++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/TemplateService.js b/src/TemplateService.js index 81a27acaf..2ecb6b00c 100644 --- a/src/TemplateService.js +++ b/src/TemplateService.js @@ -5,7 +5,7 @@ import axios from '@nextcloud/axios' import { getClient, getRootPath } from '@nextcloud/files/dav' -import { generateOcsUrl } from '@nextcloud/router' +import { generateOcsUrl, generateUrl } from '@nextcloud/router' import logger from './Logger.js' /** @@ -15,6 +15,39 @@ import logger from './Logger.js' */ const NOTE_MIMETYPES = ['text/markdown', 'text/plain'] +/** Rendered size of a template card's thumbnail, in CSS pixels. */ +const PREVIEW_SIZE = 256 + +/** + * URL of a template's thumbnail, or null when there is none to show. + * + * The template endpoint reports `hasPreview` but leaves `previewUrl` at null + * for the user's own templates — setCustomPreviewUrl() is only ever called for + * templates an app registers — so the URL has to be built from the file id + * against core's preview endpoint, which is what the Files app does too. + * + * @param {object} template a template as reported by the endpoint + * @return {string|null} preview URL, or null + */ +function previewUrl(template) { + if (template.previewUrl) { + return template.previewUrl + } + if (!template.hasPreview || !template.fileid) { + return null + } + + return generateUrl('/core/preview?fileId={fileId}&x={x}&y={y}&a={a}&mimeFallback={mimeFallback}', { + fileId: template.fileid, + x: PREVIEW_SIZE, + y: PREVIEW_SIZE, + // keep the aspect ratio rather than cropping the top of the note away, + // and fall back to a mimetype icon instead of a broken image + a: 1, + mimeFallback: 1, + }) +} + /** * Templates the user can start a note from. * @@ -38,6 +71,7 @@ export async function fetchNoteTemplates() { .filter((creator) => (creator.mimetypes ?? []).some((mime) => NOTE_MIMETYPES.includes(mime))) .flatMap((creator) => (creator.templates ?? []).map((template) => ({ ...template, + previewUrl: previewUrl(template), // the creator's icon is the sensible stand-in when a template has // no preview of its own iconSvgInline: creator.iconSvgInline, diff --git a/src/components/TemplatePicker.vue b/src/components/TemplatePicker.vue index 8b4c20069..7bb69b7ed 100644 --- a/src/components/TemplatePicker.vue +++ b/src/components/TemplatePicker.vue @@ -113,9 +113,11 @@ export default { key: String(template.fileid ?? template.templateId), label: templateLabel(template), template, - previewUrl: template.hasPreview && !this.brokenPreviews.includes(template.fileid) - ? template.previewUrl - : null, + // the service resolves this to a usable URL or null; a URL that + // still fails to load is remembered so the card falls back + previewUrl: this.brokenPreviews.includes(template.fileid) + ? null + : template.previewUrl, iconSvgInline: template.iconSvgInline, })), ]