A note-taking app inspired by nb
1import { colors } from "@cliffy/ansi/colors";
2import { Command } from "@cliffy/command";
3import { partition } from "@std/collections";
4import { format } from "@std/datetime";
5import { existsSync } from "@std/fs/exists";
6import { toSnakeCase } from "@std/text";
7import { readAllSync } from "@std/io";
8
9import { getRawTitle, iconForEntry, suffixFor } from "../entry.ts";
10import { moveItem } from "../folders.ts";
11import { UserError } from "../errors.ts";
12import { syncNotebook } from "../git.ts";
13import { Hsh } from "../hsh.ts";
14import { NotebookPath } from "../notebook.ts";
15import { likelyPath } from "../paths.ts";
16import {
17 ensureSuffix,
18 print,
19 renderMarkdown,
20 truncateString,
21} from "../utils.ts";
22
23function register(cmd: Command, hsh: Hsh) {
24 cmd
25 // add
26 .command("add", "add a new note")
27 .alias("a")
28 .arguments("[...words:string]")
29 .option("-t, --title <title:string>", "note title")
30 .option(
31 "-n, --name <name:string>",
32 "name of the file to add (minus .md extension)",
33 )
34 .option("-e, --edit", "open note for editing", { default: false })
35 .action(async (opts, ...words) => {
36 await addEntry(hsh, opts, words);
37 })
38 // edit
39 .command("edit", "edit an existing note")
40 .alias("e")
41 .arguments("<path:string>")
42 .option("-n, --rename", "rename file based on document title after editing")
43 .action(async (opts, path) => {
44 const p = await hsh.resolve(path);
45 if (await hsh.edit(p)) {
46 await hsh.emitNotebookChanged?.(
47 p.notebook,
48 `[hsh]: Edit "${p.getTitle()}"`,
49 );
50 if (opts.rename) {
51 const content = Deno.readTextFileSync(p.getAbsolutePath());
52 const rawTitle = getRawTitle(content);
53 if (rawTitle == null) {
54 print(colors.yellow("Note has no title; file not renamed."));
55 } else {
56 const newEntry = ensureSuffix(
57 toSnakeCase(rawTitle),
58 suffixFor(p.entry!),
59 );
60 if (newEntry !== p.entry) {
61 try {
62 await moveItem(p, p.withEntry(newEntry), hsh);
63 } catch {
64 print(
65 colors.yellow(
66 `"${newEntry}" already exists; file not renamed.`,
67 ),
68 );
69 }
70 }
71 }
72 }
73 }
74 })
75 // remove
76 .command("remove", "remove a note")
77 .alias("rm")
78 .alias("ri")
79 .arguments("<path:string>")
80 .action(async (_opts, path) => {
81 const p = await hsh.resolve(path);
82 const title = p.getTitle();
83 Deno.removeSync(p.getAbsolutePath());
84 await hsh.emitNotebookChanged?.(p.notebook, `[hsh]: Removed "${title}"`);
85 })
86 // list
87 .command("list", "list all notes").alias("ls")
88 .option("-a, --all", "list all entries")
89 .option("-r, --reverse", "reverse the entries")
90 .option("-l, --limit <limit:number>", "only show <limit> entries")
91 .arguments("[path:string]")
92 .action((opts, path) => {
93 listEntries(hsh, opts, path);
94 })
95 // show
96 .command("show", "show a given note")
97 .arguments("<note:string>")
98 .option("--raw", "print raw output")
99 .option("--html", "render as HTML", { conflicts: ["raw"] })
100 .action(async ({ raw, html }, path) => {
101 const p = await hsh.resolve(path);
102 const abs = p.getAbsolutePath();
103 if (existsSync(abs, { isDirectory: true })) {
104 throw new UserError(
105 `"${path}" is a directory; did you mean 'hsh ls ${path}/'?`,
106 );
107 }
108 const { attrs, body } = p.read();
109 if (html) {
110 print(await renderMarkdown(body, "html"));
111 } else if ((raw || !Deno.stdout.isTerminal())) {
112 print(body);
113 } else {
114 if (attrs?.title) {
115 print(colors.bgBrightMagenta(attrs.title) + "\n");
116 }
117 print(await renderMarkdown(body, "terminal"));
118 }
119 })
120 // pin
121 .command("pin", "pin the given item")
122 .arguments("<item:string>")
123 .action(async (_opts, item) => {
124 const p = await hsh.resolve(item);
125 p.notebook.pin(p);
126 await hsh.emitNotebookChanged?.(
127 p.notebook,
128 `[hsh]: Pin "${p.getTitle()}"`,
129 );
130 })
131 // unpin
132 .command("unpin", "unpin the given item")
133 .arguments("<item:string>")
134 .action(async (_opts, item) => {
135 const p = await hsh.resolve(item);
136 p.notebook.unpin(p);
137 await hsh.emitNotebookChanged?.(
138 p.notebook,
139 `[hsh]: Unpin "${p.getTitle()}"`,
140 );
141 })
142 // sync
143 .command("sync", "sync notebooks")
144 .arguments("[notebook:string]")
145 .option("-a, --all", "sync all notebooks", { default: false })
146 .action(async ({ all }, notebook) => {
147 const notebooks = all ? hsh.allNotebooks() : [hsh.getNotebook(notebook)];
148 for (const notebook of notebooks) {
149 await syncNotebook(notebook, true);
150 }
151 });
152}
153
154function listEntries(
155 hsh: Hsh,
156 opts: { all?: boolean; reverse?: boolean; limit?: number },
157 path: string | undefined,
158) {
159 const notebookPath = hsh.parse(path ?? "");
160 const entries = notebookPath.getEntries().filter((e) =>
161 notebookPath.entry == null || e.filename.startsWith(notebookPath.entry)
162 );
163
164 let [folders, files] = partition(entries, (e) => e.type === "directory");
165
166 let rows = 25;
167 let columns = 80;
168 try {
169 const size = Deno.consoleSize();
170 rows = size.rows;
171 columns = size.columns;
172 } catch {
173 // We don't have a terminal, just move on
174 }
175 const limit = opts.all ? null : (opts.limit ?? Math.max(rows - 6, 10));
176 const reversed = opts.reverse ?? false;
177 const maxLength = columns - 3;
178
179 folders.sort((a, b) => a.filename.localeCompare(b.filename));
180 files.sort((a, b) =>
181 (reversed ? a : b).lastModified.getTime() -
182 (reversed ? b : a).lastModified.getTime()
183 );
184 const [pinned, unpinned] = partition(files, (e) => e.pinned);
185 files = pinned.concat(unpinned);
186
187 print();
188 for (const entry of folders.concat(limit ? files.slice(0, limit) : files)) {
189 print(" " + iconForEntry(entry), false);
190 print(truncateString(entry.title, maxLength));
191 }
192 print();
193}
194
195async function addEntry(
196 hsh: Hsh,
197 opts: { title?: string; edit: boolean; name?: string },
198 words: string[],
199): Promise<NotebookPath | void> {
200 let p: NotebookPath;
201 if (words.length > 0 && likelyPath(words[0])) {
202 p = hsh.parse(words.shift()!);
203 } else {
204 p = hsh.getNotebook().newPath();
205 }
206 const n = opts.name ?? opts.title ?? format(new Date(), "yyyy-MM-dd-HHmm");
207 p.entry = ensureSuffix(toSnakeCase(n), ".md");
208
209 if (existsSync(p.getAbsolutePath())) {
210 throw new UserError(`Note "${p.entry}" already exists`);
211 }
212
213 let content: string;
214 if (Deno.stdin.isTerminal()) {
215 content = opts.title ? `# ${opts.title}\n\n` : "";
216 if (words.length > 0) content += words.join(" ") + "\n";
217 } else {
218 const decoder = new TextDecoder();
219 content = decoder.decode(readAllSync(Deno.stdin));
220 }
221 p.write(content);
222 let success = true;
223 if (opts.edit || (Deno.stdin.isTerminal() && words.length === 0)) {
224 success = await hsh.edit(p, opts.title == null ? undefined : 3);
225 }
226 if (success) {
227 await hsh.emitNotebookChanged?.(
228 p.notebook,
229 `[hsh]: Added "${p.getTitle()}"`,
230 );
231 print(`created [${p}]`);
232 return p;
233 } else {
234 Deno.removeSync(p.getAbsolutePath());
235 }
236}
237
238export default { register };