diff --git a/zettelkasten/README.md b/zettelkasten/README.md new file mode 100644 index 0000000..d40a044 --- /dev/null +++ b/zettelkasten/README.md @@ -0,0 +1,77 @@ +# Zettelkasten for QOwnNotes + +A QOwnNotes script that adds [Zettelkasten](https://en.wikipedia.org/wiki/Zettelkasten) support: unique IDs for notes and permanent wiki-links that survive note renames. + +## Concept + +In the Zettelkasten method, each note carries a **permanent unique ID** embedded in its content or filename. Links between notes are based on this ID, not on the filename. This means a note can be renamed freely without breaking any link pointing to it. + +This script implements that principle inside QOwnNotes using the native `[[filename|id]]` wiki-link format. + +## Link format + +``` +[[MyNote|20260430143012]] +``` + +- The left part (`MyNote`) is what QOwnNotes uses to resolve the link (Ctrl+click to open). +- The right part (`20260430143012`) is the permanent ZK ID, used by this script to repair links when the filename changes. + +## Actions + +Three toolbar buttons are registered: + +| Button | Action | +| ----------- | ---------------------------------------------------------------------------- | +| **ZK-ID** | Insert a new unique ZK ID at the cursor position | +| **ZK-Link** | Open a searchable dialog to pick a note and insert a `[[filename\|id]]` link | +| **ZK-Fix** | Scan all notes and repair every link whose filename is out of date | + +## ID format + +IDs are generated from the current date and time using a configurable format string. + +Available tokens: + +| Token | Value | +| ----- | -------------- | +| `%Y` | 4-digit year | +| `%M` | 2-digit month | +| `%D` | 2-digit day | +| `%h` | 2-digit hour | +| `%m` | 2-digit minute | +| `%s` | 2-digit second | + +Examples: + +| Format | Result | +| -------------------------- | ------------------- | +| `%Y%M%D%h%m%s` _(default)_ | `20260430143012` | +| `id%Y%M%Dx%h%m%s` | `id20260430x143012` | +| `%Y-%M-%D` | `2026-04-30` | + +## ID detection + +When searching for a note's ZK ID, the script checks the **filename** first, then the full **note body**. Only the first match is used. + +The detection pattern is a configurable ECMAScript regex. The default `\d{14}` matches any 14-digit timestamp. If you use a custom format with a prefix (e.g. `id%Y%M%Dx%h%m%s`), update the regex accordingly — for example `id\d{8}x\d{6}`. + +## Rename resilience + +When you rename a note in QOwnNotes, any `[[oldName|id]]` links in other notes become stale. This script fixes them automatically in two ways: + +- **Automatic** — when you open the renamed note, the script silently rewrites every backlink pointing to it with the new filename. This happens in the background with no interruption. +- **Manual** — click **ZK-Fix** at any time to repair all stale links across the entire vault in one pass. + +> **Note:** QOwnNotes may show a native dialog asking whether to replace link occurrences after a rename. That dialog does not understand the `[[filename|id]]` format and will not change anything. You can safely click _No_ and let this script handle it, or disable the dialog entirely in _Settings → Notes_. + +## Settings + +All settings are accessible in _Settings → Scripting → Zettelkasten_: + +| Setting | Default | Description | +| ---------------------------------- | -------------- | -------------------------------------------------------------------- | +| ID generation format | `%Y%M%D%h%m%s` | Format string for new IDs | +| ID detection pattern | `\d{14}` | ECMAScript regex to locate IDs in filenames and content | +| Auto-repair backlinks on note open | enabled | Automatically fix stale backlinks when a note with a ZK ID is opened | + diff --git a/zettelkasten/ZkLinkDialog.qml b/zettelkasten/ZkLinkDialog.qml new file mode 100644 index 0000000..bd1be68 --- /dev/null +++ b/zettelkasten/ZkLinkDialog.qml @@ -0,0 +1,266 @@ +import QtQuick 2.0 +import QtQuick.Window 2.0 + +Window { + id: root + title: "Insert Zettelkasten link" + width: 620 + height: 420 + minimumWidth: 480 + minimumHeight: 300 + modality: Qt.ApplicationModal + flags: Qt.Dialog | Qt.WindowCloseButtonHint + + property var entries: [] + signal linkSelected(string linkTarget, string zkId) + property var filtered: [] + + SystemPalette { + id: pal + } + + Component.onCompleted: { + applyFilter(); + searchInput.forceActiveFocus(); + } + + // ── Search field ────────────────────────────────────────────────────────── + Rectangle { + id: searchBox + anchors { + top: parent.top + left: parent.left + right: parent.right + margins: 10 + } + height: 30 + radius: 3 + color: pal.base + border.color: searchInput.activeFocus ? "#1cb27e" : pal.mid + border.width: 1 + + Text { + anchors { + fill: parent + leftMargin: 8 + } + verticalAlignment: Text.AlignVCenter + text: "Filter by name…" + color: pal.mid + font.pixelSize: 13 + visible: searchInput.text === "" + } + + TextInput { + id: searchInput + anchors { + fill: parent + margins: 8 + } + verticalAlignment: TextInput.AlignVCenter + font.pixelSize: 13 + color: pal.text + clip: true + onTextChanged: applyFilter() + Keys.onReturnPressed: acceptSelection() + Keys.onDownPressed: moveSelection(1) + Keys.onUpPressed: moveSelection(-1) + } + } + + // ── Note list ───────────────────────────────────────────────────────────── + Rectangle { + id: listBox + anchors { + top: searchBox.bottom + topMargin: 6 + left: parent.left + right: parent.right + margins: 10 + bottom: bottomBar.top + bottomMargin: 6 + } + radius: 3 + color: pal.base + border.color: pal.mid + border.width: 1 + clip: true + + ListView { + id: resultList + anchors { + fill: parent + margins: 1 + rightMargin: scrollBar.visible ? 8 : 1 + } + model: filtered + currentIndex: 0 + clip: true + boundsBehavior: Flickable.StopAtBounds + + delegate: Item { + width: resultList.width + height: 28 + + Rectangle { + anchors.fill: parent + color: index === resultList.currentIndex ? "#1cb27e" : (rowMouse.containsMouse ? "#e4f5ef" : "transparent") + } + + Text { + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + right: parent.right + margins: 8 + } + text: modelData.label + color: index === resultList.currentIndex ? "white" : pal.text + font.pixelSize: 13 + elide: Text.ElideRight + } + + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + onClicked: resultList.currentIndex = index + onDoubleClicked: acceptSelection() + } + } + } + + // Minimal scrollbar + Rectangle { + id: scrollBar + visible: resultList.contentHeight > resultList.height + width: 5 + anchors { + right: parent.right + top: parent.top + bottom: parent.bottom + margins: 1 + } + color: "transparent" + + Rectangle { + width: parent.width + radius: 2 + color: pal.mid + height: Math.max(24, resultList.height * resultList.height / Math.max(resultList.contentHeight, 1)) + y: resultList.height > 0 ? resultList.contentY / Math.max(resultList.contentHeight - resultList.height, 1) * (resultList.height - height) : 0 + } + } + + Text { + anchors.centerIn: parent + visible: filtered.length === 0 + text: "No matching note found." + color: pal.mid + font.pixelSize: 13 + } + } + + // ── Bottom bar ──────────────────────────────────────────────────────────── + Item { + id: bottomBar + anchors { + bottom: parent.bottom + left: parent.left + right: parent.right + margins: 10 + } + height: 34 + + Text { + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + } + text: filtered.length + " note(s)" + color: pal.mid + font.pixelSize: 12 + } + + // Cancel + Rectangle { + id: cancelBtn + anchors { + verticalCenter: parent.verticalCenter + right: insertBtn.left + rightMargin: 8 + } + width: 76 + height: 26 + radius: 4 + color: cancelMouse.pressed ? pal.dark : pal.button + border.color: pal.mid + border.width: 1 + + Text { + anchors.centerIn: parent + text: "Cancel" + color: pal.buttonText + font.pixelSize: 13 + } + MouseArea { + id: cancelMouse + anchors.fill: parent + onClicked: root.close() + } + } + + // Insert + Rectangle { + id: insertBtn + anchors { + verticalCenter: parent.verticalCenter + right: parent.right + } + width: 76 + height: 26 + radius: 4 + opacity: (resultList.currentIndex >= 0 && filtered.length > 0) ? 1.0 : 0.4 + color: insertMouse.pressed ? "#15896b" : "#1cb27e" + + Text { + anchors.centerIn: parent + text: "Insert" + color: "white" + font.pixelSize: 13 + } + MouseArea { + id: insertMouse + anchors.fill: parent + enabled: resultList.currentIndex >= 0 && filtered.length > 0 + onClicked: acceptSelection() + } + } + } + + // ── Logic ───────────────────────────────────────────────────────────────── + function moveSelection(delta) { + var next = resultList.currentIndex + delta; + if (next >= 0 && next < resultList.count) + resultList.currentIndex = next; + } + + function applyFilter() { + var f = searchInput.text ? searchInput.text.toLowerCase() : ""; + var result = []; + for (var i = 0; i < entries.length; i++) { + if (!f || entries[i].label.toLowerCase().indexOf(f) >= 0) + result.push(entries[i]); + } + filtered = result; + resultList.currentIndex = result.length > 0 ? 0 : -1; + } + + function acceptSelection() { + var idx = resultList.currentIndex; + if (idx < 0 || idx >= filtered.length) + return; + linkSelected(filtered[idx].linkTarget, filtered[idx].zkId); + root.close(); + } +} diff --git a/zettelkasten/info.json b/zettelkasten/info.json new file mode 100644 index 0000000..23078b3 --- /dev/null +++ b/zettelkasten/info.json @@ -0,0 +1,11 @@ +{ + "name": "Zettelkasten", + "identifier": "zettelkasten", + "version": "0.2.0", + "script": "zettelkasten.qml", + "authors": ["@luginf"], + "platforms": ["linux", "macos", "windows"], + "minAppVersion": "26.4.11", + "description": "Zettelkasten support: generates configurable note IDs and inserts [[filename|ID]] wiki-links to notes detected by a configurable ECMAScript ID pattern.", + "resources": ["ZkLinkDialog.qml"] +} diff --git a/zettelkasten/zettelkasten.qml b/zettelkasten/zettelkasten.qml new file mode 100644 index 0000000..557671d --- /dev/null +++ b/zettelkasten/zettelkasten.qml @@ -0,0 +1,243 @@ +// Zettelkasten support for QOwnNotes. +// Actions: +// • Insert ZK ID — inserts an ID built from a configurable format string +// • Insert ZK link — filters notes by name, picks one, inserts [[filename|ID]] +// • Repair ZK links — scans all notes and fixes [[oldName|ID]] → [[newName|ID]] +// ID format tokens: %Y year %M month %D day %h hour %m minute %s second +// Example format: id%Y%M%Dx%h%m%s → id20260430x143012 +// IDs are detected via a configurable ECMAScript regex (default: \d{14}). +// The regex is tested first against the note filename, then full content — +// first match only. +// Link format: [[nom_fichier_sur_disque.md|20260430143012]] +// QOwnNotes resolves the filename part as a wiki-link (Ctrl+click to open). +// Rename resilience: when a note carrying a ZK ID is opened, any backlinks in +// other notes that still reference its old filename are silently rewritten to +// use the current filename. A manual "Repair ZK links" action (ZK-Fix toolbar +// button) performs the same scan across the entire vault in one pass. +import QtQml 2.0 +import QOwnNotesTypes 1.0 + +Script { + property string idRegex + property string idFormat + property bool autoRepairLinks: true + + property variant settingsVariables: [ + { + "identifier": "idFormat", + "name": "ID generation format", + "description": "Format string for generating new IDs.\nTokens: %Y=year %M=month %D=day %h=hour %m=minute %s=second\nLiteral characters are kept as-is.\n\nExamples:\n %Y%M%D%h%m%s → 20260430143012\n id%Y%M%Dx%h%m%s → id20260430x143012\n %Y-%M-%D → 2026-04-30", + "type": "string", + "default": "%Y%M%D%h%m%s" + }, + { + "identifier": "idRegex", + "name": "ID detection pattern (ECMAScript regex)", + "description": "Pattern used to detect Zettelkasten IDs in note filenames and content.\nDefault matches 14-digit timestamps: \\d{14}", + "type": "string", + "default": "\\d{14}" + }, + { + "identifier": "autoRepairLinks", + "name": "Auto-repair backlinks on note open", + "description": "When a note with a ZK ID is opened, automatically rewrite any backlinks in other notes that still use an outdated filename for that ID.", + "type": "boolean", + "default": true + } + ] + + function init() { + script.registerCustomAction("zkInsertId", "Insert Zettelkasten ID", "ZK-ID", "", false, false, true); + script.registerCustomAction("zkInsertLink", "Insert Zettelkasten link", "ZK-Link", "", false, false, true); + script.registerCustomAction("zkRepairLinks", "Repair Zettelkasten links", "ZK-Fix", "", false, false, false); + } + + function customActionInvoked(identifier) { + if (identifier === "zkInsertId") { + insertZkId(); + } else if (identifier === "zkInsertLink") { + insertZkLink(); + } else if (identifier === "zkRepairLinks") { + repairAllLinks(); + } + } + + function noteOpenedHook(note) { + if (autoRepairLinks !== false) { + repairBacklinksFor(note); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + function generateId() { + var fmt = (idFormat || "").trim() || "%Y%M%D%h%m%s"; + var d = new Date(); + var p = function (n) { + return n < 10 ? "0" + n : String(n); + }; + return fmt.replace(/%Y/g, String(d.getFullYear())).replace(/%M/g, p(d.getMonth() + 1)).replace(/%D/g, p(d.getDate())).replace(/%h/g, p(d.getHours())).replace(/%m/g, p(d.getMinutes())).replace(/%s/g, p(d.getSeconds())); + } + + function extractId(text) { + try { + var re = new RegExp(idRegex || "\\d{14}"); + // Strip [[target|id]] links before matching so a linked ID is never + // mistaken for the note's own ID (which would corrupt idMap). + var m = text.replace(/\[\[[^\]|]*\|[^\]]*\]\]/g, "").match(re); + return m ? m[0] : null; + } catch (e) { + script.log("zettelkasten: invalid ID regex — " + e); + return null; + } + } + + // Returns the link target string for [[target|id]] given a note object. + function noteLinkTarget(note) { + var name = note && note.name ? note.name : ""; + if (name) { + var relativeDir = note.relativeNoteFileDirPath || ""; + if (relativeDir) { + relativeDir = relativeDir.replace(/[\/\\]+$/, ""); + return relativeDir ? relativeDir + "/" + name : name; + } + return name; + } + + return /\.txt$/i.test(note.fileName) ? note.fileName.slice(0, note.fileName.length - 4) : note.fileName; + } + + function regEscape(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + // ── Backlink repair ─────────────────────────────────────────────────────── + + // When a note is opened, find all notes that link to its ZK ID with a + // stale filename and rewrite those links to use the current filename. + function repairBacklinksFor(note) { + if (!note || !note.fileName) + return; + var zkId = extractId(note.fileName); + if (!zkId) + zkId = extractId(note.noteText); + if (!zkId) + return; + var currentTarget = noteLinkTarget(note); + + // Fetch notes that contain the literal string "|zkId]]" + var candidates = script.fetchNoteIdsByNoteTextPart("|" + zkId + "]]"); + var pattern = new RegExp("\\[\\[([^\\]|]*)\\|" + regEscape(zkId) + "\\]\\]", "g"); + for (var i = 0; i < candidates.length; i++) { + var n = script.fetchNoteById(candidates[i]); + if (!n || !n.noteText || !n.fullNoteFilePath) + continue; + var changed = false; + var newText = n.noteText.replace(pattern, function (match, oldTarget) { + if (oldTarget === currentTarget) + return match; + changed = true; + return "[[" + currentTarget + "|" + zkId + "]]"; + }); + if (changed) { + script.writeToFile(n.fullNoteFilePath, newText); + script.log("zettelkasten: repaired backlink in \"" + n.fileName + "\" → [[" + currentTarget + "|" + zkId + "]]"); + } + } + } + + // Full vault scan: build an id→currentTarget map, then rewrite every + // [[staleTarget|id]] in every note. + function repairAllLinks() { + var allIds = script.fetchNoteIdsByNoteTextPart(""); + + // Build zkId → correct link target + var idMap = {}; + for (var i = 0; i < allIds.length; i++) { + var note = script.fetchNoteById(allIds[i]); + if (!note || !note.fileName) + continue; + var zkId = extractId(note.fileName); + if (!zkId) + zkId = extractId(note.noteText); + if (!zkId) + continue; + idMap[zkId] = noteLinkTarget(note); + } + var pattern = /\[\[([^\]|]*)\|([^\]]*)\]\]/g; + var repairedLinks = 0; + var repairedNotes = 0; + for (var j = 0; j < allIds.length; j++) { + var n = script.fetchNoteById(allIds[j]); + if (!n || !n.noteText || !n.fullNoteFilePath) + continue; + var changed = false; + var newText = n.noteText.replace(pattern, function (match, linkTarget, linkId) { + var correct = idMap[linkId]; + if (!correct || correct === linkTarget) + return match; + changed = true; + repairedLinks++; + return "[[" + correct + "|" + linkId + "]]"; + }); + if (changed) { + script.writeToFile(n.fullNoteFilePath, newText); + repairedNotes++; + } + } + script.informationMessageBox(repairedLinks > 0 ? "Repaired " + repairedLinks + " link(s) in " + repairedNotes + " note(s)." : "All Zettelkasten links are up to date.", "Zettelkasten"); + } + + // ── Actions ─────────────────────────────────────────────────────────────── + function insertZkId() { + script.noteTextEditWrite(generateId()); + } + + function insertZkLink() { + var noteIds = script.fetchNoteIdsByNoteTextPart(""); + var entries = []; + for (var i = 0; i < noteIds.length; i++) { + var note = script.fetchNoteById(noteIds[i]); + if (!note || !note.fileName) + continue; + + // Check filename first, then full content — first match only + var zkId = extractId(note.fileName); + if (!zkId) + zkId = extractId(note.noteText); + if (!zkId) + continue; + entries.push({ + "label": zkId + " — " + note.name, + "linkTarget": noteLinkTarget(note), + "zkId": zkId + }); + } + if (entries.length === 0) { + script.informationMessageBox("No note with a Zettelkasten ID was found.\nPattern: " + (idRegex || "\\d{14}"), "Zettelkasten"); + return; + } + + // Most recent first + entries.sort(function (a, b) { + return b.zkId > a.zkId ? 1 : b.zkId < a.zkId ? -1 : 0; + }); + var component = Qt.createComponent(Qt.resolvedUrl("ZkLinkDialog.qml")); + if (component.status !== Component.Ready) { + script.informationMessageBox("Failed to load ZkLinkDialog:\n" + component.errorString(), "Zettelkasten"); + return; + } + var dialog = component.createObject(null, { + "entries": entries + }); + if (!dialog) { + script.informationMessageBox("Failed to instantiate ZkLinkDialog.", "Zettelkasten"); + return; + } + dialog.linkSelected.connect(function (linkTarget, zkId) { + script.noteTextEditWrite("[[" + linkTarget + "|" + zkId + "]]"); + }); + dialog.show(); + dialog.raise(); + dialog.requestActivate(); + } +}