diff options
Diffstat (limited to 'docs/superpowers/specs/2026-10-16-notes-design.md')
| -rw-r--r-- | docs/superpowers/specs/2026-10-16-notes-design.md | 236 |
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. |
