1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
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.
|