benjivers/notes Public Public: anyone can read this; nobody can change it.

Files 73f5fd914653f166957d8eef6ca6083f84887b4a
167 lines · 5.8 KB · ~1.7k tok Raw History
1
/**
2
 * The vault in a terminal. The forge runs this on a pty when someone
3
 * `ssh`es to the app, over the same data as the page and through the same
4
 * actions, as the account at the keyboard. j/k move, enter reads the note
5
 * under the cursor, / searches, a adds a line to today's note, n makes a
6
 * new note, d removes one, q leaves. Plain ANSI, no dependencies.
7
 */
8
9
import {
10
  addNote,
11
  canDelete,
12
  deleteNote,
13
  listNotes,
14
  openOrCreate,
15
  searchNotes,
16
  today,
17
  updateNote,
18
  type Note,
19
} from "./actions.ts";
20
import { openDb } from "./db.ts";
21
import { ssh, title, where } from "./env.ts";
22
import { whoAtTty } from "./who.ts";
23
24
const db = openDb();
25
const me = whoAtTty();
26
const esc = "\x1b[";
27
const dim = (s: string): string => `${esc}2m${s}${esc}0m`;
28
const bold = (s: string): string => `${esc}1m${s}${esc}0m`;
29
const out = (s: string): void => {
30
  process.stdout.write(s);
31
};
32
33
let cursor = 0;
34
let reading: Note | undefined;
35
let scroll = 0;
36
let typing: { what: "append" | "new" | "search"; text: string } | undefined;
37
let filter = "";
38
let said = "";
39
40
const notes = (): Note[] => {
41
  const listed = listNotes(db, me);
42
  if (!listed.ok) return [];
43
  if (filter === "") return listed.value;
44
  const hits = searchNotes(db, me, { q: filter, limit: 500 });
45
  if (!hits.ok) return [];
46
  const ids = hits.value.map((h) => h.id);
47
  return ids.flatMap((id) => listed.value.filter((n) => n.id === id));
48
};
49
50
const rows = (): number => process.stdout.rows ?? 24;
51
52
function draw(): void {
53
  const lines = [
54
    `${bold(title)}  ${dim(where)}  ${dim(me.role === "anon" ? "guest" : me.name)}`,
55
    ssh === undefined ? "" : dim(`connect: ${ssh}`),
56
    "",
57
  ];
58
  if (me.role === "anon") {
59
    lines.push("Sign in with a key the forge knows to open your vault.", "", dim("q quit"));
60
    out(`${esc}2J${esc}H${lines.join("\r\n")}`);
61
    return;
62
  }
63
  if (reading !== undefined) {
64
    const body = reading.body.split("\n");
65
    const room = Math.max(3, rows() - 6);
66
    lines.push(bold(reading.title), "");
67
    lines.push(...body.slice(scroll, scroll + room));
68
    lines.push("", dim(`j/k scroll · esc back · ${scroll + 1}-${Math.min(body.length, scroll + room)} of ${body.length}`));
69
    out(`${esc}2J${esc}H${lines.join("\r\n")}`);
70
    return;
71
  }
72
  const list = notes();
73
  if (filter !== "") lines.push(dim(`search: ${filter}  (esc clears)`));
74
  if (list.length === 0) lines.push(dim(filter === "" ? "No notes yet. Press n to write one." : "Nothing matches."));
75
  const room = Math.max(3, rows() - 8);
76
  const start = Math.max(0, Math.min(cursor - Math.floor(room / 2), list.length - room));
77
  list.slice(start, start + room).forEach((note, k) => {
78
    const i = start + k;
79
    const name = `${note.pinned === 1 ? "★ " : ""}${note.title}`;
80
    lines.push(`${i === cursor ? "▸" : " "} ${i === cursor ? bold(name) : name}  ${dim(note.updated_at.slice(0, 16).replace("T", " "))}`);
81
  });
82
  lines.push("");
83
  if (typing !== undefined)
84
    lines.push(`${{ append: `Add to ${today()}`, new: "New note title", search: "Search" }[typing.what]}: ${typing.text}${esc}7m ${esc}0m`);
85
  else if (said !== "") lines.push(said);
86
  else lines.push(dim("j/k move · enter read · / search · a add to today · n new · d delete · q quit"));
87
  out(`${esc}2J${esc}H${lines.join("\r\n")}`);
88
}
89
90
function submit(what: "append" | "new" | "search", text: string): void {
91
  if (what === "search") {
92
    filter = text.trim();
93
    cursor = 0;
94
    return;
95
  }
96
  if (text.trim() === "") return;
97
  if (what === "new") {
98
    const added = addNote(db, me, { title: text });
99
    said = added.ok ? `Wrote “${added.value.title}”.` : added.message;
100
    return;
101
  }
102
  const day = today();
103
  const opened = openOrCreate(db, me, { title: day, body: `# ${day}\n` });
104
  if (!opened.ok) {
105
    said = opened.message;
106
    return;
107
  }
108
  const body = opened.value.body.replace(/\s*$/, "");
109
  const saved = updateNote(db, me, { id: opened.value.id, body: `${body}\n\n- ${text.trim()}\n` });
110
  said = saved.ok ? `Added to ${day}.` : saved.message;
111
}
112
113
process.stdin.setRawMode?.(true);
114
process.stdin.resume();
115
process.stdin.setEncoding("utf8");
116
draw();
117
process.stdin.on("data", (key: string) => {
118
  said = "";
119
  if (typing !== undefined) {
120
    if (key === "\r") {
121
      submit(typing.what, typing.text);
122
      typing = undefined;
123
    } else if (key === "\x1b") typing = undefined;
124
    else if (key === "\x7f") typing.text = typing.text.slice(0, -1);
125
    else if (key >= " ") typing.text += key;
126
    draw();
127
    return;
128
  }
129
  if (key === "q" || key === "\x03") {
130
    out(`${esc}2J${esc}H`);
131
    process.exit(0);
132
  }
133
  if (me.role === "anon") return draw();
134
  if (reading !== undefined) {
135
    const max = Math.max(0, reading.body.split("\n").length - 1);
136
    if (key === "j" || key === "\x1b[B") scroll = Math.min(scroll + 1, max);
137
    if (key === "k" || key === "\x1b[A") scroll = Math.max(scroll - 1, 0);
138
    if (key === "\x1b" || key === "h") reading = undefined;
139
    return draw();
140
  }
141
  const list = notes();
142
  if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, list.length - 1));
143
  if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0);
144
  if (key === "\r" && list[cursor] !== undefined) {
145
    reading = list[cursor];
146
    scroll = 0;
147
  }
148
  if (key === "\x1b" && filter !== "") {
149
    filter = "";
150
    cursor = 0;
151
  }
152
  if (key === "/") typing = { what: "search", text: filter };
153
  if (key === "a") typing = { what: "append", text: "" };
154
  if (key === "n") typing = { what: "new", text: "" };
155
  if (key === "d") {
156
    const note = list[cursor];
157
    if (note === undefined) said = "Nothing to remove.";
158
    else if (!canDelete(me, note)) said = "Only the author can remove a note.";
159
    else {
160
      const removed = deleteNote(db, me, { id: note.id });
161
      said = removed.ok ? `Removed “${note.title}”.` : removed.message;
162
      cursor = Math.min(cursor, Math.max(0, list.length - 2));
163
    }
164
  }
165
  draw();
166
});
167
process.stdout.on("resize", draw);
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts