From e5badd7067b5d26ada83023d04eeee411fa07931 Mon Sep 17 00:00:00 2001 From: luginf Date: Mon, 28 Sep 2026 07:53:40 +0200 Subject: [PATCH 1/3] Add Claude Code config (CLAUDE.md, SKILLS.md) Squash fork history: main was diverged from upstream only by these config files (plus a file-mode fix), so this replaces the tangled merge history with a single commit on top of upstream/main. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 94 ++++++++++++++++++++++++++ SKILLS.md | 142 +++++++++++++++++++++++++++++++++++++++ new-note-namer/README.md | 0 3 files changed, 236 insertions(+) create mode 100644 CLAUDE.md create mode 100644 SKILLS.md mode change 100755 => 100644 new-note-namer/README.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9f8d7c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,94 @@ +# CLAUDE.md — QOwnNotes scripts workspace + +## Working conventions + +- **Always write responses and reports in English**, regardless of the language the user uses to ask. + +## Repository layout + +``` +luginf/ + qownnotes-scripts/ ← fork of https://github.com/qownnotes/scripts (working directory) + / ← one directory per script (kebab-case) + .qml ← main script (same name as directory) + info.json ← script metadata + AGENTS.md ← contributor guide (read this first) + CLAUDE.md ← this file + justfile ← task runner + devenv.nix ← dev environment with pre-commit hooks +``` + +Active scripts authored by @luginf: **txt2tags-it**, **zettelkasten**, **snippets**. +Scripts contributed to by @luginf: **new-note-namer**. + +Current branch: `snippets-zettelkasten-doc` (documentation updates for snippets and zettelkasten). + +## Language and tooling + +- Scripts are written in **QML** (Qt Modeling Language) with embedded JavaScript (Qt V4 engine — ES5/ES6 subset, no `import`/`export`, no Node APIs). +- Extra JS files bundled as `resources` in `info.json` must use `var`-level exports (not ESM) so they are accessible from QML via module qualifier (e.g., `import "markdown-it.js" as MarkdownIt`). +- Formatter: `qmlformat` for `.qml`, `prettier` for `.js`/`.json`/`.md` — run automatically on commit via pre-commit hooks. +- Test suite: `just test` (PHP, validates `info.json` structure and script layout). + +## QOwnNotes scripting API — key facts + +- `script` global: most API methods (`script.log()`, `script.noteTextEditWrite()`, `script.registerCustomAction()`, …). +- `mainWindow` global: UI-level methods. **`mainWindow.focusNoteTextEdit()`** restores keyboard focus to the editor after a custom action — `script.noteTextEditSetFocus()` does NOT exist. +- `init()` is called once on script load. Store heavy initialisation (regex compilation, platform detection) here. +- `noteToMarkdownHtmlHook(note, html, forExport)` is called on **every preview refresh** (every keystroke in live mode) — keep it fast. +- `customActionInvoked(identifier)` is called when a toolbar/menu action fires. Always call `mainWindow.focusNoteTextEdit()` at the end to return focus to the editor. +- Custom actions are registered with `script.registerCustomAction(id, menuText, buttonText, icon, useInToolbar, checkable, checked)`. +- **`handleNewNoteHeadlineHook(headline)`** — `headline` is a **plain string** (the search term or default text), NOT a Note object. Accessing `headline.noteText`, `headline.fileCreated`, etc. gives `undefined`. Return the desired note content as a string; returning `null` causes QOwnNotes to show its own built-in headline dialog. +- **`handleNoteTextFileNameHook(note)`** — `note` IS a Note object. `note.fileCreated` equals `"Invalid Date"` only at the moment of creation; use this to gate file-name changes on creation only. `note.noteText` holds the content returned by `handleNewNoteHeadlineHook`. +- **`settingsVariables` always require a script engine reload** to take effect — this applies to all setting types (boolean, string, selection). There is no live-update mechanism. +- Hook execution order on note creation: `handleNewNoteHeadlineHook` → `handleNoteTextFileNameHook` → `noteOpenedHook`. +- Full API reference: https://www.qownnotes.org/scripting/methods-and-objects.html +- Exposed classes (NoteApi, etc.): https://www.qownnotes.org/scripting/classes.html + +## new-note-namer script specifics + +`new-note-namer/` sets the note title and file name at creation time. + +**Behaviors:** +- **Creating from search**: the search term is used directly as the title — no dialog. +- **Creating from menu**: a dialog asks for the title (pre-filled empty). +- **`extraDialogForFileName`**: in both cases, shows a second dialog to enter a file name different from the title, pre-filled with the derived title. +- **`headingStyle`**: `"0"` ATX (`# Title`), `"1"` Setext (`Title / =====`), `"2"` Custom (with `customHeadingOpen`/`customHeadingClose` tags). + +**Key implementation details:** +- `handleNewNoteHeadlineHook(headline)`: if `headline` is non-empty (search term or QOwnNotes own dialog), use it directly; if empty (menu creation without prior prompt), show dialog. Returns `buildHeadline(name)`. +- `handleNoteTextFileNameHook(note)`: derives file name via `extractTitle(note.noteText)`, which strips heading markers according to `headingStyle`. If `extraDialogForFileName`, shows dialog pre-filled with the result. +- `buildHeadline(name)` / `extractTitle(noteText)`: symmetric functions — one adds heading markup, the other strips it. + +## txt2tags-it script specifics + +`txt2tags-it/` renders notes using **markdown-it** (bundled v8.4.2) augmented by a custom plugin (`markdown-it-txt2tags.js`) adding txt2tags syntax: + +| Syntax | Output | +|--------|--------| +| `= H1 =` … `===== H5 =====` | headings | +| `//italic//` | `` | +| `__underline__` | `` | +| `--strikethrough--` | `` | +| `+ item` lines | ordered list | +| `% comment` | silently consumed | +| `[[wikilink]]`, `[[wikilink\|label]]` | note links | +| `[label url]` | bare links | + +Performance notes: +- Regexes used in `noteToMarkdownHtmlHook` are pre-compiled in `init()` and stored as `property variant` (`_headRe`, `_urlAttrRe`). +- Platform check (`_isWindows`) and constant CSS string (`_cssInject`) are also cached in `init()`. +- `_urlAttrRe.lastIndex = 0` must be reset before each `replace()` call because it is a stored global regex. +- The `txt2tags_autolink` inline rule has a fast-fail letter check to avoid `src.slice(pos)` at non-letter positions. + +## Workflow + +```sh +just # list all recipes +just test # run test suite +just format # format all files +just git-create-patch # export staged changes as patch to Nextcloud Transfer +just git-apply-patch # apply patch from Nextcloud Transfer +``` + +Patch workflow is used to move changes between machines via Nextcloud. diff --git a/SKILLS.md b/SKILLS.md new file mode 100644 index 0000000..3bef77c --- /dev/null +++ b/SKILLS.md @@ -0,0 +1,142 @@ +# SKILLS.md — Patterns and lessons for QOwnNotes script development + +## QOwnNotes API gotchas + +### Focus after custom actions +After any `customActionInvoked` handler that writes to the editor, the keyboard focus is stolen by the toolbar/menu button. +**Fix:** always end `customActionInvoked` with: +```javascript +mainWindow.focusNoteTextEdit(); +``` +`script.noteTextEditSetFocus()` does **not** exist and throws `TypeError` (confirmed on v26.5.14). + +### noteToMarkdownHtmlHook performance +This hook runs on every keystroke in live preview. Avoid inside it: +- `new RegExp(...)` — recompiles the regex each call. Pre-compile in `init()` and store as `property variant`. +- `script.platformIsWindows()` — cache result in `init()` as `property bool _isWindows`. +- Constant string concatenations — extract as `property string`. +- `(?:.|\n)*?` — catastrophically slow on large HTML; use `[\s\S]*?` instead. + +Pattern for pre-compiled regex with `g` flag (stored as property): +```javascript +// in init(): +_urlAttrRe = /(\b(?:src|href|data-[\w-]+)\s*=\s*)(["'])([^"']+)\2/gi; + +// in noteToMarkdownHtmlHook(): +_urlAttrRe.lastIndex = 0; // mandatory reset before each replace() +mdHtml = mdHtml.replace(_urlAttrRe, ...); +``` + +### handleNewNoteHeadlineHook — parameter is a plain string +`handleNewNoteHeadlineHook(headline)` receives the headline as a **plain string** (the search term +or QOwnNotes default text), NOT a Note object. `headline.noteText`, `headline.fileCreated`, etc. +are all `undefined`. + +- Returning `null` → QOwnNotes shows its own built-in headline dialog. +- Returning a real string → that string becomes the note content, no dialog. +- If `headline` is non-empty, a source (search or QOwnNotes own dialog) already provided it — + use it directly. If empty, ask the user. + +```javascript +function handleNewNoteHeadlineHook(headline) { + var name = headline !== "" ? headline : newNamer("New note", "New note title", "Title"); + return buildHeadline(name); +} +``` + +### handleNoteTextFileNameHook — note.noteText holds the headline hook's return value +`handleNoteTextFileNameHook(note)` receives a proper Note object. `note.noteText` contains the +content returned by `handleNewNoteHeadlineHook`. `note.fileCreated` equals `"Invalid Date"` only +at the moment of creation; use this to block file renaming after creation: + +```javascript +if (note.fileCreated != "Invalid Date") { + return ""; +} +``` + +### buildHeadline / extractTitle symmetry +When a script both formats a headline and derives a file name from it, keep the two functions +symmetric — one adds markup, the other strips it — so title and file name are always consistent: + +```javascript +function buildHeadline(name) { + if (headingStyle === "1") return name + "\n" + "=".repeat(name.length); + return "# " + name; +} +function extractTitle(noteText) { + var first = (noteText || "").split("\n")[0]; + if (headingStyle === "1") return first; // setext: bare title + return first.slice(2); // ATX: strip "# " +} +``` + +### Hook execution order on note creation +`handleNewNoteHeadlineHook` → `handleNoteTextFileNameHook` → `noteOpenedHook` + +### settingsVariables always require a script engine reload +All setting types (boolean, string, selection) take effect only after reloading the script engine. +There is no live-update mechanism. Do not try to work around this in code — just document it. + +### QML property types for JS objects +Use `property variant` for RegExp, Array, and markdown-it instances. +Default values that require JS expressions (regex literals, `new`) must be set in `init()`, not inline: +```qml +property variant _headRe // set in init(), not here +``` + +### markdown-it inline rules: fast-fail pattern +Inline rules are tried at every character position. Add a charCode fast-fail before any `slice()` + `exec()`: +```javascript +var ch = src.charCodeAt(pos); +if (!((ch >= 0x41 && ch <= 0x5a) || (ch >= 0x61 && ch <= 0x7a))) + return false; +``` + +### JS bundled files in QML +Extra `.js` files must export via a top-level `var`, not ESM: +```javascript +var myExport; +(function(f){ /* UMD wrapper */ myExport = f(); })(...); +``` +They are imported in QML as: +```qml +import "my-lib.js" as MyLib +// usage: MyLib.myExport +``` + +### script.noteTextEditSetSelection + noteTextEditWrite +These two work together to replace a range: +```javascript +script.noteTextEditSetSelection(lineStart, lineEnd); +script.noteTextEditWrite(newLine); +``` +The cursor ends after the inserted text. There is no dedicated "replace range" API. + +### Heading toggle pattern +```javascript +var sameLevelRe = new RegExp("^" + markers + "\\s+.*?\\s+" + markers + "\\s*$"); +var newLine = sameLevelRe.test(line) ? content : markers + " " + content + " " + markers; +``` +Toggle off if already at the requested level, otherwise apply. + +## info.json conventions + +- `identifier` and `script` must exactly match the folder/file name. +- `minAppVersion`: use the current QOwnNotes version when unsure. +- Extra JS/QML files go in `resources` array. +- `platforms` list only platforms actually tested. + +## Formatting + +- `.qml` files: `qmlformat -i` (runs automatically on commit via pre-commit hook). +- `.js`/`.json`/`.md`: `prettier` (also runs on commit). +- Never add comments that describe *what* the code does; only add comments for non-obvious *why* (hidden constraints, workarounds). + +## Testing + +```sh +just test # validates info.json and script structure across all scripts +``` + +There are no unit tests for script logic — manual testing in QOwnNotes is required for rendering and UI behaviour. diff --git a/new-note-namer/README.md b/new-note-namer/README.md old mode 100755 new mode 100644 From c1721828d4d8253dde70d14e65493d410dce9340 Mon Sep 17 00:00:00 2001 From: luginf Date: Mon, 28 Sep 2026 08:08:53 +0200 Subject: [PATCH 2/3] ci: skip metadata-index upload on forks The "metadata-index" GitHub Release this workflow uploads to only exists on qownnotes/scripts; forks don't inherit Releases via git, so every push to main here failed with "release not found". Co-Authored-By: Claude Sonnet 5 --- .github/workflows/generate-index.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/generate-index.yml b/.github/workflows/generate-index.yml index f9b069a..ce1d25c 100644 --- a/.github/workflows/generate-index.yml +++ b/.github/workflows/generate-index.yml @@ -6,6 +6,9 @@ on: jobs: build: + # the "metadata-index" release this uploads to only exists on the upstream + # repo; forks don't get GitHub Releases via git, so skip there. + if: github.repository == 'qownnotes/scripts' runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 From 4b300c9ed2fa3601f6fc8317c25a5f87db73a8e0 Mon Sep 17 00:00:00 2001 From: luginf Date: Mon, 28 Sep 2026 08:10:52 +0200 Subject: [PATCH 3/3] new-note-namer: fix rename prompt firing on existing notes (#300) note.fileCreated == "Invalid Date" was used to detect a brand-new note, but it also reads "Invalid Date" the first time a pre-existing, not-yet-indexed note file is edited and saved -- it marks "new DB row", not "new file on disk". This made the script wrongly prompt for a file name on existing notes. Replace it with a one-shot flag set in handleNewNoteHeadlineHook, which only fires for genuine new-note creation, immediately before handleNoteTextFileNameHook. Bump to 0.0.5 and update CLAUDE.md's documented (and now disproven) assumption about fileCreated. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 2 +- new-note-namer/CHANGELOG.md | 4 ++++ new-note-namer/info.json | 2 +- new-note-namer/new-note-namer.qml | 16 +++++++++++++--- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9f8d7c2..600dfa2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ Current branch: `snippets-zettelkasten-doc` (documentation updates for snippets - `customActionInvoked(identifier)` is called when a toolbar/menu action fires. Always call `mainWindow.focusNoteTextEdit()` at the end to return focus to the editor. - Custom actions are registered with `script.registerCustomAction(id, menuText, buttonText, icon, useInToolbar, checkable, checked)`. - **`handleNewNoteHeadlineHook(headline)`** — `headline` is a **plain string** (the search term or default text), NOT a Note object. Accessing `headline.noteText`, `headline.fileCreated`, etc. gives `undefined`. Return the desired note content as a string; returning `null` causes QOwnNotes to show its own built-in headline dialog. -- **`handleNoteTextFileNameHook(note)`** — `note` IS a Note object. `note.fileCreated` equals `"Invalid Date"` only at the moment of creation; use this to gate file-name changes on creation only. `note.noteText` holds the content returned by `handleNewNoteHeadlineHook`. +- **`handleNoteTextFileNameHook(note)`** — `note` IS a Note object. `note.noteText` holds the content returned by `handleNewNoteHeadlineHook`. **Do not gate on `note.fileCreated == "Invalid Date"` to detect "new note"**: it also reads `"Invalid Date"` the first time a pre-existing, not-yet-indexed note file is edited and saved (see qownnotes/scripts#300) — it marks "new DB row", not "new file on disk". Instead, set a flag in `handleNewNoteHeadlineHook` (which only fires for genuine new-note creation, right before this hook per the execution order below) and consume it here. - **`settingsVariables` always require a script engine reload** to take effect — this applies to all setting types (boolean, string, selection). There is no live-update mechanism. - Hook execution order on note creation: `handleNewNoteHeadlineHook` → `handleNoteTextFileNameHook` → `noteOpenedHook`. - Full API reference: https://www.qownnotes.org/scripting/methods-and-objects.html diff --git a/new-note-namer/CHANGELOG.md b/new-note-namer/CHANGELOG.md index 24185e6..031c6f2 100644 --- a/new-note-namer/CHANGELOG.md +++ b/new-note-namer/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.0.5 - 2026-09-28 + +- Fixed the file-name prompt wrongly firing when an existing, not-yet-indexed note is edited and saved for the first time (#300). The rename logic now gates on a flag set only by the genuine new-note creation hook, instead of `note.fileCreated`. + ## 0.0.4 - 2026-05-26 - Added independent title and filename dialog settings, with search terms used directly when dialogs are disabled. diff --git a/new-note-namer/info.json b/new-note-namer/info.json index 5e74d1c..737d149 100644 --- a/new-note-namer/info.json +++ b/new-note-namer/info.json @@ -4,7 +4,7 @@ "script": "new-note-namer.qml", "authors": ["@diegovskytl", "@Mystechry", "@luginf"], "platforms": ["linux", "macos", "windows"], - "version": "0.0.4", + "version": "0.0.5", "minAppVersion": "17.06.2", "description": "Enables a dialog window (or two, or even none) so the user can choose the note title and file name at the moment of creation, or simply use the search term for the filename and title of the note with custom formattings.

