aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-10-16-notes-design.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/specs/2026-10-16-notes-design.md')
-rw-r--r--docs/superpowers/specs/2026-10-16-notes-design.md236
1 files changed, 236 insertions, 0 deletions
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.