// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. import Quickshell import Quickshell.Io import QtQuick import "../.." import "Notes.js" as Notes // A drawer tile and page for bash-notes. The store is notes.sh's own, under // BASEDIR (default ~/.local/share/bash-notes): db.json is the index and // notes/ holds one file per note, named by its creation time in epoch // seconds. The page writes notes in exactly the shape notes.sh reads, so both // tools work on the same notes. // // Git follows notes.sh: pull before the write, then add, commit, push. The one // departure is that a save here is two clicks, so an unconfirmed save leaves // db.json uncommitted, and the pulls therefore carry --autostash. // // A Module is a plain QtObject and has no default property, so the Process and // FileView objects below are declared as typed properties, as UpdatesModule // does for its Process. Module { id: mod name: "notes" label: "Notes" alwaysActive: true // fa-sticky_note, present in Inconsolata Nerd Font. icon: "\uf249" // Resolved from notes.sh's own rc, so a changed BASEDIR moves the page // with it. Empty until the shell below returns. property string baseDir: "" property string dbPath: "" property string notesDir: "" property bool gitEnabled: false property string client: "" // The whole parsed db.json, so a write can replace .notes and carry // params and git through untouched. Every read and write goes through this // rather than through dbView.text(), which is stale right after a reload. property var db: ({ notes: [] }) property bool ready: false property string lastError: "" property string gitOutput: "" property bool syncing: false // So the second click on a brand-new note can name it in the commit. property string lastCreatedId: "" readonly property var notes: Notes.sorted(mod.db.notes) readonly property int count: Array.isArray(mod.db.notes) ? mod.db.notes.length : 0 // One shell, sourcing the rc the way notes.sh sources it, so ${BASEDIR} // expands with ~ and notes.sh's own defaults apply when there is no rc. // Single-quoted JS strings: a template literal would interpolate ${...}. // Output is one field per line: base, db, notes dir, git on, client. property Process cfg: Process { 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) } } function applyConfig(text) { const l = String(text).split("\n"); mod.baseDir = l[0] || ""; mod.dbPath = l[1] || ""; mod.notesDir = l[2] || ""; mod.gitEnabled = (l[3] || "") === "1"; mod.client = l[4] || ""; } // Writing is finished when setText returns. setText is asynchronous unless // blockWrites is set, and its saved()/saveFailed() cannot be relied on to // fire for every write (a second setText to the same path loses the // signal), which would strand a callback and, worse, report a save that // never landed. blockWrites makes the write complete before returning and // saveError carries the outcome. The file is tiny and the write happens on // an explicit click, so the blocking is not felt. property FileView dbView: FileView { path: mod.dbPath atomicWrites: true blockWrites: true watchChanges: true printErrors: false property string saveError: "" onFileChanged: reload() onLoaded: mod.reparse() onLoadFailed: mod.reparse() onSaved: saveError = "" onSaveFailed: error => { console.warn("notes: db.json write failed:", error); saveError = "db.json could not be written"; } } // Nothing to read until the config has resolved, so the load failed on the // empty path does not mark the store ready. An unreadable db.json keeps // the last good parse and says so rather than showing an empty store. function reparse() { if (mod.dbPath === "") return; const v = Notes.valid(mod.dbView.text()); if (v !== null) { mod.db = v; // Once the index is readable again its own complaint is stale. if (mod.lastError === mod.indexError()) mod.lastError = ""; } else { mod.lastError = mod.indexError(); } mod.ready = true; } function indexError() { return "db.json could not be read; resolve it in " + mod.baseDir; } // db.json as it is on disk right now, or null when it cannot be read. // reload() does not block even with blockLoading set, so waitForJob() is // what makes text() the new contents; without it a create would append to // the db.json from before the pull. null is load-bearing: a conflicted // db.json (merge markers an autostash pop left behind) must stop the write, // not be replaced by an empty one. function readDbNow() { mod.dbView.reload(); mod.dbView.waitForJob(); return Notes.valid(mod.dbView.text()); } // db.json, written atomically and blocking, so the outcome is known when // this returns. mod.db is assigned first so the page does not wait for the // watch to come back round. function writeDb(db, done) { mod.db = db; mod.dbView.saveError = ""; mod.dbView.setText(Notes.stringify(db)); const ok = mod.dbView.saveError === ""; if (done) done(ok, ok ? "" : mod.dbView.saveError); } // A note body. notes.sh keeps the title out of the file, so this is the // body and nothing else. setText writes the path directly and needs no // prior load; reading the file back is what would need one, and nothing // here reads it back. property FileView bodyView: FileView { blockWrites: true printErrors: false property string saveError: "" onSaved: saveError = "" onSaveFailed: error => { console.warn("notes: note body write failed:", error); saveError = "the note's file could not be written"; } } function writeBody(path, text, done) { mod.bodyView.path = path; mod.bodyView.saveError = ""; mod.bodyView.setText(text); const ok = mod.bodyView.saveError === ""; if (done) done(ok, ok ? "" : mod.bodyView.saveError); } // The path of a note's body, "" when the entry's file is not a plain // epoch-second name: this path is read, written and rm'd, and db.json may // have come off a remote. function bodyPath(file) { const f = Notes.validFile(file); return f === "" ? "" : mod.notesDir + "/" + f; } function fileIn(db, id) { for (const n of (db.notes || [])) if (n.id === id) return Notes.validFile(n.file); return ""; } // --- git --------------------------------------------------------------- // // Both shells fold stderr into stdout, so one collector holds the reason. onExited must not read the collector directly: its // onStreamFinished can fire after the process exits. The collector stores // the text in mod.gitOutput and onExited reads that and the exit code. // The two processes never run at once, so sharing gitOutput is safe. property Process gitPull: Process { // "create" resumes the create this pull was for; "stamp" is Update list. property string after: "" property string title: "" property string body: "" property var done: null onExited: code => { mod.lastError = code === 0 ? "" : mod.gitOutput; const a = mod.gitPull.after; mod.gitPull.after = ""; if (a === "create") mod.finishCreate(code, mod.gitPull.title, mod.gitPull.body, mod.gitPull.done); else if (a === "stamp") mod.stampPull(code, mod.gitPull.done); } stdout: StdioCollector { onStreamFinished: mod.gitOutput = this.text } } property Process gitSync: Process { property var done: null onExited: code => { mod.syncing = false; mod.lastError = code === 0 ? "" : mod.gitOutput; const d = mod.gitSync.done; mod.gitSync.done = null; if (d) d(code === 0, code === 0 ? "" : mod.gitOutput); } stdout: StdioCollector { onStreamFinished: mod.gitOutput = this.text } } function startPull() { mod.lastError = ""; mod.gitPull.command = ["sh", "-c", 'git -C "$1" pull --rebase --autostash 2>&1', "sh", mod.baseDir]; // Reassigning running on a running Process does nothing, so clear it // first (Status.qml does the same for its breaktimer). mod.gitPull.running = false; mod.gitPull.running = true; } // Update list: pull, then stamp lastpull so notes.sh -l does not pull again // straight away. Stamped only on a pull that worked: claiming to have // pulled after a failure would suppress the CLI's own pull for an hour. function pull(done) { if (!mod.gitEnabled) { if (done) done(false, "git is disabled"); return; } mod.gitPull.done = done; mod.gitPull.after = "stamp"; mod.startPull(); } function stampPull(code, done) { if (code !== 0) { if (done) done(false, mod.gitOutput); return; } const db = mod.readDbNow(); if (db === null) { if (done) done(false, mod.indexError()); return; } mod.writeDb(Notes.setLastpull(db, Math.floor(Date.now() / 1000)), done); } // Create writes the note after the pull, on the db.json the pull brought // in: notes.sh reads the id after its own gitsync for the same reason. function create(title, body, done) { if (!mod.ready) { if (done) done(false, "notes not loaded yet"); return; } if (!mod.gitEnabled) { mod.finishCreate(0, title, body, done); return; } mod.gitPull.title = title; mod.gitPull.body = body; mod.gitPull.done = done; mod.gitPull.after = "create"; mod.startPull(); } function finishCreate(code, title, body, done) { const db = mod.readDbNow(); if (db === null) { if (done) done(false, mod.indexError()); return; } const now = Math.floor(Date.now() / 1000); const file = String(now); // The body first. A body write that fails then leaves no index entry // behind, so the store stays consistent and a retry is not a // duplicate; the other order leaves an entry with no text to show. mod.writeBody(mod.notesDir + "/" + file, body, (ok, msg) => { if (!ok) { if (done) done(false, msg); return; } let next = Notes.addNote(db, title, file); // Only when a pull actually happened: with git off there was none, // and claiming one would suppress notes.sh's own pull later. if (code === 0 && mod.gitEnabled) next = Notes.setLastpull(next, now); mod.lastCreatedId = next.notes[next.notes.length - 1].id; mod.writeDb(next, done); }); // shortcut: second-granularity filenames, as notes.sh takes date +%s, // so two notes in one second would share a file. Upgrade to a fresh // second or a suffix if that is ever seen. } // Edit keeps the file name and the id, replacing the title in db.json and // the body in notes/. notes.sh pulls on add only. function update(id, title, body, done) { if (!mod.ready) { if (done) done(false, "notes not loaded yet"); return; } const db = mod.readDbNow(); if (db === null) { if (done) done(false, mod.indexError()); return; } const file = mod.fileIn(db, id); if (file === "") { if (done) done(false, "note not found"); return; } mod.writeBody(mod.notesDir + "/" + file, body, (ok, msg) => { if (!ok) { if (done) done(false, msg); return; } mod.writeDb(Notes.updateNote(db, id, title), done); }); } // Delete drops the entry and removes the body file. The index goes first: // it is the record, so a failure there must leave the note alone. FileView // has no delete, so the body removal is the module's one filesystem call, // detached, so its failure is not observed; a body left behind reads as a // note without a title, not as a lost note. function remove(id, done) { if (!mod.ready) { if (done) done(false, "notes not loaded yet"); return; } const db = mod.readDbNow(); if (db === null) { if (done) done(false, mod.indexError()); return; } const file = mod.fileIn(db, id); mod.writeDb(Notes.removeNote(db, id), (ok, msg) => { if (!ok) { if (done) done(false, msg); return; } if (file !== "") Quickshell.execDetached(["rm", "--", mod.notesDir + "/" + file]); if (done) done(true, ""); }); } function msg(kind, id) { if (kind === "create") return "quickshell - adding note from " + mod.client; if (kind === "edit") return "quickshell - " + mod.client + " note edited."; return "quickshell - " + mod.client + " removing note ID " + id + "."; } // The second click of Save or Delete: stage everything, commit when there // is something staged, push. No pull here; creating already pulled. // // `exec 2>&1` folds stderr into stdout for the whole chain so the one // collector holds the reason, and `set -e` makes a failing commit (a // pre-commit hook, a signing failure) or push exit non-zero instead of // being reported as a successful sync. "Nothing staged" is tested for // rather than swallowed with `|| true`, which would hide exactly those // failures and push an unchanged HEAD. function commitPush(kind, id, done) { if (!mod.gitEnabled) { if (done) done(true, ""); return; } if (mod.syncing) { if (done) done(false, "a sync is already running"); return; } mod.syncing = true; mod.lastError = ""; mod.gitSync.done = done; mod.gitSync.command = ["sh", "-c", 'set -e\n' + 'exec 2>&1\n' + 'cd "$1"\n' + 'git add .\n' + 'if ! git diff --cached --quiet; then git commit -m "$2"; fi\n' + 'git push origin HEAD\n', "sh", mod.baseDir, mod.msg(kind, id)]; mod.gitSync.running = false; mod.gitSync.running = true; } tileContent: Component { Text { width: parent ? parent.width : implicitWidth 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: Component { Page { id: pg title: "Notes" // 57 is the page header plus its rule, the reading ClipboardModule // takes of the same number. NotesPage { width: parent.width height: pg.height - 57 owner: mod } } } }