Specially useful when the 'Allow note file name to be different to note title. \n No more cumbersome renaming :)" } diff --git a/new-note-namer/new-note-namer.qml b/new-note-namer/new-note-namer.qml index aa0005c..e3c3f0f 100644 --- a/new-note-namer/new-note-namer.qml +++ b/new-note-namer/new-note-namer.qml @@ -11,6 +11,7 @@ QtObject { property bool extraDialogForFileName property string headingStyle property string _searchTerm: "" + property bool _isNewNote: false property string customHeadingOpen property string customHeadingClose property bool underlineHeading // deprecated — kept so existing stored settings still load @@ -66,6 +67,11 @@ QtObject { function handleNewNoteHeadlineHook(headline) { // 'headline' is a plain string (the search term or default text), not a Note object. + // Only this hook fires for a genuine new-note creation, right before + // handleNoteTextFileNameHook — used below to gate the rename/prompt logic + // instead of note.fileCreated, which also reads "Invalid Date" the first + // time a pre-existing, not-yet-indexed note file is edited and saved. + _isNewNote = true; // Strip QOwnNotes search filter prefixes (e.g. "n:" for name-only search). _searchTerm = headline.replace(/^n:/i, ""); var name; @@ -113,11 +119,15 @@ QtObject { return firstLine.slice(2); // ATX: remove "# " } function handleNoteTextFileNameHook(note) { - // right when a note is created, the fileCreated property value is 'Invalid Date' - // this blocks the hook from further changing the note file name if the note title is changed - if (note.fileCreated != "Invalid Date") { + // Only act right after handleNewNoteHeadlineHook fired for THIS note + // creation (see hook order note above). note.fileCreated == "Invalid Date" + // was used previously, but it also reads "Invalid Date" the first time a + // pre-existing, not-yet-indexed note file is edited and saved — which made + // this hook wrongly fire (and prompt) on existing notes (issue #300). + if (!_isNewNote) { return ""; } + _isNewNote = false; // consume the flag: only the note just created gets renamed // Default file name: search term if available, otherwise derived from the title. var defaultName = _searchTerm !== "" ? _searchTerm : extractTitle(note.noteText);