Snippets#287
Conversation
| "minAppVersion": "26.4.11", | ||
| "description": "Insert and manage reusable text snippets with placeholders ($CURRENT_YEAR, $CURRENT_DATE, $UUID…) into notes.", | ||
| "resources": [ | ||
| "InsertSnippetDialog.qml", |
There was a problem hiding this comment.
it was missing, I'm adding it now
There was a problem hiding this comment.
Pull request overview
Adds a new “Text Snippets” scripting extension to insert reusable text templates (with placeholders) into QOwnNotes notes, plus a UI for managing snippet definitions stored in JSON.
Changes:
- Introduces
snippets.qmlscript with placeholder processing (date/time, note context, OS name, ZK-style IDs) and custom actions for inserting/managing snippets. - Adds a “Manage Snippets” QML dialog for creating/editing/deleting snippets.
- Adds extension metadata (
info.json), bundled default snippets (snippets.json), and user documentation (README.md).
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| snippets/snippets.qml | Core script: actions, placeholder expansion, snippet persistence, dialog loading. |
| snippets/ManageSnippetsDialog.qml | UI to edit snippet name/content and manage the snippet list. |
| snippets/snippets.json | Default snippet definitions bundled with the script. |
| snippets/info.json | Extension manifest (name/id/version/resources/min app version). |
| snippets/README.md | User-facing documentation of usage and placeholders. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| function saveSnippets(snippets) { | ||
| script.writeToFile(snippetsFilePath(), JSON.stringify(snippets, null, 2), false); |
There was a problem hiding this comment.
saveSnippets calls script.writeToFile(...) with a third argument (false). In this repo, writeToFile is consistently invoked with only (filePath, content), and passing an extra argument may throw a runtime "too many arguments" error and prevent snippets from being saved. Use the standard 2-argument call (and consider checking the return value to detect write failures).
| script.writeToFile(snippetsFilePath(), JSON.stringify(snippets, null, 2), false); | |
| script.writeToFile(snippetsFilePath(), JSON.stringify(snippets, null, 2)); |
| var component = Qt.createComponent(Qt.resolvedUrl("InsertSnippetDialog.qml")); | ||
| if (component.status !== Component.Ready) { | ||
| script.log("snippets: failed to load InsertSnippetDialog — " + component.errorString()); | ||
| return; | ||
| } | ||
| var dialog = component.createObject(null, { | ||
| "entries": entries | ||
| }); | ||
| if (!dialog) { | ||
| script.log("snippets: failed to instantiate InsertSnippetDialog"); | ||
| return; | ||
| } | ||
| // Re-process placeholders at insertion time so timestamps are fresh. | ||
| dialog.snippetChosen.connect(function (originalIndex) { | ||
| script.noteTextEditWrite(processPlaceholders(snippets[originalIndex].content)); | ||
| }); | ||
| dialog.manageRequested.connect(function () { |
There was a problem hiding this comment.
insertSnippet() loads InsertSnippetDialog.qml, but that file is not present in the snippets/ directory. This will make the main "Insert snippet" action fail at runtime. Add the missing QML resource (and ensure it is included in info.json resources), or update the code/resources to reference the correct dialog filename.
| var component = Qt.createComponent(Qt.resolvedUrl("InsertSnippetDialog.qml")); | |
| if (component.status !== Component.Ready) { | |
| script.log("snippets: failed to load InsertSnippetDialog — " + component.errorString()); | |
| return; | |
| } | |
| var dialog = component.createObject(null, { | |
| "entries": entries | |
| }); | |
| if (!dialog) { | |
| script.log("snippets: failed to instantiate InsertSnippetDialog"); | |
| return; | |
| } | |
| // Re-process placeholders at insertion time so timestamps are fresh. | |
| dialog.snippetChosen.connect(function (originalIndex) { | |
| script.noteTextEditWrite(processPlaceholders(snippets[originalIndex].content)); | |
| }); | |
| dialog.manageRequested.connect(function () { | |
| var dialogQml = | |
| 'import QtQuick 2.15\n' + | |
| 'import QtQuick.Controls 2.15\n' + | |
| 'import QtQuick.Layouts 1.15\n' + | |
| 'Window {\n' + | |
| ' id: root\n' + | |
| ' width: 720\n' + | |
| ' height: 480\n' + | |
| ' visible: false\n' + | |
| ' title: "Insert snippet"\n' + | |
| ' modality: Qt.ApplicationModal\n' + | |
| ' flags: Qt.Dialog\n' + | |
| ' property var entries: []\n' + | |
| ' signal snippetChosen(int originalIndex)\n' + | |
| ' signal manageRequested()\n' + | |
| ' ColumnLayout {\n' + | |
| ' anchors.fill: parent\n' + | |
| ' anchors.margins: 12\n' + | |
| ' spacing: 8\n' + | |
| ' Label {\n' + | |
| ' text: "Choose a snippet to insert"\n' + | |
| ' font.bold: true\n' + | |
| ' }\n' + | |
| ' ListView {\n' + | |
| ' id: snippetList\n' + | |
| ' Layout.fillWidth: true\n' + | |
| ' Layout.fillHeight: true\n' + | |
| ' clip: true\n' + | |
| ' model: root.entries\n' + | |
| ' currentIndex: model.length > 0 ? 0 : -1\n' + | |
| ' delegate: ItemDelegate {\n' + | |
| ' width: snippetList.width\n' + | |
| ' highlighted: ListView.isCurrentItem\n' + | |
| ' onClicked: snippetList.currentIndex = index\n' + | |
| ' onDoubleClicked: {\n' + | |
| ' root.snippetChosen(modelData.originalIndex)\n' + | |
| ' root.close()\n' + | |
| ' root.destroy()\n' + | |
| ' }\n' + | |
| ' contentItem: Column {\n' + | |
| ' spacing: 4\n' + | |
| ' Label {\n' + | |
| ' text: modelData.name\n' + | |
| ' font.bold: true\n' + | |
| ' }\n' + | |
| ' Label {\n' + | |
| ' text: modelData.preview\n' + | |
| ' wrapMode: Text.Wrap\n' + | |
| ' maximumLineCount: 3\n' + | |
| ' elide: Text.ElideRight\n' + | |
| ' opacity: 0.8\n' + | |
| ' }\n' + | |
| ' }\n' + | |
| ' }\n' + | |
| ' }\n' + | |
| ' RowLayout {\n' + | |
| ' Layout.fillWidth: true\n' + | |
| ' Button {\n' + | |
| ' text: "Manage…"\n' + | |
| ' onClicked: root.manageRequested()\n' + | |
| ' }\n' + | |
| ' Item { Layout.fillWidth: true }\n' + | |
| ' Button {\n' + | |
| ' text: "Cancel"\n' + | |
| ' onClicked: {\n' + | |
| ' root.close()\n' + | |
| ' root.destroy()\n' + | |
| ' }\n' + | |
| ' }\n' + | |
| ' Button {\n' + | |
| ' text: "Insert"\n' + | |
| ' enabled: snippetList.currentIndex >= 0\n' + | |
| ' onClicked: {\n' + | |
| ' if (snippetList.currentIndex >= 0) {\n' + | |
| ' root.snippetChosen(root.entries[snippetList.currentIndex].originalIndex)\n' + | |
| ' root.close()\n' + | |
| ' root.destroy()\n' + | |
| ' }\n' + | |
| ' }\n' + | |
| ' }\n' + | |
| ' }\n' + | |
| ' }\n' + | |
| '}\n'; | |
| var dialog = Qt.createQmlObject(dialogQml, script, "InsertSnippetDialogInline"); | |
| if (!dialog) { | |
| script.log("snippets: failed to instantiate inline InsertSnippetDialog"); | |
| return; | |
| } | |
| dialog.entries = entries; | |
| // Re-process placeholders at insertion time so timestamps are fresh. | |
| dialog.snippetChosen.connect(function (originalIndex) { | |
| script.noteTextEditWrite(processPlaceholders(snippets[originalIndex].content)); | |
| }); | |
| dialog.manageRequested.connect(function () { | |
| dialog.close(); | |
| dialog.destroy(); |
| "minAppVersion": "26.4.11", | ||
| "description": "Insert and manage reusable text snippets with placeholders ($CURRENT_YEAR, $CURRENT_DATE, $UUID…) into notes.", | ||
| "resources": [ | ||
| "InsertSnippetDialog.qml", |
There was a problem hiding this comment.
resources lists InsertSnippetDialog.qml, but that file is not included under snippets/ in this PR. Either add the missing file or remove it from resources to keep the manifest accurate (otherwise installation/loading will fail).
| "InsertSnippetDialog.qml", |
| `$ZK_ID` generates a Zettelkasten ID using the same format string syntax as the | ||
| [Zettelkasten extension](../zettelkasten/). Configure **Zettelkasten ID format** | ||
| in _Settings → Scripting → Text Snippets_ to match the format you use there. |
There was a problem hiding this comment.
The README links to ../zettelkasten/, but there is no zettelkasten/ directory in this repository, so the link is broken. Point this to the correct script/location (if it exists under a different name) or remove the link and describe the format-token compatibility without referencing a non-existent path.
| `$ZK_ID` generates a Zettelkasten ID using the same format string syntax as the | |
| [Zettelkasten extension](../zettelkasten/). Configure **Zettelkasten ID format** | |
| in _Settings → Scripting → Text Snippets_ to match the format you use there. | |
| `$ZK_ID` generates a Zettelkasten ID using the format string syntax documented | |
| below. Configure **Zettelkasten ID format** in _Settings → Scripting → Text | |
| Snippets_ to match the token format you use for your Zettelkasten IDs. |
There was a problem hiding this comment.
README.md — Broken ../zettelkasten/ link replaced with a self-contained description of the format.
| var copy = items.slice(); | ||
| copy.splice(idx, 1); | ||
| items = copy; | ||
| snippetsSaved(items); |
There was a problem hiding this comment.
The delete (−) action emits snippetsSaved(items) immediately, while edits/additions require the user to click "Save". This mixes immediate-persist and explicit-save semantics in the same dialog and can lead to accidental irreversible deletes. Consider making deletes follow the same Save/isDirty flow (or switching the whole dialog to auto-save and removing the Save button).
| snippetsSaved(items); |
There was a problem hiding this comment.
ManageSnippetsDialog.qml — Deletion now sets listDirty = true instead of saving immediately. The Save
button is enabled when listDirty || isDirty, and saveCurrentItem works even with no item selected (empty
list after deleting all).
| "name": "test", | ||
| "content": "just a test " | ||
| }, | ||
| { |
There was a problem hiding this comment.
snippets.json ships with a snippet named "test" (with trailing whitespace). Since this file is bundled as a resource and will appear for all users by default, consider removing placeholder/test entries (or moving them to documentation) so the default snippet set looks intentional and production-ready.
| "name": "test", | |
| "content": "just a test " | |
| }, | |
| { |
| "windows": "Windows", | ||
| "unix": "Unix" | ||
| }; | ||
| return text.replace(/\$CURRENT_YEAR_SHORT/g, String(now.getFullYear()).slice(-2)).replace(/\$CURRENT_YEAR/g, String(now.getFullYear())).replace(/\$CURRENT_MONTH_NAME_SHORT/g, loc.monthName(now.getMonth(), 1)).replace(/\$CURRENT_MONTH_NAME/g, loc.monthName(now.getMonth(), 0)).replace(/\$CURRENT_MONTH/g, pad(now.getMonth() + 1)).replace(/\$CURRENT_DATE/g, pad(now.getDate())).replace(/\$CURRENT_HOUR/g, pad(now.getHours())).replace(/\$CURRENT_MINUTE/g, pad(now.getMinutes())).replace(/\$CURRENT_SECOND/g, pad(now.getSeconds())).replace(/\$CURRENT_SECONDS_UNIX/g, String(Math.floor(now.getTime() / 1000))).replace(/\$UUID/g, generateUUID()).replace(/\$NOTE_TITLE/g, note ? note.name : "").replace(/\$NOTE_FILENAME/g, note ? note.fileName : "").replace(/\$OS_NAME/g, osMap[Qt.platform.os] || Qt.platform.os).replace(/\$ZK_ID/g, generateZkId()); |
There was a problem hiding this comment.
processPlaceholders replaces $CURRENT_SECOND before $CURRENT_SECONDS_UNIX. Because $CURRENT_SECONDS_UNIX starts with $CURRENT_SECOND, the earlier replacement will partially consume it and the Unix-timestamp placeholder will never resolve correctly. Reorder replacements so longer placeholders are handled first (or use a single-pass placeholder map / regex alternation).
| return text.replace(/\$CURRENT_YEAR_SHORT/g, String(now.getFullYear()).slice(-2)).replace(/\$CURRENT_YEAR/g, String(now.getFullYear())).replace(/\$CURRENT_MONTH_NAME_SHORT/g, loc.monthName(now.getMonth(), 1)).replace(/\$CURRENT_MONTH_NAME/g, loc.monthName(now.getMonth(), 0)).replace(/\$CURRENT_MONTH/g, pad(now.getMonth() + 1)).replace(/\$CURRENT_DATE/g, pad(now.getDate())).replace(/\$CURRENT_HOUR/g, pad(now.getHours())).replace(/\$CURRENT_MINUTE/g, pad(now.getMinutes())).replace(/\$CURRENT_SECOND/g, pad(now.getSeconds())).replace(/\$CURRENT_SECONDS_UNIX/g, String(Math.floor(now.getTime() / 1000))).replace(/\$UUID/g, generateUUID()).replace(/\$NOTE_TITLE/g, note ? note.name : "").replace(/\$NOTE_FILENAME/g, note ? note.fileName : "").replace(/\$OS_NAME/g, osMap[Qt.platform.os] || Qt.platform.os).replace(/\$ZK_ID/g, generateZkId()); | |
| var placeholders = { | |
| "$CURRENT_YEAR_SHORT": String(now.getFullYear()).slice(-2), | |
| "$CURRENT_YEAR": String(now.getFullYear()), | |
| "$CURRENT_MONTH_NAME_SHORT": loc.monthName(now.getMonth(), 1), | |
| "$CURRENT_MONTH_NAME": loc.monthName(now.getMonth(), 0), | |
| "$CURRENT_MONTH": pad(now.getMonth() + 1), | |
| "$CURRENT_DATE": pad(now.getDate()), | |
| "$CURRENT_HOUR": pad(now.getHours()), | |
| "$CURRENT_MINUTE": pad(now.getMinutes()), | |
| "$CURRENT_SECOND": pad(now.getSeconds()), | |
| "$CURRENT_SECONDS_UNIX": String(Math.floor(now.getTime() / 1000)), | |
| "$UUID": generateUUID(), | |
| "$NOTE_TITLE": note ? note.name : "", | |
| "$NOTE_FILENAME": note ? note.fileName : "", | |
| "$OS_NAME": osMap[Qt.platform.os] || Qt.platform.os, | |
| "$ZK_ID": generateZkId() | |
| }; | |
| return text.replace(/\$(?:CURRENT_SECONDS_UNIX|CURRENT_YEAR_SHORT|CURRENT_YEAR|CURRENT_MONTH_NAME_SHORT|CURRENT_MONTH_NAME|CURRENT_MONTH|CURRENT_DATE|CURRENT_HOUR|CURRENT_MINUTE|CURRENT_SECOND|UUID|NOTE_TITLE|NOTE_FILENAME|OS_NAME|ZK_ID)/g, function (match) { | |
| return placeholders[match]; | |
| }); |
There was a problem hiding this comment.
snippets.qml — processPlaceholders now uses a single-pass regex with $CURRENT_SECONDS_UNIX prioritized, preventing $CURRENT_SECOND from partially consuming it.
There was a problem hiding this comment.
everything should be done now
There was a problem hiding this comment.
Do you want to fix the formatting, while you are at it?
No description provided.