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
|
.pragma library
// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
//
// 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.
// Pure helpers for the notes module, tested by test_notes.js under node.
// Everything here is a value in and a value out: no file, no process.
function titleOf(note) {
return (note && typeof note.title === "string") ? note.title : "";
}
function notesOf(db) {
return (db && Array.isArray(db.notes)) ? db.notes : [];
}
// 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(t); } catch (e) { v = null; }
if (!v || typeof v !== "object" || Array.isArray(v) || !Array.isArray(v.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: [] };
}
// A note's file name as db.json should hold it: digits only, the epoch second
// notes.sh names the file with. Anything else is refused, because the page
// builds a read path, a write path and an rm path from it, and a db.json that
// came off a git remote is not entirely under this user's control.
function validFile(file) {
const f = String(file === undefined || file === null ? "" : file);
return /^\d+$/.test(f) ? f : "";
}
// Pretty, 2-space, trailing newline. notes.sh reads db.json through jq, which
// does not care about the layout.
function stringify(db) {
return JSON.stringify(db, null, 2) + "\n";
}
// Notes newest first. `file` is epoch seconds held as a string; a missing or
// non-numeric one is unusable and goes last, in input order. A copy: the
// caller's array is never reordered.
function sorted(notes) {
const usable = [], unusable = [];
for (const n of (notes || [])) {
const t = Number(n && n.file);
(isFinite(t) && t > 0 ? usable : unusable).push(n);
}
usable.sort((a, b) => Number(b.file) - Number(a.file));
return usable.concat(unusable);
}
// "dd/mm/yyyy hh:mm" in local time from an epoch-seconds string, "" for
// anything unusable, so a row with no date shows blank rather than NaN.
function formatDate(file) {
const t = Number(file);
if (!isFinite(t) || t <= 0) return "";
const d = new Date(t * 1000);
const p = n => String(n).padStart(2, "0");
return p(d.getDate()) + "/" + p(d.getMonth() + 1) + "/" + d.getFullYear()
+ " " + p(d.getHours()) + ":" + p(d.getMinutes());
}
// The next id: the highest numeric id plus one, as a string. notes.sh stores
// ids as strings and looks a note up with `select(.id == $i)`, comparing
// string to string, so a number here would stop the CLI finding a note the
// page wrote.
function nextId(notes) {
let max = 0;
for (const n of (notes || [])) {
const i = parseInt(n && n.id, 10);
if (isFinite(i) && i > max) max = i;
}
return String(max + 1);
}
// Title substring, case-insensitive; an empty query keeps all.
function filter(notes, query) {
const q = String(query === undefined || query === null ? "" : query).trim().toLowerCase();
if (q === "") return notes || [];
return (notes || []).filter(n => titleOf(n).toLowerCase().indexOf(q) >= 0);
}
// A new db with a note appended. title and file are stored as strings; the id
// comes from nextId. Every other key carries through and the input is not
// mutated.
function addNote(db, title, file) {
const notes = notesOf(db);
const entry = { id: nextId(notes), title: String(title === undefined ? "" : title),
file: String(file === undefined ? "" : file) };
return Object.assign({}, db, { notes: notes.concat([entry]) });
}
// A new db with one note's title replaced. An id that is not there leaves the
// notes untouched.
function updateNote(db, id, title) {
const notes = notesOf(db).map(n =>
n.id === id ? Object.assign({}, n, { title: String(title === undefined ? "" : title) }) : n);
return Object.assign({}, db, { notes: notes });
}
// A new db without the note this id names.
function removeNote(db, id) {
return Object.assign({}, db, { notes: notesOf(db).filter(n => n.id !== id) });
}
// A new db with git.lastpull set, as a string, the way notes.sh's gitsync
// writes it. It is what notes.sh -l reads to decide whether to pull again.
function setLastpull(db, now) {
return Object.assign({}, db, { git: Object.assign({}, db.git, { lastpull: String(now) }) });
}
|