diff options
Diffstat (limited to 'desktop')
| -rw-r--r-- | desktop/modules/notes/Notes.js | 23 | ||||
| -rw-r--r-- | desktop/modules/notes/NotesModule.qml | 211 | ||||
| -rw-r--r-- | desktop/modules/notes/NotesPage.qml | 382 | ||||
| -rw-r--r-- | desktop/modules/notes/test_notes.js | 14 |
4 files changed, 555 insertions, 75 deletions
diff --git a/desktop/modules/notes/Notes.js b/desktop/modules/notes/Notes.js index 76c8f95..6bc8c88 100644 --- a/desktop/modules/notes/Notes.js +++ b/desktop/modules/notes/Notes.js @@ -22,17 +22,28 @@ function notesOf(db) { return (db && Array.isArray(db.notes)) ? db.notes : []; } -// db.json, whole. Anything that is not an object carrying a notes array reads -// as an empty store, so a truncated or hand-edited file shows "no notes" -// rather than failing to load the page. -function parse(text) { +// The db object, or null when the text cannot be read as one. An empty or +// missing file is an empty store, not an error. Anything else that does not +// parse, or parses to something without a notes array, is null: a writer must +// refuse on null rather than replace a file it did not understand, which is +// what a conflicted db.json (merge markers from an autostash pop) is. +function valid(text) { + const t = String(text === undefined || text === null ? "" : text); + if (t.trim() === "") return { notes: [] }; let v = null; - try { v = JSON.parse(text); } catch (e) { v = null; } + try { v = JSON.parse(t); } catch (e) { v = null; } if (!v || typeof v !== "object" || Array.isArray(v) || !Array.isArray(v.notes)) - return { notes: [] }; + return null; return v; } +// db.json for display: the object, or an empty store when nothing readable is +// there, so a truncated or hand-edited file shows "no notes" rather than +// failing to load the page. +function parse(text) { + return valid(text) || { notes: [] }; +} + // Pretty, 2-space, trailing newline. notes.sh reads db.json through jq, which // does not care about the layout. function stringify(db) { diff --git a/desktop/modules/notes/NotesModule.qml b/desktop/modules/notes/NotesModule.qml index a2d54e5..94ef41e 100644 --- a/desktop/modules/notes/NotesModule.qml +++ b/desktop/modules/notes/NotesModule.qml @@ -9,6 +9,7 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. +import Quickshell import Quickshell.Io import QtQuick import "../.." @@ -20,9 +21,13 @@ import "Notes.js" as Notes // 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 -// the FileView below are declared as typed properties, as UpdatesModule does -// for its Process. +// FileView objects below are declared as typed properties, as UpdatesModule +// does for its Process. Module { id: mod @@ -42,10 +47,15 @@ Module { property string client: "" // The whole parsed db.json, so a write can replace .notes and carry - // params and git through untouched. + // 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 @@ -76,24 +86,213 @@ Module { // The store, watched rather than read on open: the tile's count has to // move when a note is added from either side, the CLI included. A missing // file is an empty store, not an error worth logging. + // + // fileChanged means the file was replaced, and text() still holds the old + // contents until the reload lands, which is why the reparse lives on + // onLoaded and not on fileChanged (the same split Prefs.qml uses). property FileView dbView: FileView { path: mod.dbPath atomicWrites: true watchChanges: true printErrors: false - onFileChanged: mod.reparse() + onFileChanged: reload() onLoaded: mod.reparse() onLoadFailed: mod.reparse() } // Nothing to read until the config has resolved, so the load failed on the - // empty path does not mark the store ready. + // 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; - mod.db = Notes.parse(mod.dbView.text()); + const v = Notes.valid(mod.dbView.text()); + if (v !== null) mod.db = v; + 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. mod.db is assigned first so the page does + // not wait for the watch to come back round. + function writeDb(db) { + mod.db = db; + mod.dbView.setText(Notes.stringify(db)); + } + + // 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 { + printErrors: false + } + + function writeBody(path, text, done) { + mod.bodyView.path = path; + mod.bodyView.setText(text); + if (done) done(true, ""); + } + + function fileIn(db, id) { + for (const n of (db.notes || [])) if (n.id === id) return n.file || ""; + return ""; + } + + // --- git --------------------------------------------------------------- + // + // Both shells fold stderr into stdout with 2>&1, 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))); + if (done) done(true, ""); + } + + // 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); + 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); + mod.writeBody(mod.notesDir + "/" + file, body, () => { if (done) done(true, ""); }); + // 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/<file>. 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.writeDb(Notes.updateNote(db, id, title)); + mod.writeBody(mod.notesDir + "/" + file, body, () => { if (done) done(true, ""); }); + } + + // Delete drops the entry and removes the body file. FileView has no + // delete, so this is the module's one filesystem call. + 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)); + 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: add everything, commit (a commit + // with nothing staged is not a failure), push. No pull here; creating + // already pulled. + function commitPush(kind, id, done) { + if (!mod.gitEnabled) { if (done) done(true, ""); return; } + if (mod.syncing) return; + mod.syncing = true; + mod.lastError = ""; + mod.gitSync.done = done; + mod.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, mod.msg(kind, id)]; + mod.gitSync.running = false; + mod.gitSync.running = true; + } + tileContent: Component { Text { width: parent ? parent.width : implicitWidth diff --git a/desktop/modules/notes/NotesPage.qml b/desktop/modules/notes/NotesPage.qml index d73bea2..a3d11f6 100644 --- a/desktop/modules/notes/NotesPage.qml +++ b/desktop/modules/notes/NotesPage.qml @@ -9,27 +9,148 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. +import Quickshell.Io import QtQuick import QtQuick.Controls import "../.." import "Notes.js" as Notes -// The notes list, newest first, with a title search. Reads the module's parsed -// db.json; nothing here touches the store or git. +// The notes page: a list, and an editor over it. +// +// Save is two clicks and each click does one thing. The first writes the note +// and, when that has finished, arms the button as "Commit & push?"; the second +// commits and pushes and returns to the list. Arming the second click only +// after the write completes is what stops a quick second click from committing +// a note the first click has not written yet. +// +// Nothing is remembered between visits: leaving the editor with text that was +// not saved drops it, which is the clipboard page's own rule. Item { id: page - // The notes module: the store, the resolved paths and the writes. + // The notes module: the store, the resolved paths, the writes and git. required property QtObject owner - Row { - id: bar - anchors { top: parent.top; left: parent.left; right: parent.right } - spacing: 8 + property string view: "list" // "list" | "editor" + property var editing: null // the note being edited, null for new + property bool preview: false // editor shows rendered Markdown + property string bodyError: "" + // A write or git command is in flight; the buttons stand down meanwhile. + property bool busy: false + + function open(note) { + page.bodyError = ""; + page.preview = note !== null; + confirm.stop(); + confirmDel.stop(); + titleField.text = note ? note.title : ""; + // Clear before the assignment below: that assignment is what retargets + // the body FileView, and its load may land synchronously, so clearing + // after would wipe the body it just read. + bodyEdit.text = ""; + page.view = "editor"; + page.editing = note; + Qt.callLater(() => titleField.forceActiveFocus()); + } + + function toList() { + confirm.stop(); + confirmDel.stop(); + page.busy = false; + page.editing = null; + page.bodyError = ""; + page.view = "list"; + } + + function save() { + if (page.busy) return; + const done = (ok, msg) => { + page.busy = false; + if (!ok) { page.owner.lastError = msg; return; } + // Git off: there is no second half, so this was the whole save. + if (!page.owner.gitEnabled) { page.toList(); return; } + confirm.start(); + }; + if (confirm.running) { + confirm.stop(); + const kind = page.editing ? "edit" : "create"; + const id = page.editing ? page.editing.id : page.owner.lastCreatedId; + page.busy = true; + page.owner.commitPush(kind, id, (ok, msg) => { + page.busy = false; + if (!ok) page.owner.lastError = msg; + page.toList(); + }); + return; + } + page.owner.lastError = ""; + page.busy = true; + if (page.editing) page.owner.update(page.editing.id, titleField.text, bodyEdit.text, done); + else page.owner.create(titleField.text, bodyEdit.text, done); + } + + function remove() { + if (page.busy || page.editing === null) return; + const id = page.editing.id; + const done = (ok, msg) => { + page.busy = false; + if (!ok) { page.owner.lastError = msg; return; } + if (!page.owner.gitEnabled) { page.toList(); return; } + confirmDel.start(); + }; + if (confirmDel.running) { + confirmDel.stop(); + page.busy = true; + page.owner.commitPush("delete", id, (ok, msg) => { + page.busy = false; + if (!ok) page.owner.lastError = msg; + page.toList(); + }); + return; + } + page.owner.lastError = ""; + page.busy = true; + page.owner.remove(id, done); + } + + function updateList() { + if (page.busy) return; + page.owner.lastError = ""; + page.busy = true; + page.owner.pull((ok, msg) => { + page.busy = false; + if (ok) updated.restart(); else page.owner.lastError = msg; + }); + } + + // The open note's body. Nothing watches it: it is read when the editor + // opens and written on save. + FileView { + id: bodyRead + path: (page.view === "editor" && page.editing) + ? page.owner.notesDir + "/" + page.editing.file : "" + printErrors: false + onLoaded: { bodyEdit.text = text(); page.bodyError = ""; } + onLoadFailed: { + bodyEdit.text = ""; + page.bodyError = page.editing ? "the note's file is missing" : ""; + } + } + + Timer { id: confirm; interval: 3000 } + Timer { id: confirmDel; interval: 3000 } + Timer { id: updated; interval: 2000 } + + // --- list -------------------------------------------------------------- + + Item { + id: listView + anchors.fill: parent + visible: page.view === "list" TextField { id: search - width: parent.width + anchors { top: parent.top; left: parent.left; right: newBtn.left; rightMargin: 8 } height: 32 placeholderText: "Search" color: Theme.text @@ -40,82 +161,219 @@ Item { border.width: 1 border.color: Qt.alpha(Theme.text, 0.15) } - Component.onCompleted: forceActiveFocus() onTextChanged: list.currentIndex = 0 } - } - ListView { - id: list - anchors { - top: bar.bottom; topMargin: 10 - left: parent.left; right: parent.right - bottom: errorLine.top; bottomMargin: 6 + Button { + id: updateBtn + anchors { top: parent.top; right: parent.right } + visible: page.owner.gitEnabled + enabled: !page.busy + text: updated.running ? "Updated" : "Update list" + onClicked: page.updateList() + } + + Button { + id: newBtn + anchors { top: parent.top; right: updateBtn.visible ? updateBtn.left : parent.right + rightMargin: updateBtn.visible ? 8 : 0 } + enabled: page.owner.ready + text: "New note" + onClicked: page.open(null) } - clip: true - spacing: 4 - model: Notes.filter(page.owner.notes, search.text) - boundsBehavior: Flickable.StopAtBounds - ScrollBar.vertical: ScrollBar {} Text { - anchors.centerIn: parent + id: listEmpty + anchors.centerIn: list visible: list.count === 0 text: page.owner.ready ? "No notes" : "Loading…" color: Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } } - delegate: Rectangle { - id: row - required property var modelData - required property int index + ListView { + id: list + anchors { + top: search.bottom; topMargin: 10 + left: parent.left; right: parent.right + bottom: listError.top; bottomMargin: 6 + } + clip: true + spacing: 4 + model: Notes.filter(page.owner.notes, search.text) + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar {} - width: list.width - height: 36 - radius: 8 - color: rowArea.containsMouse || ListView.isCurrentItem - ? Qt.alpha(Theme.accent, 0.18) : Qt.alpha(Theme.surface, 0.35) + delegate: Rectangle { + id: row + required property var modelData + required property int index - MouseArea { - id: rowArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - } + width: list.width + height: 36 + radius: 8 + color: rowArea.containsMouse || ListView.isCurrentItem + ? Qt.alpha(Theme.accent, 0.18) : Qt.alpha(Theme.surface, 0.35) - Text { - anchors { - left: parent.left; leftMargin: 10 - right: when.left; rightMargin: 8 - verticalCenter: parent.verticalCenter + MouseArea { + id: rowArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: page.open(row.modelData) } - elide: Text.ElideRight - maximumLineCount: 1 - textFormat: Text.PlainText - text: row.modelData.title - color: Theme.text - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + + Text { + anchors { + left: parent.left; leftMargin: 10 + right: when.left; rightMargin: 8 + verticalCenter: parent.verticalCenter + } + elide: Text.ElideRight + maximumLineCount: 1 + textFormat: Text.PlainText + text: row.modelData.title + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + } + + Text { + id: when + anchors { right: parent.right; rightMargin: 10; verticalCenter: parent.verticalCenter } + text: Notes.formatDate(row.modelData.file) + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + } + } + + Text { + id: listError + anchors { left: parent.left; right: parent.right; bottom: parent.bottom } + height: visible ? implicitHeight : 0 + visible: page.owner.lastError !== "" + text: page.owner.lastError + wrapMode: Text.WordWrap + color: Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + } + + // --- editor ------------------------------------------------------------ + + Item { + id: editorView + anchors.fill: parent + visible: page.view === "editor" + + Button { + id: cancelBtn + anchors { top: parent.top; left: parent.left } + text: "Cancel" + onClicked: page.toList() + } + + Button { + id: saveBtn + anchors { top: parent.top; right: parent.right } + enabled: !page.busy || confirm.running + text: page.busy && !confirm.running ? "Saving…" + : confirm.running ? "Commit & push?" : "Save" + onClicked: page.save() + } + + Button { + id: delBtn + anchors { top: parent.top; right: saveBtn.left; rightMargin: 8 } + visible: page.editing !== null + danger: true + enabled: !page.busy || confirmDel.running + text: confirmDel.running ? "Confirm?" : "Delete" + onClicked: page.remove() + } + + // Edit (raw Markdown) on the left, Preview (rendered) on the right. + // The switch that owns the state is Switch.qml, as elsewhere. + Item { + id: previewToggle + anchors { + top: parent.top; right: delBtn.visible ? delBtn.left : saveBtn.left + rightMargin: 12 } + width: prevLabel.implicitWidth + 8 + prevSwitch.implicitWidth + height: Math.max(prevLabel.implicitHeight, prevSwitch.implicitHeight) Text { - id: when - anchors { right: parent.right; rightMargin: 10; verticalCenter: parent.verticalCenter } - text: Notes.formatDate(row.modelData.file) - color: Theme.overlay + id: prevLabel + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + text: "Preview" + color: Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } } + + Switch { + id: prevSwitch + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + checked: page.preview + onToggled: page.preview = checked + } + } + + TextField { + id: titleField + anchors { top: parent.top; topMargin: 44; left: parent.left; right: parent.right } + height: 32 + placeholderText: "Title" + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 } + background: Rectangle { + radius: 6 + color: Qt.alpha(Theme.surface, 0.6) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.15) + } } - } - Text { - id: errorLine - anchors { left: parent.left; right: parent.right; bottom: parent.bottom } - height: visible ? implicitHeight : 0 - visible: page.owner.lastError !== "" - text: page.owner.lastError - wrapMode: Text.WordWrap - color: Theme.red - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + Text { + id: bodyErr + anchors { top: titleField.bottom; topMargin: 6; left: parent.left; right: parent.right } + height: visible ? implicitHeight : 0 + visible: page.bodyError !== "" + text: page.bodyError + color: Theme.yellow + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + + TextEdit { + id: bodyEdit + anchors { + top: bodyErr.bottom; topMargin: 6 + left: parent.left; right: parent.right + bottom: editError.top; bottomMargin: 6 + } + readOnly: page.preview + selectByMouse: true + textFormat: page.preview ? TextEdit.MarkdownText : TextEdit.PlainText + wrapMode: TextEdit.Wrap + color: Theme.text + selectionColor: Qt.alpha(Theme.accent, 0.4) + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + // Web and mail links only, as Segments.qml does: a note is the + // user's own, but a file: or custom scheme link would still reach + // xdg-open. + onLinkActivated: link => { if (/^(https?|mailto):/i.test(link)) Qt.openUrlExternally(link); } + HoverHandler { cursorShape: bodyEdit.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor } + } + + Text { + id: editError + anchors { left: parent.left; right: parent.right; bottom: parent.bottom } + height: visible ? implicitHeight : 0 + visible: page.owner.lastError !== "" + text: page.owner.lastError + wrapMode: Text.WordWrap + color: Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } } } diff --git a/desktop/modules/notes/test_notes.js b/desktop/modules/notes/test_notes.js index 955369c..6a316ce 100644 --- a/desktop/modules/notes/test_notes.js +++ b/desktop/modules/notes/test_notes.js @@ -18,7 +18,7 @@ const assert = require("assert"); const src = fs.readFileSync(__dirname + "/Notes.js", "utf8") .replace(/^\.pragma library$/m, ""); -const N = new Function(src + "\nreturn { parse, stringify, sorted, formatDate, nextId," +const N = new Function(src + "\nreturn { parse, valid, stringify, sorted, formatDate, nextId," + " filter, addNote, updateNote, removeNote, setLastpull };")(); const db = { @@ -51,6 +51,18 @@ assert.deepStrictEqual(N.parse(N.stringify(db)), db); assert.deepStrictEqual(N.parse("not json"), { notes: [] }); assert.deepStrictEqual(N.parse('{"a":1}'), { notes: [] }); +// valid(): the object, or null when the file cannot be read. A write must +// refuse on null rather than replace an unreadable index, which is what a +// conflicted db.json is: merge markers from an autostash pop over a remote +// change. An empty or missing file is an empty store, not an error. +assert.deepStrictEqual(N.valid(N.stringify(db)), db); +assert.deepStrictEqual(N.valid(""), { notes: [] }); +assert.deepStrictEqual(N.valid(" \n"), { notes: [] }); +assert.strictEqual(N.valid("not json"), null); +assert.strictEqual(N.valid('{"a":1}'), null); +assert.strictEqual(N.valid("<<<<<<< HEAD\n{}\n=======\n{}\n>>>>>>> other\n"), null); +assert.deepStrictEqual(N.parse("not json"), { notes: [] }); // display stays tolerant + // pure transforms preserve everything else and do not mutate const added = N.addNote(db, "T", "1750"); assert.strictEqual(added.notes.length, 4); |
