# Notes Module Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a `notes` drawer tile and page that browses, writes, edits and deletes `bash-notes` notes in the drawer, syncing to git on save. **Architecture:** One drawer module (`NotesModule.qml`) that resolves `bash-notes`' own config, owns `db.json` through a watched `FileView`, and exposes CRUD and git; one page (`NotesPage.qml`) with a list state and an editor state; pure helpers in `Notes.js`, tested under node. **Tech Stack:** Quickshell 0.3.1 QML (`Quickshell.Io.FileView` / `Process` / `StdioCollector`), QtQuick (`Text`, `TextEdit` with `TextEdit.MarkdownText`, `ListView`, `TextField`, QtQuick.Controls), node for the helper test. **Spec:** `docs/superpowers/specs/2026-10-16-notes-design.md` ## Global Constraints - GPLv2 only. Every created `.qml` and `.js` file opens with the repo's header notice, copied verbatim from a sibling file (e.g. `desktop/modules/clipboard/Clip.js`). - No home paths in committed files (a gitleaks hook blocks them and has been right every time). Build every path from `Quickshell.env("HOME")`; a literal `~` is fine only in the README. - Colours and fonts come from `Theme` only; no local palette. Glyphs use `Theme.iconFamily`; the tile glyph is `\uf249` (`sticky-note`), verified present in Inconsolata Nerd Font. - `id`, `title` and `file` are stored in `db.json` as **strings**. - File writes go through `FileView` with `atomicWrites: true`. - Component notes go in the module `README.md`, not `AGENTS.md`. - Commits are GPG-signed by the global config; a plain `git commit` is enough. ## Review Focus 1. A `notes` entry with a missing or non-numeric `file` must not throw in sorting or render a bogus date; it still lists and opens. 2. git off (`USEGIT` not `true`, or empty `GITREMOTE`): Save writes and returns in one click, Update list is absent, and no git `Process` runs. 3. An unconfirmed Save (second click never made), then another create or Update list: the pull must not be blocked by the uncommitted `db.json`. 4. Leaving the editor with unsaved text (Cancel, back arrow, Escape, click outside): the draft is dropped and no partial note is written. 5. Opening a note whose body file is missing: an error line shows; nothing crashes and nothing is overwritten. --- ### Task 1: Pure `db.json` helpers **Files:** - Create: `desktop/modules/notes/Notes.js` - Test: `desktop/modules/notes/test_notes.js` **Interfaces:** - Produces, all pure, `.pragma library`: `parse(text) -> object`, `stringify(db) -> string`, `sorted(notes) -> array`, `formatDate(file) -> string`, `nextId(notes) -> string`, `filter(notes, query) -> array`, `addNote(db, title, file) -> object`, `updateNote(db, id, title) -> object`, `removeNote(db, id) -> object`, `setLastpull(db, now) -> object`. - `parse` returns the whole object, `{ notes: [] }` for invalid JSON, a non-object, or a missing `notes` array. - `sorted` copies (never reorders the input), sorts numeric on `file` descending, and puts unusable entries last in input order. - `addNote`, `updateNote`, `removeNote`, `setLastpull` return a new object via spread and never mutate the input; every key other than the one they change carries through. - [ ] **Step 1: Write the failing test** `test_notes.js`, in the style of `desktop/modules/clipboard/test_clip.js`: strip the pragma, evaluate with `new Function(src + "\nreturn { ... };")()`, `assert`, end with `console.log("test_notes: ok")`. ```js const db = { params: { version: "0.4git", dbversion: "0.4git_1" }, git: { lastpull: "1700000000" }, notes: [ { id: "1", title: "a", file: "999" }, { id: "2", title: "b", file: "1000" }, { id: "3", title: "c", file: "300" }, ], }; // numeric, not lexicographic: 1000 > 999 > 300 assert.deepStrictEqual(N.sorted(db.notes).map(n => n.id), ["2", "1", "3"]); assert.strictEqual(db.notes[0].id, "1"); // input not reordered // a missing or non-numeric file sorts last, input order, and formats as "" const bad = [{ id: "1", title: "x" }, { id: "2", title: "y", file: "abc" }, { id: "3", title: "z", file: "10" }]; assert.deepStrictEqual(N.sorted(bad).map(n => n.id), ["3", "1", "2"]); assert.strictEqual(N.formatDate(undefined), ""); assert.strictEqual(N.formatDate("abc"), ""); assert.match(N.formatDate("1000"), /^\d{2}\/\d{2}\/\d{4} \d{2}:\d{2}$/); assert.strictEqual(N.nextId(db.notes), "4"); assert.strictEqual(N.nextId([]), "1"); // the whole object round-trips; params and git are kept assert.deepStrictEqual(N.parse(N.stringify(db)), db); assert.deepStrictEqual(N.parse("not json"), { notes: [] }); assert.deepStrictEqual(N.parse('{"a":1}'), { notes: [] }); // pure transforms preserve everything else and do not mutate const added = N.addNote(db, "T", "1750"); assert.strictEqual(added.notes.length, 4); assert.deepStrictEqual(added.notes[3], { id: "4", title: "T", file: "1750" }); assert.strictEqual(db.notes.length, 3); assert.deepStrictEqual(added.params, db.params); assert.deepStrictEqual(added.git, db.git); assert.deepStrictEqual(N.removeNote(added, "4"), db); assert.strictEqual(N.updateNote(db, "2", "Renamed").notes[1].title, "Renamed"); assert.deepStrictEqual(N.updateNote(db, "99", "x"), db); assert.strictEqual(N.setLastpull(db, 1234).git.lastpull, "1234"); assert.strictEqual(db.git.lastpull, "1700000000"); assert.deepStrictEqual(N.filter(db.notes, " A ").map(n => n.id), ["1"]); assert.strictEqual(N.filter(db.notes, "").length, 3); ``` - [ ] **Step 2: Run it, watch it fail** Run: `node desktop/modules/notes/test_notes.js` Expected: FAIL, `Cannot find module .../Notes.js` (or `parse is not a function`). - [ ] **Step 3: Implement `Notes.js`** Write the ten functions to the signatures above. `parse` guards with try/catch and checks the object shape; `sorted` uses `Number(n.file)` with `NaN` pushed behind finite values; `stringify` is `JSON.stringify(db, null, 2) + "\n"`; the transforms spread `db` and its `notes`/`git`. - [ ] **Step 4: Run it, watch it pass** Run: `node desktop/modules/notes/test_notes.js` Expected: `test_notes: ok` - [ ] **Step 5: Commit** ```bash git add desktop/modules/notes/Notes.js desktop/modules/notes/test_notes.js git commit -m "notes: pure db.json helpers, node-tested" ``` --- ### Task 2: Module and read-only list page **Files:** - Create: `desktop/modules/notes/NotesModule.qml` - Create: `desktop/modules/notes/NotesPage.qml` (list state only) - Modify: `desktop/shell.qml` (add `import "modules/notes"` with the other module imports, and `NotesModule {}` in the `modules` list) **Interfaces:** - Consumes: `Notes.js` (Task 1). - Produces, on the module (`id: mod`): `baseDir`, `dbPath`, `notesDir` (strings), `gitEnabled` (bool), `client` (string), `ready` (bool), `db` (object), `notes` (array, sorted), `count` (int), `lastError` (string), `reparse()`. - Produces `NotesPage`: a required `property QtObject owner`. - [ ] **Step 1: Write `NotesModule.qml`** `Module { id: mod; name: "notes"; label: "Notes"; alwaysActive: true }` in `desktop/modules/notes/`, `import "../.."` and `import "Notes.js" as Notes`. Resolve config with one Process, then let the `db.json` `FileView` load. The shell is built from **single-quoted** JS strings so `${...}` is not interpolated by the JS engine: ```qml Process { id: cfg running: true command: ["sh", "-c", '[ -r "$HOME/.config/bash-notes.rc" ] && . "$HOME/.config/bash-notes.rc"\n' + 'base="${BASEDIR:-$HOME/.local/share/bash-notes}"\n' + 'git=0; [ "${USEGIT:-false}" = "true" ] && [ -n "${GITREMOTE:-}" ] && git=1\n' + 'printf "%s\\n" "$base" "${DB:-$base/db.json}" "${NOTESDIR:-$base/notes}" "$git" "${GITCLIENT:-$(hostname)}"'] stdout: StdioCollector { onStreamFinished: mod.applyConfig(this.text) } } ``` `applyConfig(text)` splits on `\n` into `baseDir`, `dbPath`, `notesDir`, `gitEnabled` (`"1"`), `client`. ```qml FileView { id: dbView path: mod.dbPath atomicWrites: true watchChanges: true printErrors: false onFileChanged: mod.reparse() onLoaded: mod.reparse() onLoadFailed: if (mod.dbPath !== "") mod.reparse() // missing store reads as empty } ``` `reparse()` sets `mod.db = Notes.parse(dbView.text())` and `mod.ready = true` (but does nothing while `dbPath === ""`, so `ready` only turns true after config resolves). `count` reads `Array.isArray(mod.db.notes) ? mod.db.notes.length : 0`; `notes` is `Notes.sorted(mod.db.notes ?? [])`. `lastError` starts `""`. Tile, mirroring `MailTile`'s shape inline (the `mod` id resolves inside a `Component` declared in this file, as `gm` does in `GitModule.qml`): ```qml tileContent: Component { Text { elide: Text.ElideRight font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } color: Theme.subtext text: !mod.ready ? "…" : mod.count === 0 ? "empty" : mod.count === 1 ? "1 note" : mod.count + " notes" } } ``` Page: `page: Component { Page { title: "Notes"; NotesPage { width: parent.width; height: parent.height - 57; owner: mod } } }` — the `- 57` is the header plus its rule, the same arithmetic `ClipboardModule` uses. - [ ] **Step 2: Write `NotesPage.qml`, list state** `Item { id: page; required property QtObject owner }`, `import "../.."` and `import "Notes.js" as Notes`. Layout mirrors `ClipboardPage`: - A top `Row` with a `TextField` (`id: search`, placeholder `Search`, `Component.onCompleted: forceActiveFocus()`, the same styled `background` Rectangle as the clipboard search field). - Below it a `ListView` bound to `Notes.filter(page.owner.notes, search.text)`, `clip: true`, `spacing: 4`, `ScrollBar.vertical: ScrollBar {}`, an empty-state `Text` reading `page.owner.ready ? "No notes" : "Loading…"`. - Each row is a `Rectangle` (height 36, radius 8, the clipboard row's hover colour) holding the title (elided, `Theme.text`) and the date `Notes.formatDate(modelData.file)` (right-aligned, `Theme.overlay`), with a `MouseArea onClicked` left as a no-op for this task. - A `Text` under the list bound to `page.owner.lastError`, `visible: page.owner.lastError !== ""`, `color: Theme.red` (unused until Task 3, but the binding is set now). - [ ] **Step 3: Register** In `desktop/shell.qml`, add `import "modules/notes"` alongside the other module imports and `NotesModule {},` in the `modules:` list. - [ ] **Step 4: Verify** From your own terminal (not an agent tool call: a detached `qs` dies with the call, see `AGENTS.md`) run `./reload.sh desktop`. The drawer's grid gains a Notes tile reading `N notes` or `empty`, matching the count in `~/.local/share/bash-notes/db.json`. Opening it lists the notes newest first with their dates, and typing filters by title. A new module directory also needs the import change in `shell.qml` to be seen at all; if the tile does not appear, restart the shell. - [ ] **Step 5: Commit** ```bash git add desktop/modules/notes/NotesModule.qml desktop/modules/notes/NotesPage.qml desktop/shell.qml git commit -m "notes: drawer tile with count and the note list" ``` --- ### Task 3: Editor and the write/git path **Files:** - Modify: `desktop/modules/notes/NotesModule.qml` - Modify: `desktop/modules/notes/NotesPage.qml` **Interfaces:** - Module adds: `create(title, body, done)`, `update(id, title, body, done)`, `remove(id, done)`, `pull(done)`, `commitPush(kind, id, done)` (`kind` is `"create"`/`"edit"`/`"delete"`; the module builds the message from `kind` and `client`), `writeDb(db)`, `writeBody(path, text, done)`; `lastCreatedId` (string); `gitOutput` (string); `syncing` (bool) while a git command runs. Each `done(ok, message)` is optional. - Page adds: `view` (`"list"` | `"editor"`), `editing` (the note object or `null`), and the editor controls. - [ ] **Step 1: Add the write path to the module** `db.json` writes reuse the existing `dbView`: `mod.writeDb(db)` is `dbView.setText(Notes.stringify(db))`. Body writes use a second `FileView` with a pending-write, because `setText` on a `FileView` that has not completed a load of the target path writes empty (`appearance/Icons.qml` documents this): ```qml FileView { id: bodyWrite printErrors: false property var pending: null function queue(path, text, done) { bodyWrite.pending = { path: path, text: text, done: done }; bodyWrite.path = path; // retarget; load attempt follows } function commit() { if (bodyWrite.pending === null) return; const p = bodyWrite.pending; bodyWrite.pending = null; bodyWrite.setText(p.text); if (p.done) p.done(true, ""); } onLoaded: commit() onLoadFailed: commit() } ``` After `setText` the file watch fires `onLoaded` again; `pending` is null, so `commit` does nothing (the self-write rule `Status.qml` keeps). - [ ] **Step 2: Add the git path to the module** Two `Process`es. A git command is retriggered with `running = false` then `running = true`, never a bare reassignment (`AGENTS.md`, `Status.qml`). ```qml Process { id: gitPull property string after: "" // "create" | "stamp" property string title: "" property string body: "" property var done: null onExited: code => { mod.lastError = code === 0 ? "" : mod.gitOutput; const a = gitPull.after; gitPull.after = ""; if (a === "create") mod.finishCreate(code, gitPull.title, gitPull.body, gitPull.done); else if (a === "stamp") mod.stampPull(gitPull.done); } stdout: StdioCollector { onStreamFinished: mod.gitOutput = this.text } } Process { id: gitSync property var done: null onExited: code => { mod.syncing = false; mod.lastError = code === 0 ? "" : mod.gitOutput; if (gitSync.done) { const d = gitSync.done; gitSync.done = null; d(code === 0); } } stdout: StdioCollector { onStreamFinished: mod.gitOutput = this.text } } ``` `mod.gitOutput` is shared by both, which is safe because the two never run at once (a second click while `syncing` is ignored). Chains, exactly as the spec's Git section: - Pull: `gitPull.command = ["sh", "-c", 'git -C "$1" pull --rebase --autostash 2>&1', "sh", mod.baseDir]` (the directory is `$1`, never in the command string), with `stdout: StdioCollector { onStreamFinished: mod.gitOutput = this.text }`. - Sync: `gitSync.command = ["sh", "-c", 'git -C "$1" add . && { git -C "$1" commit -m "$2" || true; } && git -C "$1" push origin HEAD 2>&1', "sh", mod.baseDir, message]` — the message is `$2`, never inside the command string. Both fold stderr into stdout with `2>&1` so one collector holds the reason. **`StdioCollector.onStreamFinished` can fire after `Process.onExited`**, so `onExited` must not assume the text is ready: the collector stores it in `mod.gitOutput`, and `onExited` reads that property (and the exit code) rather than the collector directly. Show `mod.gitOutput` in `lastError` on a non-zero code. Messages come from one `msg(kind, id)` in the module: create `"quickshell - adding note from " + mod.client`; edit `"quickshell - " + mod.client + " note edited."`; delete `"quickshell - " + mod.client + " removing note ID " + id + "."` `pull(done)` sets `gitPull.after = "stamp"` and starts; `stampPull()` reloads `dbView`, writes `Notes.setLastpull(Notes.parse(dbView.text()), Math.floor(Date.now()/1000))`, then calls `done`. `create(title, body, done)`: if `!mod.gitEnabled`, call `finishCreate(0, title, body, done)` directly. Otherwise stash `title`/`body`/`done` on `gitPull`, set `after = "create"`, start it. `finishCreate(code, title, body, done)` **re-reads** `dbView` after the pull: ```js dbView.reload(); const db = Notes.parse(dbView.text()); const now = Math.floor(Date.now() / 1000); const file = String(now); const next = Notes.setLastpull(Notes.addNote(db, title, file), now); mod.lastCreatedId = next.notes[next.notes.length - 1].id; mod.writeDb(next); mod.writeBody(mod.notesDir + "/" + file, body, () => { if (done) done(true, ""); }); ``` `update(id, title, body, done)`: no pull. `mod.writeDb(Notes.updateNote(Notes.parse(dbView.text()), id, title))`, then `mod.writeBody(mod.notesDir + "/" + fileFor(id), body, done)` (`fileFor` scans `mod.notes`). `remove(id, done)`: no pull. `mod.writeDb(Notes.removeNote(...))`, then `Quickshell.execDetached(["rm", "--", mod.notesDir + "/" + fileFor(id)])` (the spec's one filesystem call, `FileView` having no delete), then `done`. `commitPush(kind, id, done)`: ignore the click while `mod.syncing`; set `mod.syncing = true`; set `gitSync.done = done`; set `gitSync.command` with the `msg(kind, id)` message as `$2`; retrigger. `finishCreate` also records the new note's id in `mod.lastCreatedId` (the last entry `Notes.addNote` appended), so a second click on a brand-new note can name it. - [ ] **Step 3: Add the editor to the page** `view` starts `"list"`. A row click calls `open(note)`: ```js function open(note) { // note === null for New note page.editing = note; page.titleText = note ? note.title : ""; page.body = ""; page.preview = note !== null; page.view = "editor"; } ``` Body read: one more `FileView` in the page, `path: page.editing ? page.owner.notesDir + "/" + page.editing.file : ""`, `onLoaded: page.body = text()`, `onLoadFailed: { page.body = ""; page.bodyError = page.editing ? "note file missing" : ""; }`. Controls, all reusing `desktop/Button.qml`: - List header `Row`: the search field, **New note** (`page.open(null)`), and, `visible: page.owner.gitEnabled`, **Update list** → `page.owner.pull()` (the button text becomes `Updated` for 2s on success, or `page.owner.lastError` shows under it). - Editor: a `Cancel` button returning to `"list"`; a title `TextField` bound to `page.titleText`; an **Edit / Preview** pair of buttons toggling `page.preview` (a two-button segmented look: the active one accents); the body as a `TextEdit` (`readOnly: page.preview`, `textFormat: page.preview ? TextEdit.MarkdownText : TextEdit.PlainText`, `wrapMode: TextEdit.Wrap`, `selectByMouse: true`, `onLinkActivated` restricted to `http`/`https`/`mailto` as `Segments.qml` does) with a `Text` in front of it when `page.bodyError !== ""`. - The body `TextEdit` height: the page's `height` minus the header controls, as `ClipboardPage` sizes itself, so the `Page`'s outer `ScrollView` never scrolls. Save is two clicks with a `Timer` for the armed state, the shape `ClipboardPage`'s "Clear all" uses. The first click sends only the local write; the second only runs git. ```js function save() { page.owner.lastError = ""; if (!confirm.running) { const done = (ok, msg) => { if (!ok) page.owner.lastError = msg; }; if (page.editing) page.owner.update(page.editing.id, page.titleText, page.body, done); else page.owner.create(page.titleText, page.body, done); if (!page.owner.gitEnabled) { page.view = "list"; return; } // one click, git off confirm.start(); return; } confirm.stop(); const kind = page.editing ? "edit" : "create"; const id = page.editing ? page.editing.id : page.owner.lastCreatedId; page.owner.commitPush(kind, id, () => { page.view = "list"; }); } ``` (`lastCreatedId` is set by `finishCreate`, so the second click on a brand-new note can name it.) Delete, existing notes only, is the same two clicks: the first calls `page.owner.remove(page.editing.id, done)`, the second `page.owner.commitPush("delete", page.editing.id, () => page.view = "list")`. With git off the first click removes and returns to the list. - [ ] **Step 4: Verify** Reload with `./reload.sh desktop` from your terminal, then check, in order: 1. **New note**: Edit mode, typed title and body; first click reads `Commit & push?`, second commits — `git -C ~/.local/share/bash-notes log -1 --stat` shows the note file and `db.json` in one commit with the `quickshell - adding note from …` message. 2. **Round trip with the CLI**: `notes.sh -l` lists the note you wrote; `notes.sh -p ` or `notes.sh ` shows its body. 3. **A CLI note opens here**: create one with `notes.sh -a "cli note"`, then open it in the drawer, confirm Preview renders it and the row dates sort right. 4. **git off**: set `USEGIT=false` in the rc (or empty `GITREMOTE`), reload, and confirm Save is one click and Update list is gone, then restore the rc. 5. **Unconfirmed save then pull**: write a note, click Save once, do not confirm, then press Update list — it must complete, not report `cannot pull with rebase`. 6. **Unsaved edits dropped**: type into the editor, click Cancel (and separately Escape), and confirm the list is unchanged and no new file appeared in `notes/`. 7. **Missing body**: `rm` one note's body file behind the page's back, open its row, and confirm the error line shows rather than a crash. - [ ] **Step 5: Commit** ```bash git add desktop/modules/notes/NotesModule.qml desktop/modules/notes/NotesPage.qml git commit -m "notes: in-drawer editor with commit-on-save and git pull" ``` --- ### Task 4: Component README **Files:** - Create: `desktop/modules/notes/README.md` **Interfaces:** - Consumes: everything above; nothing consumes it. - [ ] **Step 1: Write the README** Mirror `desktop/modules/clipboard/README.md`'s shape: what the module is, that the store is `notes.sh`'s own under `BASEDIR` and both tools share it, the two-click Save and the git order and why it differs from `notes.sh` (the page's write and commit are two clicks, hence `--autostash`), that `lastpull` is stamped after the pull, that unsaved text is discarded on leaving, and the node test line `node desktop/modules/notes/test_notes.js`. Note the endpoint of the spec's "Not in scope": no `$EDITOR`, no backup/restore, no export, no pull on open. - [ ] **Step 2: Commit** ```bash git add desktop/modules/notes/README.md git commit -m "notes: component README" ``` --- ## Self-Review **Spec coverage:** store and string ids (Task 1, `parse`/`addNote`); config sourcing (Task 2); icon, tile count, module (Task 2); list, search, New, Update list (Tasks 2–3); editor, Preview/Edit, Markdown, Cancel (Task 3); writes and the create pull-then-write (Task 3); git order, messages, `lastpull` (Task 3); files and registration (Tasks 1–3); testing (Task 1); README (Task 4). Nothing in the spec is unowned. **Known looseness, deliberately left to the implementer:** the exact pixel layout of the editor and the segmented Edit/Preview toggle (follow `ClipboardPage` and `desktop/Switch.qml`). **Type consistency:** `Notes.*` names are used identically in every task; `owner` is the module's id `mod` throughout; `writeBody`/`writeDb` are the module's only write entry points.