diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-16 20:33:59 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-16 20:33:59 +0200 |
| commit | aa190a4775d99f205ef29a6c99ec7ab283c76ee8 (patch) | |
| tree | d28cb2cb1a01623dfb24854313cb6c7c3a224768 /docs/superpowers | |
| parent | 2537315f53cb6d0dbc5238245451e85b388ae5c5 (diff) | |
| download | quickshell-aa190a4775d99f205ef29a6c99ec7ab283c76ee8.tar.gz quickshell-aa190a4775d99f205ef29a6c99ec7ab283c76ee8.zip | |
docs: notes drawer module spec and plan
A drawer tile and page for bash-notes: browse, write, edit and delete
notes in the page, sharing notes.sh's store so both tools work on the
same notes.
Git follows notes.sh's shape, pull before write, commit and push after,
with two departures the page needs: an unconfirmed Save leaves db.json
uncommitted (write and commit are two clicks here, one invocation there),
so the pulls carry --autostash, and lastpull is stamped after the pull
rather than before it, which notes.sh does and then ignores the failed
pull that follows.
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/plans/2026-10-16-notes.md | 415 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-10-16-notes-design.md | 236 |
2 files changed, 651 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-16-notes.md b/docs/superpowers/plans/2026-10-16-notes.md new file mode 100644 index 0000000..9c04d53 --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-notes.md @@ -0,0 +1,415 @@ +# 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 <id>` or `notes.sh <id>` 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. diff --git a/docs/superpowers/specs/2026-10-16-notes-design.md b/docs/superpowers/specs/2026-10-16-notes-design.md new file mode 100644 index 0000000..9b460a8 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-notes-design.md @@ -0,0 +1,236 @@ +# Notes module + +A drawer tile and page for `bash-notes`, the shell note taker. Listing, +writing, editing and deleting all happen in the page; the store is the same +one `notes.sh` uses, so both tools work on the same notes. Git sync moves +from "run `notes.sh` and it pushes for you" to a confirm on save. + +The predecessor is `rofi-notes.sh`, which lists notes in rofi and opens one +in a terminal editor. It can list and choose, but writing and reading both +leave the desktop, and nothing shows a note without a pager. + +## The store + +`notes.sh` keeps everything under `BASEDIR`, default +`~/.local/share/bash-notes`: + +- **`db.json`** — the index. `{"params": {...}, "git": {"lastpull": ...}, + "notes": [{"id": "1", "title": "...", "file": "1750000000"}]}`. +- **`notes/`** — one file per note, named by the creation time in epoch + seconds. The file holds the body; the title is only in `db.json`. + +`id`, `title` and `file` are all **strings** in `db.json`. `notes.sh` writes +them with `jq --arg` and looks a note up with `select(.id == $i)`, comparing +string to string, so a number where a string belongs would make the CLI stop +finding a note the page wrote. The page matches: `id` and `file` are written +as strings. + +`notes.sh` lists by iterating `notes/*` and looking each filename up in +`db.json` for its title. The page lists the other way round, from +`db.json.notes`, which is where the titles are; a file with no entry would +not show, and an entry with no file shows an error when its body is read. +The two stay in step because every page write touches both. + +A write replaces only `.notes`; `params` and the rest of `git` round-trip +unchanged, so a store the page writes before `notes.sh` has ever run still has +the shape `firstrun` gives it. `git.lastpull` is the one exception, set +deliberately on a pull (see Git); it is what stops `notes.sh` pulling on every +run after the page has pulled. + +## Configuration + +Paths and git settings come from `notes.sh`'s own config, not from constants, +so a changed `BASEDIR` moves the page with it. At load the module runs one +shell, sourcing the rc and printing what it needs: + + sh -c '[ -r "$HOME/.config/bash-notes.rc" ] && . "$HOME/.config/bash-notes.rc" + base="${BASEDIR:-$HOME/.local/share/bash-notes}" + printf ... "$base" "${DB:-$base/db.json}" "${NOTESDIR:-$base/notes}" ...' + +Sourcing is what makes `${BASEDIR}` in the rc expand the way `notes.sh` +expands it, `~` included. Git is enabled when `USEGIT` is `true` and +`GITREMOTE` is non-empty, the same test `notes.sh` makes. `GITREMOTE`'s +value is never read by the page: the remote is already configured in the +notes repository, so `git push` needs only the directory. `GITCLIENT` is +read for the commit message. + +## Module + +`desktop/modules/notes/NotesModule.qml`, name `notes`, `alwaysActive: true`. + +- **Icon**: `\uf249`, `sticky-note`, present in Inconsolata Nerd Font. +- **Tile line**: the note count, `12 notes`, from `db.json`. The module holds + a `FileView` on `db.json` with `watchChanges`, so the count is right the + moment the grid appears and moves when a note is added from either side. + That is a watch held while the drawer is closed, which the other pages + avoid; here it is the tile's whole content and it is one small file, the + same call mail makes for its unread count. +- **Page**: a `Page` hosting `NotesPage`, `owner: mod`. + +## Page + +`desktop/modules/notes/NotesPage.qml`, UI only, in two states. + +### List + +- A search box, a **New note** button and, when git is on, an **Update list** + button. +- Newest first, each row a title and its date (`dd/mm/yyyy hh:mm`, from the + `file` timestamp, local time). A click opens the note. Case-insensitive + substring search on the title, the same filter the clipboard page uses. +- **Update list** runs the page's pull (`pull --rebase --autostash`, see Git) + and stamps `git.lastpull`, so `notes.sh -l` does not immediately pull + again. The file watch then re-reads `db.json`, and notes pulled from another + machine appear. A failed pull shows git's reason in a line under the button. + +### Editor + +Opened by a row (existing note) or **New note**. + +- A title field and a body, with an **Edit / Preview** toggle. Preview + renders the body as Markdown; Edit shows the raw Markdown in a `TextEdit`. + An existing note opens in Preview, a new one in Edit. +- **Save** is two clicks, so the note is on disk before git is involved: the + first writes it and the button becomes **Commit & push?**; the second runs + the git half (see Git) and returns to the list. Leaving with the second click + unmade keeps the saved note and does not push it. With git off there is no + git half, so Save writes and returns to the list in one click. +- **Cancel** returns to the list without saving; **Delete**, existing notes + only, is two clicks the same way and pushes the removal. +- **Unsaved text is discarded.** Leaving the editor with Cancel, the page's own + back arrow, Escape or a click outside the drawer drops whatever was typed and + not saved. `Page` has no veto over a back, and a drawer that destroys its page + is the clipboard page's own ethos; the save is the explicit act. +- Body `TextEdit`s take the visible height and scroll themselves, as + `ClipboardPage` does, so the `Page`'s outer `ScrollView` never scrolls. + +## Writes + +All index writes go through the module's `db.json` `FileView` with +`atomicWrites: true`, so a write that dies mid-way cannot leave a half +`db.json` for `notes.sh` to trip on. A body is written with a `FileView` on +its own path, which creates the file. + +- **Create**: the first click pulls first (see Git), then re-reads `db.json` + so the append is based on what the pull brought in, not on what was read + before it; `notes.sh` reads the id after its own pull for the same reason. + `file` is the current time in epoch seconds, `id` is the highest existing id + plus one (as a string), and `lastpull` is set to now in the same write. + Write the body to `notes/<file>`, append `{id, title, file}` to + `db.json.notes`. +- **Edit**: replace the title in the entry, rewrite `notes/<file>`. +- **Delete**: drop the entry from `db.json.notes` and `rm -- notes/<file>`. + `FileView` has no delete, so this is the one `execDetached` filesystem + call. + +A save is committed only on the second click, so a note written and not +confirmed stays uncommitted, and so does the `lastpull` stamp **Update list** +writes. That is why the page's pulls carry `--autostash`. + +An id and a `file` colliding with an existing note is not guarded against: +`notes.sh` takes `date +%s` once per run and a run is interactive, so two +notes in one second are as unlikely here as there, and a collision would +overwrite the earlier note's file. `shortcut: second-granularity filenames, +upgrade to a fresh second or a suffix if a collision is ever seen.` + +## Git + +Every git action shells out and shows git's stderr when it fails; the page +never parses git's output. + +The order is `notes.sh`'s: pull before the write, then add, commit, push. The +pull happens on the first click, before anything is written, so it runs on a +clean tree and never needs a rebase. The commit and the push happen on the +second click. + +- **Create**: click 1 `pull --rebase --autostash`, then the write; click 2 + `git add .`, `commit`, `git push origin HEAD`. +- **Edit / Delete**: click 1 writes; click 2 `git add .`, `commit`, + `git push origin HEAD`. `notes.sh` pulls on add only, and the page matches. +- **Update list**: `pull --rebase --autostash`, then stamp `git.lastpull`. + +`--autostash` is there because the page's write and commit are two clicks, so +an unconfirmed save leaves `db.json` uncommitted, and this machine's +`pull.rebase=true` refuses a pull over any unstaged change. With the second +click made, the tree is clean and it does nothing. `notes.sh` has no such +gap: its write and its commit are one invocation, so its tree is always clean +when it pulls. + +The commands run as one `sh -c` chain with the base directory and the message +passed as `$1` and `$2`, never built into the command string, the rule the +clipboard page keeps for its own untrusted text. `git add .` runs from +`BASEDIR`, as `notes.sh` runs it. A second click with nothing staged, an edit +that changed nothing, is not a failure: git has nothing to record and the push +still runs. + +`git.lastpull` is stamped after the pull, not before. `notes.sh` stamps first +and then pulls, which leaves its own `db.json` dirty for the pull it is about +to run, and it ignores the resulting failure; stamping on return keeps the +field truthful without that. The field is what `notes.sh -l` reads to decide +whether to pull again inside `GITSYNCDELAY`, so a page pull that did not stamp +would make every CLI list pull again. + +The commit message follows `notes.sh`'s wording but names the page, so +history says where a change came from: `quickshell - adding note from +<client>`, `quickshell - <client> note edited.`, `quickshell - <client> +removing note ID <id>.` (`<client>` is `GITCLIENT`, defaulting to the +hostname, as in `notes.sh`.) Flip the prefix to `notes.sh - ` if identical +messages matter more than the origin. + +Nothing pulls on open, so the list shows local state until **Update list** is +pressed. `notes.sh`'s own `list` pulls on a timer; the drawer makes it an +explicit click instead. + +## Markdown + +Preview is a read-only `TextEdit` with `textFormat: TextEdit.MarkdownText`, +selectable as the assistant canvas is. Qt's Markdown is a subset: headings, +emphasis, lists, links, inline code and quotes render; tables and fenced-code +highlighting do not. Links open externally, restricted to `http`, `https` +and `mailto` as `Segments.qml` does. The body is the user's own, so it is not +passed through `Notify.sanitize`; a remote image in a note will be fetched +when its preview renders. + +The Markdown body is shown verbatim when editing, so the page is not a WYSIWYG +editor: it is a plain note with a rendered view of it. + +## Files + + desktop/modules/notes/ + NotesModule.qml module, tile count, db.json FileView, writes and git + NotesPage.qml list and editor + Notes.js pure helpers, node-tested + test_notes.js the test + README.md + +`Notes.js` (`.pragma library`) holds the pure parts: parsing the whole +`db.json`, sorting newest first, formatting a timestamp, the next id, and the +transforms that return a new `db.json` object with a note added or removed +(every other key carried through untouched). The module calls them; the file +operations and git stay in QML. + +Registering: `import "modules/notes"` and a `NotesModule {}` in `shell.qml`'s +`modules` list. + +## Testing + +`node desktop/modules/notes/test_notes.js`: + +- Parsing a `db.json` written by `notes.sh`, `params` and `git` wrappers + included, keeps the whole object, not just `notes`. +- Sorting is by `file` descending, numeric, not lexicographic. +- The next id is the highest existing plus one, and `1` for an empty list. +- Adding then removing a note returns a db equal to the original, with + `params` and `git.lastpull` intact, and adding does not mutate the input + object. +- A malformed or truncated `db.json` yields an empty list rather than a + throw, so the page renders "no notes" instead of failing to load. + +The tests must be shown to fail on a broken input before they are trusted. + +## Not in scope + +Opening a note in `$EDITOR`. Restoring backups (`--restore`) or making one +(`--backup`). Markdown export. `--showconf` / `--userconf`. Pulling on open. +Watching `notes/` itself: a file deleted behind the page's back is noticed +when its body is next read, not before. |
