| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 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); |