Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/generate-index.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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)
<script-name>/ ← one directory per script (kebab-case)
<name>.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.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
- 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//` | `<em>` |
| `__underline__` | `<u>` |
| `--strikethrough--` | `<del>` |
| `+ 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.
142 changes: 142 additions & 0 deletions SKILLS.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions new-note-namer/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Empty file modified new-note-namer/README.md
100755 → 100644
Empty file.
2 changes: 1 addition & 1 deletion new-note-namer/info.json
Original file line number Diff line number Diff line change
Expand Up @@ -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. <br><br> Specially useful when the 'Allow note file name to be different to note title. \n No more cumbersome renaming :)"
}
16 changes: 13 additions & 3 deletions new-note-namer/new-note-namer.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading