Skip to content
Merged
77 changes: 77 additions & 0 deletions zettelkasten/README.md
Original file line number Diff line number Diff line change
@@ -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 |

266 changes: 266 additions & 0 deletions zettelkasten/ZkLinkDialog.qml
Original file line number Diff line number Diff line change
@@ -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();
}
}
11 changes: 11 additions & 0 deletions zettelkasten/info.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading
Loading