qotm 1.0.7 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,1960 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/io.ts
4
+ import { spawn } from "node:child_process";
5
+ var processIo = () => ({
6
+ stdout: (text) => void process.stdout.write(text),
7
+ stderr: (text) => void process.stderr.write(text),
8
+ env: process.env,
9
+ isTTY: Boolean(process.stdout.isTTY),
10
+ columns: process.stdout.columns,
11
+ openUrl: openInBrowser
12
+ });
13
+ function openInBrowser(url2) {
14
+ const [cmd, args] = process.env.BROWSER ? [process.env.BROWSER, [url2]] : process.platform === "darwin" ? ["open", [url2]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url2.replace(/&/g, "^&")]] : ["xdg-open", [url2]];
15
+ return new Promise((resolve2, reject) => {
16
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
17
+ child.on("error", reject);
18
+ child.on("spawn", () => {
19
+ child.unref();
20
+ resolve2();
21
+ });
22
+ });
23
+ }
24
+ var CliError = class extends Error {
25
+ constructor(message, exitCode = 1) {
26
+ super(message);
27
+ this.exitCode = exitCode;
28
+ }
29
+ exitCode;
30
+ };
31
+
32
+ // src/args.ts
33
+ import { parseArgs } from "node:util";
34
+ function parse(args, options, allowPositionals = false) {
35
+ try {
36
+ return parseArgs({ args, options, allowPositionals, strict: true });
37
+ } catch (err) {
38
+ throw new CliError(`${err.message.replace(/\. To specify.*$/s, "")}. Run qotm --help.`, 2);
39
+ }
40
+ }
41
+
42
+ // src/dataset.ts
43
+ import { existsSync } from "node:fs";
44
+ import { join } from "node:path";
45
+ import { fileURLToPath } from "node:url";
46
+ import { DatabaseSync } from "node:sqlite";
47
+ var SCHEMA_VERSION = 1;
48
+ var TABLES = ["authors", "meta", "quote_topics", "quotes", "quotes_fts", "topics"];
49
+ var SNAPSHOT = fileURLToPath(new URL("../data/qotm.sqlite", import.meta.url));
50
+ var SNAPSHOT_MANIFEST = fileURLToPath(new URL("../data/snapshot.json", import.meta.url));
51
+ var toQuote = (r) => ({
52
+ n: Number(r.n),
53
+ id: String(r.id),
54
+ text: String(r.text),
55
+ type: r.type,
56
+ author: r.author ?? null,
57
+ authorSlug: r.author_slug ?? null,
58
+ work: r.work ?? null,
59
+ year: r.year == null ? null : Number(r.year),
60
+ source: r.source ?? null,
61
+ sourceUrl: r.source_url ?? null,
62
+ contributor: r.contributor ?? null
63
+ });
64
+ var DatasetError = class extends Error {
65
+ constructor(message, reason) {
66
+ super(message);
67
+ this.reason = reason;
68
+ }
69
+ reason;
70
+ };
71
+ var Dataset = class _Dataset {
72
+ constructor(db, file, meta) {
73
+ this.db = db;
74
+ this.file = file;
75
+ this.meta = meta;
76
+ }
77
+ db;
78
+ file;
79
+ meta;
80
+ /** Opens and checks a file: a known schema version and every table it should have. */
81
+ static open(file) {
82
+ if (!existsSync(file)) throw new DatasetError(`${file} does not exist`, "missing");
83
+ let db;
84
+ try {
85
+ db = new DatabaseSync(file, { readOnly: true });
86
+ } catch (err) {
87
+ throw new DatasetError(`${file} cannot be opened: ${err.message}`, "corrupt");
88
+ }
89
+ try {
90
+ const userVersion = Number(db.prepare("PRAGMA user_version").get().user_version);
91
+ const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map(
92
+ (r) => String(r.name)
93
+ );
94
+ const meta = Object.fromEntries(
95
+ db.prepare("SELECT key, value FROM meta").all().map((r) => [String(r.key), String(r.value)])
96
+ );
97
+ const schemaVersion = Number(meta.schemaVersion ?? userVersion);
98
+ if (schemaVersion !== SCHEMA_VERSION || userVersion !== SCHEMA_VERSION)
99
+ throw new DatasetError(
100
+ `${file} is dataset schema ${schemaVersion}; this qotm reads schema ${SCHEMA_VERSION}`,
101
+ "schema"
102
+ );
103
+ const missing = TABLES.filter((t) => !tables.includes(t));
104
+ if (missing.length) throw new DatasetError(`${file} has no ${missing.join(", ")} table`, "corrupt");
105
+ db.prepare("SELECT count(*) FROM quotes").get();
106
+ return new _Dataset(db, file, {
107
+ version: meta.version ?? "unknown",
108
+ schemaVersion,
109
+ builtAt: meta.builtAt ?? null
110
+ });
111
+ } catch (err) {
112
+ db.close();
113
+ if (err instanceof DatasetError) throw err;
114
+ throw new DatasetError(`${file} is not a qotm dataset: ${err.message}`, "corrupt");
115
+ }
116
+ }
117
+ count() {
118
+ return Number(this.db.prepare("SELECT count(*) AS n FROM quotes").get().n);
119
+ }
120
+ /** The ids `qotm` may draw from: every quote, or those under a topic, by an author, or in a set. */
121
+ candidates(filter = {}) {
122
+ const where = [];
123
+ const params = [];
124
+ if (filter.topic) {
125
+ where.push(
126
+ "id IN (SELECT qt.quote_id FROM quote_topics qt JOIN topics t ON t.id = qt.topic_id WHERE t.slug = ?)"
127
+ );
128
+ params.push(filter.topic);
129
+ }
130
+ if (filter.author) {
131
+ where.push("author_slug = ?");
132
+ params.push(filter.author);
133
+ }
134
+ if (filter.ids) {
135
+ if (!filter.ids.length) return [];
136
+ where.push(`id IN (${filter.ids.map(() => "?").join(", ")})`);
137
+ params.push(...filter.ids);
138
+ }
139
+ const sql = `SELECT id FROM quotes${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY n`;
140
+ return this.db.prepare(sql).all(...params).map((r) => String(r.id));
141
+ }
142
+ quote(id) {
143
+ const row = this.db.prepare("SELECT * FROM quotes WHERE id = ?").get(id);
144
+ return row ? toQuote(row) : null;
145
+ }
146
+ /** A quote's topic slugs. */
147
+ topicsOf(id) {
148
+ return this.db.prepare(
149
+ "SELECT t.slug FROM quote_topics qt JOIN topics t ON t.id = qt.topic_id WHERE qt.quote_id = ? ORDER BY t.slug"
150
+ ).all(id).map((r) => String(r.slug));
151
+ }
152
+ topics() {
153
+ return this.db.prepare("SELECT slug FROM topics ORDER BY slug").all().map((r) => String(r.slug));
154
+ }
155
+ authors() {
156
+ return this.db.prepare("SELECT slug FROM authors ORDER BY slug").all().map((r) => String(r.slug));
157
+ }
158
+ /**
159
+ * Full-text search (FTS5 over text, author and work), best match first.
160
+ * Each word is matched as typed or as a prefix, all words required.
161
+ */
162
+ search(text, limit = 10) {
163
+ const words = text.match(/[\p{L}\p{N}]+/gu) ?? [];
164
+ if (!words.length) return [];
165
+ const match = words.map((w) => `"${w.replace(/"/g, "")}"*`).join(" ");
166
+ try {
167
+ return this.db.prepare(
168
+ "SELECT q.* FROM quotes_fts f JOIN quotes q ON q.n = f.rowid WHERE quotes_fts MATCH ? ORDER BY rank LIMIT ?"
169
+ ).all(match, limit).map(toQuote);
170
+ } catch (error) {
171
+ if (error instanceof Error && /no such module: fts5/.test(error.message)) {
172
+ throw new CliError(
173
+ `qotm search needs Node.js 22.16+ or 24+ (this is ${process.version}). Everything else works; update Node to search, or use qotm search --web.`
174
+ );
175
+ }
176
+ throw error;
177
+ }
178
+ }
179
+ close() {
180
+ this.db.close();
181
+ }
182
+ };
183
+ function openDataset(dataDir, snapshot = SNAPSHOT) {
184
+ const local = join(dataDir, "qotm.sqlite");
185
+ let warning;
186
+ try {
187
+ return { dataset: Dataset.open(local), from: "local" };
188
+ } catch (err) {
189
+ if (!(err instanceof DatasetError)) throw err;
190
+ if (err.reason === "schema")
191
+ warning = `${err.message}. Using the bundled quotes; update qotm (npm i -g qotm) to read it.`;
192
+ else if (err.reason === "corrupt")
193
+ warning = `the synced dataset is unreadable (${err.message}). Using the bundled quotes; run qotm sync to fetch it again.`;
194
+ }
195
+ return { dataset: Dataset.open(snapshot), from: "snapshot", warning };
196
+ }
197
+
198
+ // src/config.ts
199
+ import { join as join4 } from "node:path";
200
+
201
+ // src/paths.ts
202
+ import { homedir } from "node:os";
203
+ import { join as join2 } from "node:path";
204
+ function qotmPaths(env = process.env, platform = process.platform, home = homedir()) {
205
+ if (env.QOTM_HOME) return { config: join2(env.QOTM_HOME, "config"), data: join2(env.QOTM_HOME, "data") };
206
+ if (platform === "darwin")
207
+ return {
208
+ config: join2(home, "Library", "Preferences", "qotm"),
209
+ data: join2(home, "Library", "Application Support", "qotm")
210
+ };
211
+ if (platform === "win32") {
212
+ const roaming = env.APPDATA || join2(home, "AppData", "Roaming");
213
+ const local = env.LOCALAPPDATA || join2(home, "AppData", "Local");
214
+ return { config: join2(roaming, "qotm", "Config"), data: join2(local, "qotm", "Data") };
215
+ }
216
+ return {
217
+ config: join2(env.XDG_CONFIG_HOME || join2(home, ".config"), "qotm"),
218
+ data: join2(env.XDG_DATA_HOME || join2(home, ".local", "share"), "qotm")
219
+ };
220
+ }
221
+
222
+ // src/render.ts
223
+ var STYLES = ["pull", "bubble", "card", "slab"];
224
+ var MIN_COLUMNS = 40;
225
+ var sgr = (open2, close) => (s) => s ? `\x1B[${open2}m${s}\x1B[${close}m` : s;
226
+ var none = (s) => s;
227
+ function palette(color) {
228
+ if (!color)
229
+ return {
230
+ text: none,
231
+ frame: none,
232
+ credit: none,
233
+ accent: none,
234
+ panel: none,
235
+ panelText: none,
236
+ panelAccent: none,
237
+ panelCredit: none
238
+ };
239
+ const panel = "48;5;235";
240
+ return {
241
+ text: sgr("1", "22"),
242
+ frame: sgr("38;5;242", "39"),
243
+ credit: sgr("38;5;248", "39"),
244
+ accent: sgr("38;5;173", "39"),
245
+ panel: sgr(panel, "49"),
246
+ panelText: sgr(`1;38;5;255;${panel}`, "22;39;49"),
247
+ panelAccent: sgr(`38;5;173;${panel}`, "39;49"),
248
+ panelCredit: sgr(`38;5;248;${panel}`, "39;49")
249
+ };
250
+ }
251
+ var cells = (s) => [...s].length;
252
+ function wrapWords(text, width) {
253
+ const lines = [];
254
+ for (const paragraph of text.split("\n")) {
255
+ let line = "";
256
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
257
+ if (line && cells(line) + 1 + cells(word) > width) {
258
+ lines.push(line);
259
+ line = word;
260
+ } else line = line ? `${line} ${word}` : word;
261
+ }
262
+ lines.push(line);
263
+ }
264
+ return lines;
265
+ }
266
+ function balance(text, max) {
267
+ const count = wrapWords(text, max).length;
268
+ if (count === 1) return wrapWords(text, max);
269
+ let width = max;
270
+ while (width > 12 && wrapWords(text, width - 1).length === count) width--;
271
+ return wrapWords(text, width);
272
+ }
273
+ var longest = (lines) => Math.max(0, ...lines.map(cells));
274
+ var clip = (s, n2) => cells(s) > n2 ? `${[...s].slice(0, n2 - 1).join("")}\u2026` : s;
275
+ function pull(card2, columns, p) {
276
+ const indent = " ";
277
+ const lines = balance(card2.text, Math.min(60, columns - indent.length - 2));
278
+ const marks = [" \u2584\u2584 \u2584\u2584 ", " \u2580\u2588 \u2580\u2588 "];
279
+ const out = lines.map((l, i) => (i < 2 ? p.accent(marks[i]) : indent) + p.text(l));
280
+ if (lines.length < 2) out.push(p.accent(marks[1]));
281
+ out.push("");
282
+ const ref = card2.ref ? p.frame(` \xB7 ${card2.ref}`) : "";
283
+ out.push(`${indent}${p.accent("\u2014")} ${p.credit(card2.credit)}${ref}`);
284
+ if (card2.source)
285
+ out.push(...balance(card2.source, Math.min(60, columns - indent.length - 2)).map((l) => indent + p.frame(l)));
286
+ return out;
287
+ }
288
+ var Q = [" \u2584\u2584\u2584\u2584\u2584", " \u2584\u2580 \u2580\u2584", " \u2588 \u2588", " \u2580\u2584 \u2584\u2588", " \u2580\u2580\u2580\u2580\u2580 \u2588", " \u2580\u2584\u2584"];
289
+ function bubble(card2, columns, p) {
290
+ const max = Math.min(56, columns - 12);
291
+ const lines = balance(card2.text, max);
292
+ const credit2 = `\u2014 ${clip(card2.credit, max - 2)}`;
293
+ const source = card2.source ? balance(card2.source, max) : [];
294
+ const width = Math.max(longest(lines), cells(credit2), longest(source));
295
+ const inner = width + 6;
296
+ const row = (content, length) => ` ${p.frame("\u2502")} ${content}${" ".repeat(width - length)} ${p.frame("\u2502")}`;
297
+ const blank = ` ${p.frame(`\u2502${" ".repeat(inner)}\u2502`)}`;
298
+ return [
299
+ ` ${p.frame(`\u256D${"\u2500".repeat(inner)}\u256E`)}`,
300
+ blank,
301
+ ...lines.map((l) => row(p.text(l), cells(l))),
302
+ blank,
303
+ row(" ".repeat(width - cells(credit2)) + p.accent("\u2014") + " " + p.credit(credit2.slice(2)), width),
304
+ ...source.map((l) => row(" ".repeat(width - cells(l)) + p.frame(l), width)),
305
+ blank,
306
+ ` ${p.frame(`\u2570\u2500\u2500\u252C${"\u2500".repeat(inner - 3)}\u256F`)}`,
307
+ ` ${p.frame("\u2502")}`,
308
+ ...Q.map((l) => p.accent(l))
309
+ ];
310
+ }
311
+ function card(c, columns, p) {
312
+ const max = Math.min(50, columns - 14);
313
+ const lines = balance(c.text, max);
314
+ const credit2 = clip(c.credit, max);
315
+ const source = c.source ? balance(c.source, max) : [];
316
+ const inner = Math.max(longest(lines), cells(credit2), longest(source), 24) + 10;
317
+ const centre = (s, paint) => {
318
+ const left = Math.floor((inner - cells(s)) / 2);
319
+ return ` ${p.frame("\u2502")}${" ".repeat(left)}${paint(s)}${" ".repeat(inner - left - cells(s))}${p.frame("\u2502")}`;
320
+ };
321
+ const blank = ` ${p.frame(`\u2502${" ".repeat(inner)}\u2502`)}`;
322
+ const ref = c.ref ?? "";
323
+ const status2 = c.status;
324
+ const dashes = inner - 5 - cells(ref) - cells(status2) - (ref ? 1 : 0);
325
+ const bottom = dashes >= 2 ? ` ${p.frame("\u2570\u2500")}${ref ? ` ${p.credit(ref)}` : ""} ${p.frame("\u2500".repeat(dashes))} ${p.accent(status2)} ${p.frame("\u2500\u256F")}` : ` ${p.frame(`\u2570${"\u2500".repeat(inner)}\u256F`)}`;
326
+ return [
327
+ ` ${p.frame(`\u256D${"\u2500".repeat(inner)}\u256E`)}`,
328
+ blank,
329
+ ...lines.map((l) => centre(l, p.text)),
330
+ blank,
331
+ centre("\u2500\u2500", p.accent),
332
+ centre(credit2, p.credit),
333
+ ...source.map((l) => centre(l, p.frame)),
334
+ blank,
335
+ bottom
336
+ ];
337
+ }
338
+ function slab(c, columns, p) {
339
+ const max = Math.min(56, columns - 11);
340
+ const lines = balance(c.text, max);
341
+ const credit2 = `\u2014 ${clip(c.credit, max - 2)}`;
342
+ const source = c.source ? balance(c.source, max) : [];
343
+ const width = Math.max(longest(lines), cells(credit2), longest(source)) + 6;
344
+ const edge = ` ${p.accent("\u258C")}`;
345
+ const blank = edge + p.panel(" ".repeat(width));
346
+ const row = (content, length, rest = p.panel) => `${edge}${p.panel(" ")}${content}${rest(" ".repeat(width - 3 - length))}`;
347
+ const footer = clip([...c.topics, c.status, c.ref].filter(Boolean).join(" \xB7 "), columns - 5);
348
+ return [
349
+ blank,
350
+ ...lines.map((l) => row(p.panelText(l), cells(l))),
351
+ blank,
352
+ row(p.panelAccent("\u2014") + p.panel(" ") + p.panelCredit(credit2.slice(2)), cells(credit2)),
353
+ ...source.map((l) => row(p.panelCredit(l), cells(l))),
354
+ blank,
355
+ ` ${p.frame(footer)}`
356
+ ];
357
+ }
358
+ var DRAW = { pull, bubble, card, slab };
359
+ function render(c, style2, columns, color) {
360
+ return `
361
+ ${DRAW[style2](c, columns, palette(color)).join("\n")}
362
+ `;
363
+ }
364
+
365
+ // src/store.ts
366
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
367
+ import { randomBytes } from "node:crypto";
368
+ import { dirname, join as join3 } from "node:path";
369
+ function readJson(file, empty) {
370
+ try {
371
+ return { ...empty, ...JSON.parse(readFileSync(file, "utf8")) };
372
+ } catch {
373
+ return empty;
374
+ }
375
+ }
376
+ function writeJson(file, value) {
377
+ mkdirSync(dirname(file), { recursive: true });
378
+ const tmp = `${file}.${process.pid}.tmp`;
379
+ writeFileSync(tmp, `${JSON.stringify(value, null, " ")}
380
+ `);
381
+ renameSync(tmp, file);
382
+ }
383
+ var HISTORY_SIZE = 50;
384
+ var historyFile = (data) => join3(data, "history.json");
385
+ var readHistory = (data) => readJson(historyFile(data), { shown: [] });
386
+ function remember(data, id, now = /* @__PURE__ */ new Date(), size = HISTORY_SIZE) {
387
+ const { shown } = readHistory(data);
388
+ writeJson(historyFile(data), {
389
+ shown: [{ id, at: now.toISOString() }, ...shown.filter((s) => s.id !== id)].slice(0, Math.max(1, size))
390
+ });
391
+ }
392
+ var favoritesFile = (data) => join3(data, "favorites.json");
393
+ var readFavorites = (data) => {
394
+ const f = readJson(favoritesFile(data), {});
395
+ return {
396
+ favorites: f.favorites ?? [],
397
+ pending: { add: f.pending?.add ?? [], remove: f.pending?.remove ?? [] },
398
+ localOnly: f.localOnly ?? []
399
+ };
400
+ };
401
+ var writeFavorites = (data, f) => writeJson(favoritesFile(data), f);
402
+ function addFavorite(data, id, now = /* @__PURE__ */ new Date()) {
403
+ const f = readFavorites(data);
404
+ if (f.favorites.some((x) => x.id === id)) return false;
405
+ writeFavorites(data, {
406
+ favorites: [{ id, addedAt: now.toISOString() }, ...f.favorites],
407
+ pending: { add: [.../* @__PURE__ */ new Set([...f.pending.add, id])], remove: f.pending.remove.filter((x) => x !== id) },
408
+ localOnly: f.localOnly
409
+ });
410
+ return true;
411
+ }
412
+ function removeFavorite(data, id) {
413
+ const f = readFavorites(data);
414
+ if (!f.favorites.some((x) => x.id === id)) return false;
415
+ writeFavorites(data, {
416
+ favorites: f.favorites.filter((x) => x.id !== id),
417
+ pending: { add: f.pending.add.filter((x) => x !== id), remove: [.../* @__PURE__ */ new Set([...f.pending.remove, id])] },
418
+ localOnly: f.localOnly.filter((x) => x !== id)
419
+ });
420
+ return true;
421
+ }
422
+ var personalFile = (data) => join3(data, "personal.json");
423
+ var readPersonal = (data) => readJson(personalFile(data), { quotes: [] });
424
+ function addPersonal(data, quote2, now = /* @__PURE__ */ new Date()) {
425
+ const { quotes } = readPersonal(data);
426
+ let id;
427
+ do
428
+ id = `p_${randomBytes(6).toString("base64url").toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 8).padEnd(8, "0")}`;
429
+ while (quotes.some((q) => q.id === id));
430
+ const created = {
431
+ id,
432
+ text: quote2.text,
433
+ author: quote2.author ?? null,
434
+ work: quote2.work ?? null,
435
+ year: quote2.year ?? null,
436
+ createdAt: now.toISOString()
437
+ };
438
+ writeJson(personalFile(data), { quotes: [...quotes, created] });
439
+ return created;
440
+ }
441
+ function setRemoteId(data, id, remoteId) {
442
+ const { quotes } = readPersonal(data);
443
+ writeJson(personalFile(data), { quotes: quotes.map((q) => q.id === id ? { ...q, remoteId } : q) });
444
+ }
445
+
446
+ // src/config.ts
447
+ var SETTINGS = {
448
+ api: {
449
+ default: "https://api.qotm.dev",
450
+ env: "QOTM_API_URL",
451
+ describe: "the qotm API (login, sync, favorites, submit)",
452
+ parse: (v) => url(v)
453
+ },
454
+ site: {
455
+ default: "https://qotm.dev",
456
+ env: "QOTM_SITE_URL",
457
+ describe: "the qotm site (qotm open, search --web)",
458
+ parse: (v) => url(v)
459
+ },
460
+ color: {
461
+ default: "auto",
462
+ env: void 0,
463
+ describe: "auto (on a terminal), always or never",
464
+ parse: (v) => {
465
+ if (!["auto", "always", "never"].includes(v)) throw new CliError("color is auto, always or never.", 2);
466
+ return v;
467
+ }
468
+ },
469
+ style: {
470
+ default: "pull",
471
+ env: void 0,
472
+ describe: "how a quote is drawn: pull, bubble, card or slab",
473
+ parse: (v) => {
474
+ if (!STYLES.includes(v))
475
+ throw new CliError(`style is ${STYLES.slice(0, -1).join(", ")} or ${STYLES.at(-1)}.`, 2);
476
+ return v;
477
+ }
478
+ },
479
+ history: {
480
+ default: "50",
481
+ env: void 0,
482
+ describe: "how many recent quotes qotm avoids repeating (0: none)",
483
+ parse: (v) => {
484
+ if (!/^\d{1,4}$/.test(v)) throw new CliError("history is a number of quotes, like 50.", 2);
485
+ return String(Number(v));
486
+ }
487
+ }
488
+ };
489
+ var KEYS = Object.keys(SETTINGS);
490
+ function url(v) {
491
+ try {
492
+ const u = new URL(v);
493
+ if (u.protocol !== "https:" && u.protocol !== "http:") throw new Error();
494
+ return v.replace(/\/+$/, "");
495
+ } catch {
496
+ throw new CliError(`"${v}" is not a URL (https://\u2026).`, 2);
497
+ }
498
+ }
499
+ var settingsFile = (io) => join4(qotmPaths(io.env).config, "settings.json");
500
+ var readSettings = (io) => readJson(settingsFile(io), {});
501
+ function resolve(io, key) {
502
+ const s = SETTINGS[key];
503
+ const fromEnv = s.env ? io.env[s.env] : void 0;
504
+ if (fromEnv) return { key, value: s.parse(fromEnv), from: "env" };
505
+ const saved = readSettings(io)[key];
506
+ if (saved !== void 0) return { key, value: saved, from: "settings" };
507
+ return { key, value: s.default, from: "default" };
508
+ }
509
+ var setting = (io, key) => resolve(io, key).value;
510
+ function setSetting(io, key, value) {
511
+ const parsed = SETTINGS[key].parse(value);
512
+ writeJson(settingsFile(io), { ...readSettings(io), [key]: parsed });
513
+ return parsed;
514
+ }
515
+ function unsetSetting(io, key) {
516
+ const { [key]: _, ...rest } = readSettings(io);
517
+ writeJson(settingsFile(io), rest);
518
+ }
519
+ function assertKey(key) {
520
+ if (!key || !(key in SETTINGS))
521
+ throw new CliError(`Unknown setting "${key ?? ""}". Settings: ${KEYS.join(", ")}.`, 2);
522
+ return key;
523
+ }
524
+
525
+ // src/version.ts
526
+ var VERSION = "2.0.0";
527
+
528
+ // src/http.ts
529
+ async function api(io, path, init = {}) {
530
+ const base = setting(io, "api");
531
+ let res;
532
+ try {
533
+ res = await fetch(`${base}${path}`, {
534
+ method: init.method ?? "GET",
535
+ headers: {
536
+ "user-agent": `qotm/${VERSION}`,
537
+ accept: "application/json",
538
+ ...init.body !== void 0 ? { "content-type": "application/json" } : {},
539
+ ...init.token ? { authorization: `Bearer ${init.token}` } : {},
540
+ ...init.headers
541
+ },
542
+ body: init.body !== void 0 ? JSON.stringify(init.body) : void 0,
543
+ signal: AbortSignal.timeout(init.timeoutMs ?? 15e3)
544
+ });
545
+ } catch (err) {
546
+ const cause = String(err.cause ?? "");
547
+ if (/bad port/i.test(cause))
548
+ throw new CliError(
549
+ `${base}: fetch refuses that port (a "bad port" in the Fetch standard); use another, or the https address.`
550
+ );
551
+ const why = err.name === "TimeoutError" ? "it did not answer in time" : "it could not be reached";
552
+ throw new CliError(`${base}: ${why}. Check your connection, or qotm config get api.`);
553
+ }
554
+ const text = await res.text();
555
+ let body2 = text;
556
+ try {
557
+ body2 = text ? JSON.parse(text) : null;
558
+ } catch {
559
+ }
560
+ return { status: res.status, headers: res.headers, body: body2 };
561
+ }
562
+ function apiMessage(res) {
563
+ const m = res.body?.message;
564
+ return typeof m === "string" ? m : Array.isArray(m) ? m.join("; ") : `HTTP ${res.status}`;
565
+ }
566
+
567
+ // src/state.ts
568
+ import { chmodSync, rmSync } from "node:fs";
569
+ import { join as join5 } from "node:path";
570
+ var sessionFile = (config2) => join5(config2, "session.json");
571
+ var readSession = (config2) => {
572
+ const s = readJson(sessionFile(config2), {});
573
+ return s.token ? s : null;
574
+ };
575
+ function writeSession(config2, session) {
576
+ writeJson(sessionFile(config2), session);
577
+ try {
578
+ chmodSync(sessionFile(config2), 384);
579
+ } catch {
580
+ }
581
+ }
582
+ var clearSession = (config2) => rmSync(sessionFile(config2), { force: true });
583
+ var syncFile = (data) => join5(data, "sync.json");
584
+ var readSync = (data) => {
585
+ const s = readJson(syncFile(data), {});
586
+ return s.version ? s : null;
587
+ };
588
+ function writeSync(data, state) {
589
+ writeJson(syncFile(data), state);
590
+ }
591
+
592
+ // src/favorites-sync.ts
593
+ async function syncFavorites(io, timeoutMs = 15e3) {
594
+ const { config: config2, data } = qotmPaths(io.env);
595
+ const session = readSession(config2);
596
+ if (!session) return { status: "signed-out" };
597
+ const before = readFavorites(data);
598
+ const sent = { add: [...before.pending.add], remove: [...before.pending.remove] };
599
+ let res;
600
+ try {
601
+ res = await api(io, "/v1/me/favorites/sync", {
602
+ method: "PUT",
603
+ token: session.token,
604
+ body: sent,
605
+ timeoutMs
606
+ });
607
+ } catch {
608
+ return { status: "queued", reason: "the API could not be reached", pending: sent.add.length + sent.remove.length };
609
+ }
610
+ if (res.status === 401)
611
+ return {
612
+ status: "queued",
613
+ reason: "your session ended; run qotm login",
614
+ pending: sent.add.length + sent.remove.length
615
+ };
616
+ if (res.status !== 200)
617
+ return { status: "queued", reason: apiMessage(res), pending: sent.add.length + sent.remove.length };
618
+ const now = readFavorites(data);
619
+ const pending = {
620
+ add: now.pending.add.filter((id) => !sent.add.includes(id)),
621
+ remove: now.pending.remove.filter((id) => !sent.remove.includes(id))
622
+ };
623
+ const skipped = res.body.skipped ?? [];
624
+ const localOnly = [.../* @__PURE__ */ new Set([...now.localOnly, ...skipped])].filter((id) => !pending.remove.includes(id));
625
+ const merged = res.body.favorites.filter((f) => !pending.remove.includes(f.id));
626
+ const known = new Set(merged.map((f) => f.id));
627
+ const keep = now.favorites.filter(
628
+ (f) => !known.has(f.id) && (localOnly.includes(f.id) || pending.add.includes(f.id))
629
+ );
630
+ const favorites2 = [...keep, ...merged].sort((a, b) => b.addedAt.localeCompare(a.addedAt));
631
+ writeFavorites(data, { favorites: favorites2, pending, localOnly });
632
+ return { status: "synced", handle: session.handle, count: favorites2.length, skipped };
633
+ }
634
+
635
+ // src/output.ts
636
+ var siteUrl = (io) => setting(io, "site");
637
+ var quoteUrl = (io, id) => id.startsWith("q_") ? `${siteUrl(io)}/q/${id.slice(2)}` : null;
638
+ function useColor(io, plain = false) {
639
+ if (plain) return false;
640
+ const configured = setting(io, "color");
641
+ if (configured !== "auto") return configured === "always";
642
+ if (io.env.FORCE_COLOR && io.env.FORCE_COLOR !== "0") return true;
643
+ if ("NO_COLOR" in io.env && io.env.NO_COLOR !== void 0) return false;
644
+ if (io.env.TERM === "dumb") return false;
645
+ return io.isTTY;
646
+ }
647
+ var sgr2 = (open2, close) => (on) => (s) => on ? `\x1B[${open2}m${s}\x1B[${close}m` : s;
648
+ var style = {
649
+ bold: sgr2("1", "22"),
650
+ /** The kit's clay accent (#b85c3e), as near as 256 colours get. */
651
+ clay: sgr2("38;5;173", "39"),
652
+ /** Muted text. */
653
+ muted: sgr2("38;5;245", "39")
654
+ };
655
+ function wrap(text, width) {
656
+ const lines = [];
657
+ for (const paragraph of text.split("\n")) {
658
+ let line = "";
659
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
660
+ if (line && line.length + 1 + word.length > width) {
661
+ lines.push(line);
662
+ line = word;
663
+ } else line = line ? `${line} ${word}` : word;
664
+ }
665
+ lines.push(line);
666
+ }
667
+ return lines.join("\n");
668
+ }
669
+
670
+ // src/commands/local.ts
671
+ var QUOTE_ID = /^q_[a-z0-9]{3,32}$/i;
672
+ function target(given, data, verb) {
673
+ if (given) {
674
+ const id = given.startsWith("q_") ? given : `q_${given}`;
675
+ if (!QUOTE_ID.test(id)) throw new CliError(`"${given}" is not a quote id (q_8f2k1c).`, 2);
676
+ return id;
677
+ }
678
+ const [latest] = readHistory(data).shown;
679
+ if (!latest) throw new CliError(`Nothing to ${verb} yet. Run qotm for a quote, then qotm ${verb}.`);
680
+ if (latest.id.startsWith("p_"))
681
+ throw new CliError("The last quote is one of your private quotes; favorites are quotes from the dataset.");
682
+ return latest.id;
683
+ }
684
+ async function share(io) {
685
+ const r = await syncFavorites(io, 3e3);
686
+ return r.status === "queued" ? ` (it syncs with your account later: ${r.reason})` : "";
687
+ }
688
+ var save = {
689
+ name: "save",
690
+ usage: "qotm save [<id>]",
691
+ summary: "add the last quote to your favorites",
692
+ help: "Favorites live on this machine; signed in, they are shared with your account (offline, at the next qotm sync).",
693
+ async run(args, io) {
694
+ const { positionals } = parse(args, {}, true);
695
+ const { data } = qotmPaths(io.env);
696
+ const id = target(positionals[0], data, "save");
697
+ const muted = style.muted(useColor(io));
698
+ const added = addFavorite(data, id);
699
+ const note = added ? await share(io) : "";
700
+ io.stdout(muted(added ? `saved to favorites${note}
701
+ ` : "already in your favorites\n"));
702
+ return 0;
703
+ }
704
+ };
705
+ var unsave = {
706
+ name: "unsave",
707
+ usage: "qotm unsave [<id>]",
708
+ summary: "remove the last quote (or <id>) from your favorites",
709
+ async run(args, io) {
710
+ const { positionals } = parse(args, {}, true);
711
+ const { data } = qotmPaths(io.env);
712
+ const id = target(positionals[0], data, "unsave");
713
+ const muted = style.muted(useColor(io));
714
+ const removed = removeFavorite(data, id);
715
+ const note = removed ? await share(io) : "";
716
+ io.stdout(muted(removed ? `removed from favorites${note}
717
+ ` : "not in your favorites\n"));
718
+ return 0;
719
+ }
720
+ };
721
+ var favorites = {
722
+ name: "favorites",
723
+ usage: "qotm favorites [--plain | --json]",
724
+ summary: "list your favorites",
725
+ run(args, io) {
726
+ const { values: v } = parse(args, { plain: { type: "boolean" }, json: { type: "boolean" } });
727
+ const { data } = qotmPaths(io.env);
728
+ const list = readFavorites(data).favorites;
729
+ const { dataset } = openDataset(data);
730
+ try {
731
+ const rows = list.map((f) => ({ ...f, quote: dataset.quote(f.id) }));
732
+ if (v.json) {
733
+ io.stdout(
734
+ `${JSON.stringify(
735
+ rows.map((r) => ({
736
+ id: r.id,
737
+ addedAt: r.addedAt,
738
+ text: r.quote?.text ?? null,
739
+ author: r.quote?.author ?? null
740
+ }))
741
+ )}
742
+ `
743
+ );
744
+ return 0;
745
+ }
746
+ if (!rows.length) {
747
+ io.stdout("No favorites yet. Save the last quote with qotm save.\n");
748
+ return 0;
749
+ }
750
+ const color = useColor(io, v.plain);
751
+ const width = v.plain || !io.columns ? Infinity : io.columns;
752
+ for (const r of rows) {
753
+ const words = r.quote ? `${r.quote.text} \u2014 ${r.quote.author ?? (r.quote.contributor ? `@${r.quote.contributor}` : "a former member")}` : "not in this dataset (run qotm sync)";
754
+ const line = `${r.id} ${words}`;
755
+ const clipped = line.length > width ? `${line.slice(0, Math.max(10, width - 1))}\u2026` : line;
756
+ io.stdout(`${style.muted(color)(clipped.slice(0, r.id.length))}${clipped.slice(r.id.length)}
757
+ `);
758
+ }
759
+ return 0;
760
+ } finally {
761
+ dataset.close();
762
+ }
763
+ }
764
+ };
765
+ var add = {
766
+ name: "add",
767
+ usage: 'qotm add "<text>" [--author <name>] [--work <title>] [--year <yyyy>]',
768
+ summary: "keep a private quote",
769
+ help: `
770
+ Private quotes never enter the dataset. Signed in, they are also kept in
771
+ your account (My quotes on qotm.dev). qotm --personal draws from them;
772
+ qotm submit <p_id> sends one for review as your original.
773
+ `,
774
+ async run(args, io) {
775
+ const { values: v, positionals } = parse(
776
+ args,
777
+ { author: { type: "string" }, work: { type: "string" }, year: { type: "string" } },
778
+ true
779
+ );
780
+ const text = positionals.join(" ").replace(/\s+/g, " ").trim();
781
+ if (!text) throw new CliError('Give the quote: qotm add "Make it work, make it right, make it fast."', 2);
782
+ if (text.length > 280) throw new CliError(`That is ${text.length} characters; a quote is at most 280.`, 2);
783
+ if (v.year !== void 0 && !/^\d{1,4}$/.test(v.year)) throw new CliError("--year is a year, like 1974.", 2);
784
+ const { data } = qotmPaths(io.env);
785
+ const kept = addPersonal(data, {
786
+ text,
787
+ author: v.author?.trim() || null,
788
+ work: v.work?.trim() || null,
789
+ year: v.year ? Number(v.year) : null
790
+ });
791
+ const muted = style.muted(useColor(io));
792
+ io.stdout(muted(`kept as a private quote (${kept.id}). qotm --personal shows your private quotes.
793
+ `));
794
+ const session = readSession(qotmPaths(io.env).config);
795
+ if (session) {
796
+ try {
797
+ const res = await api(io, "/v1/submissions", {
798
+ method: "POST",
799
+ token: session.token,
800
+ timeoutMs: 5e3,
801
+ body: {
802
+ type: "private",
803
+ text,
804
+ ...kept.author ? { attribution: kept.author } : {},
805
+ ...kept.work ? { source: kept.work } : {},
806
+ ...kept.year ? { year: kept.year } : {}
807
+ }
808
+ });
809
+ if (res.status === 200 || res.status === 201) {
810
+ setRemoteId(data, kept.id, res.body.id);
811
+ io.stdout(muted(`and in your account's private quotes (${res.body.id})
812
+ `));
813
+ } else io.stdout(muted(`not sent to your account: ${apiMessage(res)}
814
+ `));
815
+ } catch {
816
+ io.stdout(muted("not sent to your account: the API could not be reached\n"));
817
+ }
818
+ }
819
+ return 0;
820
+ }
821
+ };
822
+
823
+ // src/commands/login.ts
824
+ import { hostname } from "node:os";
825
+ var CLIENT_ID = "qotm-cli";
826
+ var GRANT = "urn:ietf:params:oauth:grant-type:device_code";
827
+ function deviceLabel(env, host = hostname()) {
828
+ const base = (p) => p.split(/[\\/]/).pop().replace(/\.exe$/i, "");
829
+ const shellPath = env.SHELL || env.ComSpec || env.COMSPEC;
830
+ const shell = shellPath ? base(shellPath) : "terminal";
831
+ const name = host.replace(/\.local$/, "") || "this computer";
832
+ return `${name} \xB7 ${shell}`.slice(0, 80);
833
+ }
834
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
835
+ function heldVersion(io) {
836
+ try {
837
+ const { dataset } = openDataset(qotmPaths(io.env).data);
838
+ const v = dataset.meta.version;
839
+ dataset.close();
840
+ return /^v\d{4}\.\d{2}\.\d+$/.test(v) ? v : void 0;
841
+ } catch {
842
+ return void 0;
843
+ }
844
+ }
845
+ var login = {
846
+ name: "login",
847
+ usage: "qotm login [--no-browser] [--force]",
848
+ summary: "sign in to sync favorites and submit quotes",
849
+ help: `
850
+ Prints a code and opens qotm.dev/device in your browser; approve it there
851
+ and this terminal is signed in. It is listed under Settings \u203A Terminals.
852
+
853
+ --no-browser only print the address (for a remote shell)
854
+ --force sign in again, replacing the current session
855
+ `,
856
+ async run(args, io) {
857
+ const { values: v } = parse(args, { "no-browser": { type: "boolean" }, force: { type: "boolean" } });
858
+ const { config: config2 } = qotmPaths(io.env);
859
+ const current = readSession(config2);
860
+ if (current && !v.force)
861
+ throw new CliError(`Already signed in as @${current.handle ?? "?"}. qotm logout first, or qotm login --force.`);
862
+ const color = useColor(io);
863
+ const muted = style.muted(color);
864
+ const start = await api(io, "/auth/device/code", {
865
+ method: "POST",
866
+ body: { client_id: CLIENT_ID }
867
+ });
868
+ if (start.status !== 200) throw new CliError(`Sign-in could not start: ${apiMessage(start)}.`);
869
+ const code2 = start.body;
870
+ const where = code2.verification_uri_complete ?? code2.verification_uri;
871
+ io.stdout(`To sign in, open ${code2.verification_uri} and enter:
872
+
873
+ ${style.bold(color)(code2.user_code)}
874
+
875
+ `);
876
+ if (!v["no-browser"] && io.openUrl) {
877
+ try {
878
+ await io.openUrl(where);
879
+ io.stdout(muted("opened in your browser; waiting for you to approve\u2026\n"));
880
+ } catch {
881
+ io.stdout(muted(`could not open a browser; go to ${where}
882
+ `));
883
+ }
884
+ } else io.stdout(muted("waiting for you to approve\u2026\n"));
885
+ let interval = Math.max(0, code2.interval) * 1e3;
886
+ const deadline = Date.now() + code2.expires_in * 1e3;
887
+ let token = null;
888
+ while (!token) {
889
+ if (Date.now() > deadline) throw new CliError("The code expired before it was approved. Run qotm login again.");
890
+ await sleep(interval);
891
+ const res = await api(
892
+ io,
893
+ "/auth/device/token",
894
+ {
895
+ method: "POST",
896
+ body: { grant_type: GRANT, device_code: code2.device_code, client_id: CLIENT_ID }
897
+ }
898
+ );
899
+ if (res.status === 200 && res.body.access_token) token = res.body.access_token;
900
+ else if (res.body?.error === "authorization_pending") continue;
901
+ else if (res.body?.error === "slow_down") interval += 5e3;
902
+ else if (res.body?.error === "expired_token")
903
+ throw new CliError("The code expired before it was approved (codes last 15 minutes). Run qotm login again.");
904
+ else if (res.body?.error === "access_denied")
905
+ throw new CliError("Sign-in was denied in the browser. Nothing changed here.");
906
+ else throw new CliError(`Sign-in failed: ${res.body?.error_description ?? res.body?.error ?? apiMessage(res)}.`);
907
+ }
908
+ const me = await api(io, "/v1/me", { token });
909
+ if (me.status !== 200) throw new CliError(`Signed in, but the account could not be read: ${apiMessage(me)}.`);
910
+ const dataset = heldVersion(io);
911
+ const device = await api(io, "/v1/me/devices", {
912
+ method: "POST",
913
+ token,
914
+ body: { label: deviceLabel(io.env) },
915
+ headers: dataset ? { "x-qotm-dataset": dataset } : {}
916
+ });
917
+ if (device.status !== 200 && device.status !== 201)
918
+ throw new CliError(`Signed in, but this terminal could not be registered: ${apiMessage(device)}.`);
919
+ writeSession(config2, {
920
+ token,
921
+ handle: me.body.handle ?? null,
922
+ deviceId: device.body.id ?? null,
923
+ api: setting(io, "api"),
924
+ signedInAt: (/* @__PURE__ */ new Date()).toISOString()
925
+ });
926
+ io.stdout(`signed in as @${me.body.handle}. ${muted("This terminal is listed under Settings \u203A Terminals.")}
927
+ `);
928
+ const f = await syncFavorites(io);
929
+ if (f.status === "synced") io.stdout(muted(`favorites: ${f.count}, synced with your account
930
+ `));
931
+ return 0;
932
+ }
933
+ };
934
+ var logout = {
935
+ name: "logout",
936
+ usage: "qotm logout",
937
+ summary: "sign out and revoke this terminal",
938
+ help: "Your local quotes, favorites and dataset stay on this machine.",
939
+ async run(args, io) {
940
+ parse(args, {});
941
+ const { config: config2 } = qotmPaths(io.env);
942
+ const session = readSession(config2);
943
+ if (!session) {
944
+ io.stdout("not signed in\n");
945
+ return 0;
946
+ }
947
+ const muted = style.muted(useColor(io));
948
+ let note = "";
949
+ try {
950
+ const res = await api(io, "/v1/me/devices/current", { method: "DELETE", token: session.token });
951
+ if (res.status !== 200 && res.status !== 204 && res.status !== 401)
952
+ note = ` The API answered ${res.status}; revoke it from Settings \u203A Terminals if it is still listed.`;
953
+ } catch {
954
+ note = " The API could not be reached; revoke it from Settings \u203A Terminals.";
955
+ }
956
+ clearSession(config2);
957
+ io.stdout(`signed out. ${muted(`Your local quotes, favorites and dataset stay.${note}`)}
958
+ `);
959
+ return 0;
960
+ }
961
+ };
962
+
963
+ // src/pick.ts
964
+ function pick(candidates, recent, random = Math.random) {
965
+ if (!candidates.length) return null;
966
+ const seen = new Set(recent);
967
+ const fresh = candidates.filter((id) => !seen.has(id));
968
+ if (fresh.length) return fresh[Math.floor(random() * fresh.length)];
969
+ const set = new Set(candidates);
970
+ return [...recent].reverse().find((id) => set.has(id)) ?? candidates[0];
971
+ }
972
+ function closest(input, options) {
973
+ let best = null;
974
+ let bestScore = Infinity;
975
+ for (const o of options) {
976
+ const d = distance(input, o);
977
+ if (d < bestScore) [best, bestScore] = [o, d];
978
+ }
979
+ return best && bestScore <= Math.max(2, Math.floor(input.length / 3)) ? best : null;
980
+ }
981
+ function distance(a, b) {
982
+ const row = Array.from({ length: b.length + 1 }, (_, j) => j);
983
+ for (let i = 1; i <= a.length; i++) {
984
+ let prev = row[0];
985
+ row[0] = i;
986
+ for (let j = 1; j <= b.length; j++) {
987
+ const cur = row[j];
988
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
989
+ prev = cur;
990
+ }
991
+ }
992
+ return row[b.length];
993
+ }
994
+
995
+ // src/quotes.ts
996
+ var fromDataset = (q, topics) => ({
997
+ id: q.id,
998
+ text: q.text,
999
+ type: q.type,
1000
+ author: q.author ? { name: q.author, slug: q.authorSlug } : null,
1001
+ contributor: q.contributor,
1002
+ work: q.work,
1003
+ year: q.year,
1004
+ source: q.source,
1005
+ sourceUrl: q.sourceUrl,
1006
+ topics
1007
+ });
1008
+ var fromPersonal = (p) => ({
1009
+ id: p.id,
1010
+ text: p.text,
1011
+ type: "personal",
1012
+ author: p.author ? { name: p.author, slug: null } : null,
1013
+ contributor: null,
1014
+ work: p.work,
1015
+ year: p.year,
1016
+ source: null,
1017
+ sourceUrl: null,
1018
+ topics: []
1019
+ });
1020
+ function credit(q) {
1021
+ if (q.author) return q.author.name;
1022
+ if (q.type === "original") return q.contributor ? `@${q.contributor}` : "a former member";
1023
+ return q.type === "personal" ? "you" : "unknown";
1024
+ }
1025
+ function sourceLine(q) {
1026
+ const work = [q.source ?? q.work, q.year].filter(Boolean).join(", ");
1027
+ const parts = [work, q.sourceUrl].filter(Boolean);
1028
+ return parts.length ? parts.join(" \xB7 ") : null;
1029
+ }
1030
+ function formatQuote(q, io, opts = {}) {
1031
+ const color = useColor(io, opts.plain);
1032
+ const columns = opts.columns ?? 80;
1033
+ if (!opts.plain && (io.isTTY || color) && columns >= MIN_COLUMNS) {
1034
+ const personal = q.type === "personal";
1035
+ return render(
1036
+ {
1037
+ text: q.text,
1038
+ credit: credit(q),
1039
+ source: opts.source ? sourceLine(q) ?? "no source on record" : null,
1040
+ ref: personal ? null : `q/${q.id.replace(/^q_/, "")}`,
1041
+ status: personal ? "private" : q.type === "original" ? "\u2713 original" : "\u2713 curated",
1042
+ topics: q.topics.map((t) => t.replace(/-/g, " "))
1043
+ },
1044
+ opts.style ?? "pull",
1045
+ columns,
1046
+ color
1047
+ );
1048
+ }
1049
+ const width = Math.max(20, Math.min(opts.columns ?? 80, 80) - 2);
1050
+ const text = color || io.isTTY && !opts.plain ? wrap(q.text, width) : q.text;
1051
+ const lines = [style.bold(color)(text), `${style.clay(color)("\u2014")} ${style.muted(color)(credit(q))}`];
1052
+ if (opts.source) {
1053
+ const where = sourceLine(q);
1054
+ lines.push(style.muted(color)(` ${where ?? "no source on record"}`));
1055
+ }
1056
+ return `${lines.join("\n")}
1057
+ `;
1058
+ }
1059
+ function jsonQuote(q, io, dataset) {
1060
+ return `${JSON.stringify({
1061
+ id: q.id,
1062
+ text: q.text,
1063
+ type: q.type,
1064
+ author: q.author,
1065
+ contributor: q.contributor,
1066
+ work: q.work,
1067
+ year: q.year,
1068
+ source: q.source,
1069
+ sourceUrl: q.sourceUrl,
1070
+ topics: q.topics,
1071
+ url: quoteUrl(io, q.id),
1072
+ dataset: q.type === "personal" ? null : dataset?.meta.version ?? null
1073
+ })}
1074
+ `;
1075
+ }
1076
+
1077
+ // src/commands/quote.ts
1078
+ var OUTPUT = {
1079
+ style: { type: "string" },
1080
+ plain: { type: "boolean" },
1081
+ json: { type: "boolean" },
1082
+ source: { type: "boolean" }
1083
+ };
1084
+ var OPTIONS = {
1085
+ topic: { type: "string" },
1086
+ author: { type: "string" },
1087
+ favorites: { type: "boolean" },
1088
+ personal: { type: "boolean" },
1089
+ ...OUTPUT
1090
+ };
1091
+ function print(q, io, out, dataset) {
1092
+ const drawn = SETTINGS.style.parse(out.style ?? setting(io, "style"));
1093
+ io.stdout(
1094
+ out.json ? jsonQuote(q, io, dataset) : formatQuote(q, io, { plain: out.plain, source: out.source, style: drawn, columns: io.columns })
1095
+ );
1096
+ }
1097
+ function unknown(kind, slug, options) {
1098
+ const near = closest(slug, options);
1099
+ throw new CliError(
1100
+ `No ${kind} "${slug}" in your dataset.${near ? ` Did you mean ${near}?` : ""} ${kind === "topic" ? `Topics: ${options.join(", ")}.` : "Authors go by slug: qotm --author dijkstra."}`,
1101
+ 2
1102
+ );
1103
+ }
1104
+ var quote = {
1105
+ name: "quote",
1106
+ usage: "qotm [--topic <slug>] [--author <slug>] [--favorites | --personal] [--source] [--style <name>] [--plain | --json]",
1107
+ summary: "print a quote (the default)",
1108
+ help: `
1109
+ Prints one curated quote from the local dataset, avoiding the last 50 shown
1110
+ (qotm config set history <n>).
1111
+
1112
+ --topic <slug> from one topic: qotm --topic debugging
1113
+ --author <slug> by one author: qotm --author dijkstra
1114
+ --favorites from your favorites (qotm save)
1115
+ --personal from your private quotes (qotm add)
1116
+ --source add where it is from
1117
+ --style <name> draw it as pull (the default), bubble, card or slab, this
1118
+ once (qotm config set style <name> to keep one)
1119
+ --plain just the words and the credit: no shape, colour or
1120
+ wrapping (also when piped)
1121
+ --json one JSON object: id, text, type, author, contributor, work,
1122
+ year, source, sourceUrl, topics, url, dataset
1123
+ `,
1124
+ run(args, io) {
1125
+ const { values: v } = parse(args, OPTIONS);
1126
+ if (v.favorites && v.personal) throw new CliError("Choose --favorites or --personal, not both.", 2);
1127
+ const { data } = qotmPaths(io.env);
1128
+ const size = Number(setting(io, "history"));
1129
+ const recent = size ? readHistory(data).shown.slice(0, size).map((s) => s.id) : [];
1130
+ if (v.personal) {
1131
+ if (v.topic || v.author)
1132
+ throw new CliError("--topic and --author pick from the dataset, not your private quotes.", 2);
1133
+ const { quotes } = readPersonal(data);
1134
+ const id = pick(
1135
+ quotes.map((q) => q.id),
1136
+ recent
1137
+ );
1138
+ if (!id) throw new CliError('You have no private quotes yet. Keep one with qotm add "<text>".');
1139
+ print(fromPersonal(quotes.find((q) => q.id === id)), io, v, null);
1140
+ remember(data, id, /* @__PURE__ */ new Date(), size);
1141
+ return 0;
1142
+ }
1143
+ const { dataset, warning } = openDataset(data);
1144
+ try {
1145
+ if (warning) io.stderr(`qotm: ${warning}
1146
+ `);
1147
+ if (v.topic && !dataset.topics().includes(v.topic)) unknown("topic", v.topic, dataset.topics());
1148
+ if (v.author && !dataset.authors().includes(v.author)) unknown("author", v.author, dataset.authors());
1149
+ const favorites2 = v.favorites ? readFavorites(data).favorites.map((f) => f.id) : void 0;
1150
+ const candidates = dataset.candidates({ topic: v.topic, author: v.author, ids: favorites2 });
1151
+ const id = pick(candidates, recent);
1152
+ if (!id) {
1153
+ if (v.favorites)
1154
+ throw new CliError(
1155
+ favorites2.length ? "None of your favorites are in this dataset (with these filters)." : "You have no favorites yet. Save the last quote with qotm save."
1156
+ );
1157
+ throw new CliError("No quotes match. Try without --topic or --author.");
1158
+ }
1159
+ print(fromDataset(dataset.quote(id), dataset.topicsOf(id)), io, v, dataset);
1160
+ remember(data, id, /* @__PURE__ */ new Date(), size);
1161
+ return 0;
1162
+ } finally {
1163
+ dataset.close();
1164
+ }
1165
+ }
1166
+ };
1167
+ var last = {
1168
+ name: "last",
1169
+ usage: "qotm last [--source] [--style <name>] [--plain | --json]",
1170
+ summary: "show the last quote again",
1171
+ run(args, io) {
1172
+ const { values: v } = parse(args, OUTPUT);
1173
+ const { data } = qotmPaths(io.env);
1174
+ const [latest] = readHistory(data).shown;
1175
+ if (!latest) throw new CliError("Nothing shown yet. Run qotm for a quote.");
1176
+ if (latest.id.startsWith("p_")) {
1177
+ const p = readPersonal(data).quotes.find((q) => q.id === latest.id);
1178
+ if (!p) throw new CliError("That private quote is gone. Run qotm for another.");
1179
+ print(fromPersonal(p), io, v, null);
1180
+ return 0;
1181
+ }
1182
+ const { dataset } = openDataset(data);
1183
+ try {
1184
+ const q = dataset.quote(latest.id);
1185
+ if (!q) throw new CliError(`${latest.id} is no longer in your dataset. Run qotm for another.`);
1186
+ print(fromDataset(q, dataset.topicsOf(q.id)), io, v, dataset);
1187
+ return 0;
1188
+ } finally {
1189
+ dataset.close();
1190
+ }
1191
+ }
1192
+ };
1193
+
1194
+ // src/submission.ts
1195
+ var QUOTE_MARKS = /^["“”„'‘’«»]+|["“”„'‘’«»]+$/g;
1196
+ var cleanText = (text) => text.replace(/\s+/g, " ").trim().replace(QUOTE_MARKS, "").trim();
1197
+ var topicList = (values) => [
1198
+ ...new Set(
1199
+ values.flatMap((v) => v.split(",")).map((t) => t.trim()).filter(Boolean)
1200
+ )
1201
+ ];
1202
+ function validate(input, knownTopics2) {
1203
+ const e = {};
1204
+ const text = cleanText(input.text);
1205
+ if (text.length < 3) e.text = "give the quote, at least 3 characters";
1206
+ else if (text.length > 280) e.text = `at most 280 characters (this is ${text.length})`;
1207
+ if (!input.topics.length) e.topic = "choose at least one topic (--topic debugging)";
1208
+ else if (input.topics.length > 3) e.topic = "at most three topics";
1209
+ else if (knownTopics2?.length) {
1210
+ const unknown2 = input.topics.filter((t) => !knownTopics2.includes(t));
1211
+ if (unknown2.length)
1212
+ e.topic = `unknown topic ${unknown2.join(", ")} (qotm search --web, or: ${knownTopics2.join(", ")})`;
1213
+ }
1214
+ if (input.original) {
1215
+ if (!input.rights) e.rights = "confirm you wrote this quote and may publish it (--rights)";
1216
+ for (const [field, value] of [
1217
+ ["author", input.author],
1218
+ ["source", input.source]
1219
+ ])
1220
+ if (value) e[field] = "an original is yours: leave out --author and --source";
1221
+ return e;
1222
+ }
1223
+ const author = input.author?.trim() ?? "";
1224
+ if (author.length < 2) e.author = 'who said or wrote it (--author "Donald E. Knuth")';
1225
+ else if (author.length > 160) e.author = "at most 160 characters";
1226
+ const source = input.source?.trim() ?? "";
1227
+ if (source.length < 2)
1228
+ e.source = 'where it was said or written (--source "Structured Programming with go to Statements")';
1229
+ else if (source.length > 300) e.source = "at most 300 characters";
1230
+ if (input.year !== void 0 && (!/^-?\d{1,4}$/.test(input.year) || Number(input.year) > 2100 || Number(input.year) < -3e3))
1231
+ e.year = "a year, like 1974";
1232
+ if (input.url !== void 0) {
1233
+ try {
1234
+ const u = new URL(input.url);
1235
+ if (u.protocol !== "https:" && u.protocol !== "http:") throw new Error();
1236
+ } catch {
1237
+ e.url = "a link to the source, starting with https://";
1238
+ }
1239
+ }
1240
+ if (input.context && input.context.trim().length > 1e3) e.context = "at most 1000 characters";
1241
+ return e;
1242
+ }
1243
+ function body(input) {
1244
+ const text = cleanText(input.text);
1245
+ if (input.original) return { type: "original", text, topics: input.topics, rights: true };
1246
+ return {
1247
+ type: "canonical",
1248
+ text,
1249
+ attribution: input.author.trim(),
1250
+ source: input.source.trim(),
1251
+ ...input.year ? { year: Number(input.year) } : {},
1252
+ ...input.url ? { sourceUrl: input.url } : {},
1253
+ ...input.context?.trim() ? { context: input.context.trim() } : {},
1254
+ topics: input.topics
1255
+ };
1256
+ }
1257
+ function apiFieldError(message) {
1258
+ const m = /^Validation: (\w+)(?::| -) (.+)$/.exec(message);
1259
+ if (!m) return null;
1260
+ const field = { attribution: "author", sourceUrl: "url", topics: "topic" }[m[1]] ?? m[1];
1261
+ return [field, m[2]];
1262
+ }
1263
+
1264
+ // src/commands/submit.ts
1265
+ var fieldLines = (errors) => Object.entries(errors).map(([field, problem]) => ` ${field.padEnd(8)}${problem}`).join("\n");
1266
+ function knownTopics(io) {
1267
+ try {
1268
+ const { dataset } = openDataset(qotmPaths(io.env).data);
1269
+ const topics = dataset.topics();
1270
+ dataset.close();
1271
+ return topics;
1272
+ } catch {
1273
+ return [];
1274
+ }
1275
+ }
1276
+ async function showMatch(io, token, text, original) {
1277
+ const res = await api(io, "/v1/quotes/match", { method: "POST", token, body: { text } });
1278
+ if (res.status === 401) throw new CliError("Your session ended. Run qotm login, then try again.");
1279
+ if (res.status !== 200) throw new CliError(`The match check failed: ${apiMessage(res)}.`);
1280
+ if (!res.body.match) return null;
1281
+ const m = res.body;
1282
+ const color = useColor(io);
1283
+ const how = m.method === "exact" ? "the same words" : `${Math.round((m.score ?? 0) * 100)}% similar`;
1284
+ io.stdout(
1285
+ `${style.clay(color)("possible match")} ${style.muted(color)(`(${how}):`)} ${m.match.id} \u201C${m.match.text}\u201D${m.match.author ? ` \u2014 ${m.match.author}` : ""}
1286
+ ` + style.muted(color)(
1287
+ original ? "Declared original, but it matches a quote on qotm: a moderator checks it before it is published.\n" : "It may already be on qotm; a verifier will compare them.\n"
1288
+ )
1289
+ );
1290
+ return m;
1291
+ }
1292
+ var submit = {
1293
+ name: "submit",
1294
+ usage: 'qotm submit "<text>" --author <name> --source <title> --topic <slug> [--year] [--url] [--context]\n qotm submit --original "<text>" --topic <slug> --rights\n qotm submit <p_id> --topic <slug> --rights',
1295
+ summary: "send a quote for review",
1296
+ help: `
1297
+ Canonical quotes (someone else's words) go to a verifier, who checks the
1298
+ source; originals (your own words) get an originality check. Signing in is
1299
+ needed: qotm login.
1300
+
1301
+ --author <name> who said or wrote it
1302
+ --source <title> where: a book, paper, talk or post
1303
+ --year <yyyy> when (optional)
1304
+ --url <link> a link to the source (optional)
1305
+ --context <text> what surrounded it (optional)
1306
+ --topic <slug> one to three topics: --topic debugging,unix
1307
+ --original your own words; needs --rights
1308
+ --rights "I wrote this quote and have the right to publish it"
1309
+ --check only show whether it matches a quote already on qotm (signed in)
1310
+ --json print the created quote as JSON
1311
+
1312
+ A private quote's id (p_\u2026, from qotm add) submits it as your original.
1313
+ `,
1314
+ async run(args, io) {
1315
+ const { values: v, positionals } = parse(
1316
+ args,
1317
+ {
1318
+ author: { type: "string" },
1319
+ source: { type: "string" },
1320
+ year: { type: "string" },
1321
+ url: { type: "string" },
1322
+ context: { type: "string" },
1323
+ topic: { type: "string", multiple: true },
1324
+ original: { type: "boolean" },
1325
+ rights: { type: "boolean" },
1326
+ check: { type: "boolean" },
1327
+ json: { type: "boolean" }
1328
+ },
1329
+ true
1330
+ );
1331
+ let text = positionals.join(" ");
1332
+ let original = Boolean(v.original);
1333
+ if (/^p_[a-z0-9]{8}$/.test(text.trim())) {
1334
+ const kept = readPersonal(qotmPaths(io.env).data).quotes.find((q) => q.id === text.trim());
1335
+ if (!kept) throw new CliError(`No private quote ${text.trim()} here. qotm --personal --json shows yours.`, 2);
1336
+ text = kept.text;
1337
+ original = true;
1338
+ }
1339
+ const input = {
1340
+ original,
1341
+ text,
1342
+ author: v.author,
1343
+ source: v.source,
1344
+ year: v.year,
1345
+ url: v.url,
1346
+ context: v.context,
1347
+ topics: topicList(v.topic ?? []),
1348
+ rights: v.rights
1349
+ };
1350
+ const session = readSession(qotmPaths(io.env).config);
1351
+ if (v.check) {
1352
+ if (cleanLength(text) < 3) throw new CliError('Give the quote to check: qotm submit --check "<text>"', 2);
1353
+ if (!session) throw new CliError("Sign in to check for matches: qotm login.");
1354
+ const m = await showMatch(io, session.token, text, original);
1355
+ if (!m) io.stdout("no match: nothing like it on qotm yet\n");
1356
+ return 0;
1357
+ }
1358
+ const errors = validate(input, knownTopics(io));
1359
+ if (Object.keys(errors).length)
1360
+ throw new CliError(
1361
+ `the quote was not sent. Fix:
1362
+ ${fieldLines(errors)}
1363
+ qotm submit --help shows every option.`,
1364
+ 2
1365
+ );
1366
+ if (!session)
1367
+ throw new CliError("Sign in to submit: qotm login. (qotm add keeps a quote on this machine meanwhile.)");
1368
+ await showMatch(io, session.token, input.text, original);
1369
+ const res = await api(io, "/v1/submissions", {
1370
+ method: "POST",
1371
+ token: session.token,
1372
+ body: body(input)
1373
+ });
1374
+ if (res.status === 401) throw new CliError("Your session ended. Run qotm login, then submit again.");
1375
+ if (res.status === 400) {
1376
+ const field = apiFieldError(apiMessage(res));
1377
+ throw new CliError(
1378
+ `the quote was not accepted:
1379
+ ${field ? fieldLines({ [field[0]]: field[1] }) : ` ${apiMessage(res)}`}`,
1380
+ 2
1381
+ );
1382
+ }
1383
+ if (res.status !== 200 && res.status !== 201) throw new CliError(`The quote was not sent: ${apiMessage(res)}.`);
1384
+ if (v.json) {
1385
+ io.stdout(`${JSON.stringify(res.body)}
1386
+ `);
1387
+ return 0;
1388
+ }
1389
+ const color = useColor(io);
1390
+ const next = original ? "It gets an originality check, then goes public." : "A verifier checks the source; you get an email when they have decided.";
1391
+ io.stdout(
1392
+ `submitted ${res.body.id} ${style.muted(color)(`(${res.body.status.toLowerCase().replace(/_/g, " ")})`)}
1393
+ ${style.muted(color)(`${next} ${siteUrl(io)}/my-quotes`)}
1394
+ `
1395
+ );
1396
+ return 0;
1397
+ }
1398
+ };
1399
+ var cleanLength = (text) => text.replace(/\s+/g, " ").trim().length;
1400
+
1401
+ // src/commands/sync.ts
1402
+ import { createHash } from "node:crypto";
1403
+ import { createWriteStream, mkdirSync as mkdirSync2, readdirSync, renameSync as renameSync2, rmSync as rmSync2 } from "node:fs";
1404
+ import { join as join6 } from "node:path";
1405
+ import { Readable } from "node:stream";
1406
+ import { pipeline } from "node:stream/promises";
1407
+ var TEMP = /^qotm\.sqlite\.download-\d+$/;
1408
+ var n = (v) => v.toLocaleString("en-US");
1409
+ function held(data) {
1410
+ const state = readSync(data);
1411
+ if (!state) return null;
1412
+ try {
1413
+ Dataset.open(join6(data, "qotm.sqlite")).close();
1414
+ return { version: state.version, etag: state.etag };
1415
+ } catch {
1416
+ return null;
1417
+ }
1418
+ }
1419
+ async function download(m, file, onProgress) {
1420
+ let res;
1421
+ try {
1422
+ res = await fetch(m.url, { headers: { "user-agent": `qotm/${VERSION}` }, signal: AbortSignal.timeout(3e5) });
1423
+ } catch {
1424
+ throw new CliError(
1425
+ `The dataset could not be downloaded from ${new URL(m.url).host}. Your current quotes are unchanged.`
1426
+ );
1427
+ }
1428
+ if (!res.ok || !res.body)
1429
+ throw new CliError(`The dataset download failed (HTTP ${res.status}). Your current quotes are unchanged.`);
1430
+ const hash = createHash("sha256");
1431
+ let bytes = 0;
1432
+ const body2 = Readable.fromWeb(res.body);
1433
+ body2.on("data", (chunk) => {
1434
+ hash.update(chunk);
1435
+ bytes += chunk.length;
1436
+ if (bytes > m.size) body2.destroy(new CliError("The download is larger than the manifest says. It was refused."));
1437
+ else onProgress(bytes);
1438
+ });
1439
+ try {
1440
+ await pipeline(body2, createWriteStream(file));
1441
+ } catch (err) {
1442
+ if (err instanceof CliError) throw err;
1443
+ throw new CliError(`The download was interrupted (${err.message}). Your current quotes are unchanged.`);
1444
+ }
1445
+ if (bytes !== m.size)
1446
+ throw new CliError(`The download stopped at ${n(bytes)} of ${n(m.size)} bytes. Your current quotes are unchanged.`);
1447
+ if (hash.digest("hex") !== m.sha256)
1448
+ throw new CliError(
1449
+ "The download does not match the manifest's sha256, so it was refused. Your current quotes are unchanged."
1450
+ );
1451
+ }
1452
+ var sync = {
1453
+ name: "sync",
1454
+ usage: "qotm sync [--force] [--json]",
1455
+ summary: "update your quotes to the latest dataset release",
1456
+ help: `
1457
+ Fetches the latest dataset release (qotm.sqlite), checks it against its
1458
+ manifest (size and sha256), and replaces your copy in one step: an
1459
+ interrupted or mismatched download leaves your quotes as they were.
1460
+
1461
+ --force download even when you already have this release
1462
+ --json print what was done as JSON
1463
+ `,
1464
+ async run(args, io) {
1465
+ const { values: v } = parse(args, { force: { type: "boolean" }, json: { type: "boolean" } });
1466
+ const { config: config2, data } = qotmPaths(io.env);
1467
+ mkdirSync2(data, { recursive: true });
1468
+ for (const f of readdirSync(data)) if (TEMP.test(f)) rmSync2(join6(data, f), { force: true });
1469
+ const session = readSession(config2);
1470
+ const have = held(data);
1471
+ const res = await api(io, "/v1/dataset/manifest", {
1472
+ token: session?.token,
1473
+ headers: {
1474
+ ...have && !v.force && have.etag ? { "if-none-match": have.etag } : {},
1475
+ ...session && have ? { "x-qotm-dataset": have.version } : {}
1476
+ }
1477
+ });
1478
+ const report = async (out, text) => {
1479
+ const favorites2 = await syncFavorites(io);
1480
+ io.stdout(v.json ? `${JSON.stringify({ ...out, favorites: favorites2 })}
1481
+ ` : text + favoritesLine(io, favorites2));
1482
+ return 0;
1483
+ };
1484
+ if (res.status === 404)
1485
+ return report(
1486
+ { updated: false, reason: "no-release" },
1487
+ "No dataset release has been published yet; you have the quotes that came with qotm.\n"
1488
+ );
1489
+ if (res.status === 304)
1490
+ return report({ updated: false, version: have.version }, `already up to date (${have.version})
1491
+ `);
1492
+ if (res.status !== 200) throw new CliError(`The dataset manifest could not be read: ${apiMessage(res)}.`);
1493
+ const m = res.body;
1494
+ if (m.schemaVersion !== SCHEMA_VERSION)
1495
+ throw new CliError(
1496
+ `Release ${m.version} uses dataset schema ${m.schemaVersion}; this qotm reads schema ${SCHEMA_VERSION}. Update qotm: npm i -g qotm`
1497
+ );
1498
+ const etag = res.headers.get("etag");
1499
+ const current = readSync(data);
1500
+ if (!v.force && have && current?.version === m.version && current.sha256 === m.sha256) {
1501
+ writeSync(data, { ...current, etag, syncedAt: (/* @__PURE__ */ new Date()).toISOString() });
1502
+ return report({ updated: false, version: m.version }, `already up to date (${m.version})
1503
+ `);
1504
+ }
1505
+ const temp = join6(data, `qotm.sqlite.download-${process.pid}`);
1506
+ const cleanup = () => rmSync2(temp, { force: true });
1507
+ process.once("SIGINT", cleanup);
1508
+ const color = useColor(io);
1509
+ const progress = io.isTTY && !v.json;
1510
+ try {
1511
+ let last2 = -1;
1512
+ await download(m, temp, (bytes) => {
1513
+ const pct = Math.floor(bytes / m.size * 100);
1514
+ if (progress && pct !== last2) {
1515
+ last2 = pct;
1516
+ io.stdout(`\r${style.muted(color)(`downloading ${m.version}\u2026 ${pct}%`)}`);
1517
+ }
1518
+ });
1519
+ if (progress) io.stdout("\r\x1B[2K");
1520
+ try {
1521
+ const ds = Dataset.open(temp);
1522
+ const version = ds.meta.version;
1523
+ ds.close();
1524
+ if (version !== m.version) throw new DatasetError(`it holds ${version}, not ${m.version}`, "corrupt");
1525
+ } catch (err) {
1526
+ throw new CliError(`The download is not a usable dataset (${err.message}). It was refused.`);
1527
+ }
1528
+ renameSync2(temp, join6(data, "qotm.sqlite"));
1529
+ } catch (err) {
1530
+ if (progress) io.stdout("\r\x1B[2K");
1531
+ throw err;
1532
+ } finally {
1533
+ cleanup();
1534
+ process.removeListener("SIGINT", cleanup);
1535
+ }
1536
+ writeSync(data, {
1537
+ version: m.version,
1538
+ schemaVersion: m.schemaVersion,
1539
+ sha256: m.sha256,
1540
+ size: m.size,
1541
+ publishedAt: m.publishedAt,
1542
+ etag,
1543
+ syncedAt: (/* @__PURE__ */ new Date()).toISOString()
1544
+ });
1545
+ const c = m.changes;
1546
+ return report(
1547
+ { updated: true, version: m.version, from: have?.version ?? null, changes: c },
1548
+ `synced ${m.version}: ${n(c.eligible)} quotes ${style.muted(color)(`(+${n(c.added)} new, ~${n(c.corrected)} corrected, \u2212${n(c.withdrawn)} withdrawn)`)}
1549
+ `
1550
+ );
1551
+ }
1552
+ };
1553
+ function favoritesLine(io, f) {
1554
+ const m = style.muted(useColor(io));
1555
+ if (f.status === "signed-out") return "";
1556
+ if (f.status === "queued")
1557
+ return m(`favorites: ${f.pending} change${f.pending === 1 ? "" : "s"} waiting (${f.reason})
1558
+ `);
1559
+ const skipped = f.skipped.length ? `; ${f.skipped.length} not on qotm.dev, kept here only` : "";
1560
+ return m(`favorites: ${f.count}, synced with @${f.handle ?? "your account"}${skipped}
1561
+ `);
1562
+ }
1563
+
1564
+ // src/commands/tools.ts
1565
+ import { createHash as createHash2 } from "node:crypto";
1566
+ import { accessSync, constants, mkdirSync as mkdirSync3, readFileSync as readFileSync2, rmSync as rmSync3, writeFileSync as writeFileSync2 } from "node:fs";
1567
+ import { join as join7 } from "node:path";
1568
+ var JSON_OPT = { json: { type: "boolean" } };
1569
+ async function openInBrowser2(io, url2) {
1570
+ if (!io.openUrl) throw new CliError(`Open ${url2} in your browser.`);
1571
+ try {
1572
+ await io.openUrl(url2);
1573
+ } catch {
1574
+ io.stdout(`could not open a browser; the address is ${url2}
1575
+ `);
1576
+ }
1577
+ }
1578
+ var open = {
1579
+ name: "open",
1580
+ usage: "qotm open [<id>]",
1581
+ summary: "open the last quote (or <id>) on qotm.dev",
1582
+ help: "Its page has the source, the context and who verified it.",
1583
+ async run(args, io) {
1584
+ const { positionals } = parse(args, {}, true);
1585
+ let id = positionals[0];
1586
+ if (id && !id.startsWith("q_")) id = `q_${id}`;
1587
+ if (!id) {
1588
+ const [latest] = readHistory(qotmPaths(io.env).data).shown;
1589
+ if (!latest) throw new CliError("Nothing shown yet. Run qotm for a quote, then qotm open.");
1590
+ id = latest.id;
1591
+ }
1592
+ const url2 = quoteUrl(io, id);
1593
+ if (!url2) throw new CliError("That is one of your private quotes; it has no page on qotm.dev.");
1594
+ io.stdout(style.muted(useColor(io))(`opening ${id.replace(/^q_/, "q/")} in your browser
1595
+ `));
1596
+ await openInBrowser2(io, url2);
1597
+ return 0;
1598
+ }
1599
+ };
1600
+ var search = {
1601
+ name: "search",
1602
+ usage: "qotm search <text> [--limit <n>] [--web] [--plain | --json]",
1603
+ summary: "search the quotes in your dataset",
1604
+ help: `
1605
+ Searches the text, author and work of every quote in your dataset, offline;
1606
+ each word also matches as a prefix (debug finds debugging).
1607
+
1608
+ --limit <n> at most n results (10)
1609
+ --web open the search on qotm.dev instead
1610
+ --json a list of { id, text, author, authorSlug, work, year, url }
1611
+ `,
1612
+ async run(args, io) {
1613
+ const { values: v, positionals } = parse(
1614
+ args,
1615
+ { limit: { type: "string" }, web: { type: "boolean" }, plain: { type: "boolean" }, ...JSON_OPT },
1616
+ true
1617
+ );
1618
+ const text = positionals.join(" ").trim();
1619
+ if (!text) throw new CliError("What should qotm search for? qotm search complexity", 2);
1620
+ if (v.web) {
1621
+ const url2 = `${siteUrl(io)}/search?q=${encodeURIComponent(text)}`;
1622
+ io.stdout(style.muted(useColor(io))(`opening the search for "${text}" in your browser
1623
+ `));
1624
+ await openInBrowser2(io, url2);
1625
+ return 0;
1626
+ }
1627
+ const limit = v.limit === void 0 ? 10 : Number(v.limit);
1628
+ if (!Number.isInteger(limit) || limit < 1) throw new CliError("--limit is a number of results, like 10.", 2);
1629
+ const { dataset, warning } = openDataset(qotmPaths(io.env).data);
1630
+ try {
1631
+ if (warning) io.stderr(`qotm: ${warning}
1632
+ `);
1633
+ const found = dataset.search(text, limit);
1634
+ if (v.json) {
1635
+ io.stdout(
1636
+ `${JSON.stringify(
1637
+ found.map((q) => ({
1638
+ id: q.id,
1639
+ text: q.text,
1640
+ author: q.author,
1641
+ authorSlug: q.authorSlug,
1642
+ work: q.work,
1643
+ year: q.year,
1644
+ url: quoteUrl(io, q.id)
1645
+ }))
1646
+ )}
1647
+ `
1648
+ );
1649
+ return 0;
1650
+ }
1651
+ if (!found.length) {
1652
+ io.stdout(
1653
+ `Nothing in your dataset matches "${text}". qotm search ${JSON.stringify(text)} --web searches qotm.dev.
1654
+ `
1655
+ );
1656
+ return 0;
1657
+ }
1658
+ const color = useColor(io, v.plain);
1659
+ for (const q of found)
1660
+ io.stdout(
1661
+ `${style.muted(color)(q.id)} ${q.text} ${style.clay(color)("\u2014")} ${q.author ?? (q.contributor ? `@${q.contributor}` : "a former member")}
1662
+ `
1663
+ );
1664
+ return 0;
1665
+ } finally {
1666
+ dataset.close();
1667
+ }
1668
+ }
1669
+ };
1670
+ var status = {
1671
+ name: "status",
1672
+ usage: "qotm status [--json]",
1673
+ summary: "show your dataset, last sync and account",
1674
+ async run(args, io) {
1675
+ const { values: v } = parse(args, JSON_OPT);
1676
+ const { config: config2, data } = qotmPaths(io.env);
1677
+ const { dataset, from, warning } = openDataset(data);
1678
+ const sync2 = readSync(data);
1679
+ const session = readSession(config2);
1680
+ const favorites2 = readFavorites(data);
1681
+ const report = {
1682
+ dataset: {
1683
+ version: dataset.meta.version,
1684
+ schemaVersion: dataset.meta.schemaVersion,
1685
+ from,
1686
+ quotes: dataset.count(),
1687
+ file: dataset.file
1688
+ },
1689
+ lastSync: sync2?.syncedAt ?? null,
1690
+ account: session ? { handle: session.handle, api: session.api } : null,
1691
+ favorites: favorites2.favorites.length,
1692
+ pendingFavoriteChanges: favorites2.pending.add.length + favorites2.pending.remove.length,
1693
+ privateQuotes: readPersonal(data).quotes.length,
1694
+ api: setting(io, "api")
1695
+ };
1696
+ dataset.close();
1697
+ if (v.json) {
1698
+ io.stdout(`${JSON.stringify(report)}
1699
+ `);
1700
+ return 0;
1701
+ }
1702
+ const m = style.muted(useColor(io));
1703
+ const lines = [
1704
+ [
1705
+ "dataset",
1706
+ `${report.dataset.version} \xB7 ${report.dataset.quotes} quotes${from === "snapshot" ? " (the copy that came with qotm)" : ""}`
1707
+ ],
1708
+ ["last sync", report.lastSync ? report.lastSync.replace("T", " ").slice(0, 16) : "never (qotm sync)"],
1709
+ ["account", session ? `@${session.handle ?? "?"}` : "not signed in (qotm login)"],
1710
+ [
1711
+ "favorites",
1712
+ `${report.favorites}${report.pendingFavoriteChanges ? ` (${report.pendingFavoriteChanges} to sync)` : ""}`
1713
+ ],
1714
+ ["private quotes", String(report.privateQuotes)]
1715
+ ];
1716
+ if (warning) io.stderr(`qotm: ${warning}
1717
+ `);
1718
+ for (const [k, val] of lines) io.stdout(`${m(k.padEnd(15))}${val}
1719
+ `);
1720
+ return 0;
1721
+ }
1722
+ };
1723
+ var config = {
1724
+ name: "config",
1725
+ usage: "qotm config [get <key> | set <key> <value> | unset <key>] [--json]",
1726
+ summary: "show or change settings",
1727
+ help: `
1728
+ Settings (qotm config set <key> <value>):
1729
+ ${KEYS.map((k) => ` ${k.padEnd(9)}${SETTINGS[k].describe} (default ${SETTINGS[k].default})`).join("\n")}
1730
+
1731
+ QOTM_API_URL and QOTM_SITE_URL override api and site for one run.
1732
+ `,
1733
+ run(args, io) {
1734
+ const { values: v, positionals } = parse(args, JSON_OPT, true);
1735
+ const [action, key, value] = positionals;
1736
+ if (!action || action === "list") {
1737
+ const all = KEYS.map((k) => resolve(io, k));
1738
+ if (v.json) io.stdout(`${JSON.stringify(Object.fromEntries(all.map((r) => [r.key, r.value])))}
1739
+ `);
1740
+ else
1741
+ for (const r of all)
1742
+ io.stdout(
1743
+ `${r.key.padEnd(9)}${r.value}${r.from === "default" ? " (default)" : r.from === "env" ? " (from the environment)" : ""}
1744
+ `
1745
+ );
1746
+ return 0;
1747
+ }
1748
+ if (action === "get") {
1749
+ const r = resolve(io, assertKey(key));
1750
+ io.stdout(v.json ? `${JSON.stringify({ [r.key]: r.value })}
1751
+ ` : `${r.value}
1752
+ `);
1753
+ return 0;
1754
+ }
1755
+ if (action === "set") {
1756
+ const k = assertKey(key);
1757
+ if (value === void 0) throw new CliError(`Give the value: qotm config set ${k} <value>.`, 2);
1758
+ io.stdout(`${k} = ${setSetting(io, k, value)}
1759
+ `);
1760
+ return 0;
1761
+ }
1762
+ if (action === "unset") {
1763
+ const k = assertKey(key);
1764
+ unsetSetting(io, k);
1765
+ io.stdout(`${k} = ${SETTINGS[k].default} (default)
1766
+ `);
1767
+ return 0;
1768
+ }
1769
+ throw new CliError(`Unknown config action "${action}": get, set, unset or list.`, 2);
1770
+ }
1771
+ };
1772
+ var sha256 = (file) => createHash2("sha256").update(readFileSync2(file)).digest("hex");
1773
+ var NODE_SUPPORTED = "22.16+ or 24+";
1774
+ function nodeOk(version) {
1775
+ const [major = 0, minor = 0] = version.replace(/^v/, "").split(".").map(Number);
1776
+ return major === 22 && minor >= 16 || major >= 24;
1777
+ }
1778
+ var doctor = {
1779
+ name: "doctor",
1780
+ usage: "qotm doctor [--offline] [--json]",
1781
+ summary: "check the install: Node, folders, dataset, API",
1782
+ help: `
1783
+ Checks, and names any problem:
1784
+ node Node.js ${NODE_SUPPORTED}
1785
+ folders the config and data folders can be written
1786
+ dataset the dataset opens, and its sha256 matches its manifest
1787
+ api the API answers (skipped with --offline)
1788
+ `,
1789
+ async run(args, io) {
1790
+ const { values: v } = parse(args, { offline: { type: "boolean" }, ...JSON_OPT });
1791
+ const checks = [];
1792
+ const add2 = (name, ok, detail) => checks.push({ name, ok, detail });
1793
+ add2(
1794
+ "node",
1795
+ nodeOk(process.version),
1796
+ nodeOk(process.version) ? process.version : `${process.version}: qotm needs Node.js ${NODE_SUPPORTED}`
1797
+ );
1798
+ const paths = qotmPaths(io.env);
1799
+ for (const [label, dir] of [
1800
+ ["config folder", paths.config],
1801
+ ["data folder", paths.data]
1802
+ ]) {
1803
+ try {
1804
+ mkdirSync3(dir, { recursive: true });
1805
+ accessSync(dir, constants.W_OK);
1806
+ const probe = join7(dir, `.doctor-${process.pid}`);
1807
+ writeFileSync2(probe, "ok");
1808
+ rmSync3(probe);
1809
+ add2(label, true, dir);
1810
+ } catch (err) {
1811
+ add2(label, false, `${dir} cannot be written: ${err.code ?? err.message}`);
1812
+ }
1813
+ }
1814
+ try {
1815
+ const { dataset, from, warning } = openDataset(paths.data);
1816
+ const file = dataset.file;
1817
+ const label = `${dataset.meta.version}, ${dataset.count()} quotes`;
1818
+ dataset.close();
1819
+ const expected = from === "local" ? readSync(paths.data)?.sha256 : snapshotSha();
1820
+ if (warning) add2("dataset", false, warning);
1821
+ else if (!expected) add2("dataset", true, `${label} (no manifest to check against; qotm sync records one)`);
1822
+ else if (sha256(file) !== expected)
1823
+ add2("dataset", false, `${file} does not match its manifest's sha256: run qotm sync to fetch it again`);
1824
+ else add2("dataset", true, `${label}${from === "snapshot" ? ", the copy that came with qotm" : ""}, sha256 ok`);
1825
+ } catch (err) {
1826
+ add2("dataset", false, `no usable dataset: ${err.message}. Reinstall qotm (npm i -g qotm).`);
1827
+ }
1828
+ if (!v.offline) {
1829
+ const base = setting(io, "api");
1830
+ try {
1831
+ const res = await api(io, "/health", { timeoutMs: 5e3 });
1832
+ add2("api", res.status === 200, res.status === 200 ? `${base} answers` : `${base} answered ${res.status}`);
1833
+ } catch (err) {
1834
+ add2("api", false, err.message);
1835
+ }
1836
+ }
1837
+ const failed = checks.filter((c) => !c.ok);
1838
+ if (v.json) io.stdout(`${JSON.stringify({ ok: !failed.length, checks })}
1839
+ `);
1840
+ else {
1841
+ const color = useColor(io);
1842
+ for (const c of checks)
1843
+ io.stdout(`${c.ok ? style.muted(color)("\u2713") : style.clay(color)("\u2717")} ${c.name.padEnd(14)}${c.detail}
1844
+ `);
1845
+ io.stdout(
1846
+ failed.length ? `
1847
+ ${failed.length} problem${failed.length > 1 ? "s" : ""}: ${failed.map((c) => c.name).join(", ")}
1848
+ ` : "\nall good\n"
1849
+ );
1850
+ }
1851
+ return failed.length ? 1 : 0;
1852
+ }
1853
+ };
1854
+ function snapshotSha() {
1855
+ try {
1856
+ return JSON.parse(readFileSync2(SNAPSHOT_MANIFEST, "utf8")).sha256;
1857
+ } catch {
1858
+ return void 0;
1859
+ }
1860
+ }
1861
+
1862
+ // src/commands.ts
1863
+ var COMMANDS = [
1864
+ quote,
1865
+ last,
1866
+ save,
1867
+ unsave,
1868
+ favorites,
1869
+ add,
1870
+ submit,
1871
+ open,
1872
+ search,
1873
+ sync,
1874
+ login,
1875
+ logout,
1876
+ status,
1877
+ config,
1878
+ doctor
1879
+ ];
1880
+ var DEFAULT_COMMAND = quote;
1881
+
1882
+ // src/main.ts
1883
+ var pad = (s, n2) => s + " ".repeat(Math.max(1, n2 - s.length));
1884
+ function usage() {
1885
+ const width = Math.max(10, ...COMMANDS.map((c) => c.name.length)) + 3;
1886
+ const commands = COMMANDS.length ? COMMANDS.map((c) => ` ${pad(c.name, width)}${c.summary}`).join("\n") : " (none yet)";
1887
+ return `qotm ${VERSION}: one considered programming quote in your terminal.
1888
+
1889
+ Usage:
1890
+ qotm <command> [options]
1891
+
1892
+ Commands:
1893
+ ${commands}
1894
+
1895
+ Options:
1896
+ -h, --help show this help, or a command's: qotm help <command>
1897
+ -v, --version print the version
1898
+
1899
+ https://qotm.dev
1900
+ `;
1901
+ }
1902
+ function commandHelp(c) {
1903
+ return `Usage: ${c.usage}
1904
+
1905
+ ${c.summary}
1906
+ ${c.help ? `
1907
+ ${c.help.trim()}
1908
+ ` : ""}`;
1909
+ }
1910
+ var find = (name) => COMMANDS.find((c) => c.name === name);
1911
+ async function main(argv, io) {
1912
+ const [first, ...rest] = argv;
1913
+ try {
1914
+ if (first === "-v" || first === "--version") {
1915
+ io.stdout(`${VERSION}
1916
+ `);
1917
+ return 0;
1918
+ }
1919
+ if (first === void 0 || first.startsWith("-") && !["-h", "--help", "-v", "--version"].includes(first))
1920
+ return await DEFAULT_COMMAND.run(argv, io);
1921
+ if (first === "-h" || first === "--help") {
1922
+ io.stdout(usage());
1923
+ return 0;
1924
+ }
1925
+ if (first === "help") {
1926
+ const target2 = rest[0] ? find(rest[0]) : void 0;
1927
+ if (rest[0] && !target2) throw new CliError(`Unknown command "${rest[0]}". Run qotm --help for the list.`, 2);
1928
+ io.stdout(target2 ? commandHelp(target2) : usage());
1929
+ return 0;
1930
+ }
1931
+ const command = find(first);
1932
+ if (!command) throw new CliError(`Unknown command "${first}". Run qotm --help for the list.`, 2);
1933
+ if (rest.includes("-h") || rest.includes("--help")) {
1934
+ io.stdout(commandHelp(command));
1935
+ return 0;
1936
+ }
1937
+ return await command.run(rest, io);
1938
+ } catch (err) {
1939
+ if (err instanceof CliError) {
1940
+ io.stderr(`qotm: ${err.message}
1941
+ `);
1942
+ return err.exitCode;
1943
+ }
1944
+ io.stderr(`qotm: something went wrong: ${err instanceof Error ? err.message : String(err)}
1945
+ `);
1946
+ if (io.env.QOTM_DEBUG && err instanceof Error && err.stack) io.stderr(`${err.stack}
1947
+ `);
1948
+ return 1;
1949
+ }
1950
+ }
1951
+
1952
+ // src/cli.ts
1953
+ process.removeAllListeners("warning");
1954
+ process.on("warning", (w) => {
1955
+ if (w.name === "ExperimentalWarning" && /SQLite/i.test(w.message)) return;
1956
+ process.stderr.write(`${w.name}: ${w.message}
1957
+ `);
1958
+ });
1959
+ var code = await main(process.argv.slice(2), processIo());
1960
+ process.exitCode = code;