A note-taking app inspired by nb
at main 238 lines 7.4 kB view raw
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 };