import { colors } from "@cliffy/ansi/colors"; import { Command } from "@cliffy/command"; import { partition } from "@std/collections"; import { format } from "@std/datetime"; import { existsSync } from "@std/fs/exists"; import { toSnakeCase } from "@std/text"; import { readAllSync } from "@std/io"; import { getRawTitle, iconForEntry, suffixFor } from "../entry.ts"; import { moveItem } from "../folders.ts"; import { UserError } from "../errors.ts"; import { syncNotebook } from "../git.ts"; import { Hsh } from "../hsh.ts"; import { NotebookPath } from "../notebook.ts"; import { likelyPath } from "../paths.ts"; import { ensureSuffix, print, renderMarkdown, truncateString, } from "../utils.ts"; function register(cmd: Command, hsh: Hsh) { cmd // add .command("add", "add a new note") .alias("a") .arguments("[...words:string]") .option("-t, --title ", "note title") .option( "-n, --name ", "name of the file to add (minus .md extension)", ) .option("-e, --edit", "open note for editing", { default: false }) .action(async (opts, ...words) => { await addEntry(hsh, opts, words); }) // edit .command("edit", "edit an existing note") .alias("e") .arguments("") .option("-n, --rename", "rename file based on document title after editing") .action(async (opts, path) => { const p = await hsh.resolve(path); if (await hsh.edit(p)) { await hsh.emitNotebookChanged?.( p.notebook, `[hsh]: Edit "${p.getTitle()}"`, ); if (opts.rename) { const content = Deno.readTextFileSync(p.getAbsolutePath()); const rawTitle = getRawTitle(content); if (rawTitle == null) { print(colors.yellow("Note has no title; file not renamed.")); } else { const newEntry = ensureSuffix( toSnakeCase(rawTitle), suffixFor(p.entry!), ); if (newEntry !== p.entry) { try { await moveItem(p, p.withEntry(newEntry), hsh); } catch { print( colors.yellow( `"${newEntry}" already exists; file not renamed.`, ), ); } } } } } }) // remove .command("remove", "remove a note") .alias("rm") .alias("ri") .arguments("") .action(async (_opts, path) => { const p = await hsh.resolve(path); const title = p.getTitle(); Deno.removeSync(p.getAbsolutePath()); await hsh.emitNotebookChanged?.(p.notebook, `[hsh]: Removed "${title}"`); }) // list .command("list", "list all notes").alias("ls") .option("-a, --all", "list all entries") .option("-r, --reverse", "reverse the entries") .option("-l, --limit ", "only show entries") .arguments("[path:string]") .action((opts, path) => { listEntries(hsh, opts, path); }) // show .command("show", "show a given note") .arguments("") .option("--raw", "print raw output") .option("--html", "render as HTML", { conflicts: ["raw"] }) .action(async ({ raw, html }, path) => { const p = await hsh.resolve(path); const abs = p.getAbsolutePath(); if (existsSync(abs, { isDirectory: true })) { throw new UserError( `"${path}" is a directory; did you mean 'hsh ls ${path}/'?`, ); } const { attrs, body } = p.read(); if (html) { print(await renderMarkdown(body, "html")); } else if ((raw || !Deno.stdout.isTerminal())) { print(body); } else { if (attrs?.title) { print(colors.bgBrightMagenta(attrs.title) + "\n"); } print(await renderMarkdown(body, "terminal")); } }) // pin .command("pin", "pin the given item") .arguments("") .action(async (_opts, item) => { const p = await hsh.resolve(item); p.notebook.pin(p); await hsh.emitNotebookChanged?.( p.notebook, `[hsh]: Pin "${p.getTitle()}"`, ); }) // unpin .command("unpin", "unpin the given item") .arguments("") .action(async (_opts, item) => { const p = await hsh.resolve(item); p.notebook.unpin(p); await hsh.emitNotebookChanged?.( p.notebook, `[hsh]: Unpin "${p.getTitle()}"`, ); }) // sync .command("sync", "sync notebooks") .arguments("[notebook:string]") .option("-a, --all", "sync all notebooks", { default: false }) .action(async ({ all }, notebook) => { const notebooks = all ? hsh.allNotebooks() : [hsh.getNotebook(notebook)]; for (const notebook of notebooks) { await syncNotebook(notebook, true); } }); } function listEntries( hsh: Hsh, opts: { all?: boolean; reverse?: boolean; limit?: number }, path: string | undefined, ) { const notebookPath = hsh.parse(path ?? ""); const entries = notebookPath.getEntries().filter((e) => notebookPath.entry == null || e.filename.startsWith(notebookPath.entry) ); let [folders, files] = partition(entries, (e) => e.type === "directory"); let rows = 25; let columns = 80; try { const size = Deno.consoleSize(); rows = size.rows; columns = size.columns; } catch { // We don't have a terminal, just move on } const limit = opts.all ? null : (opts.limit ?? Math.max(rows - 6, 10)); const reversed = opts.reverse ?? false; const maxLength = columns - 3; folders.sort((a, b) => a.filename.localeCompare(b.filename)); files.sort((a, b) => (reversed ? a : b).lastModified.getTime() - (reversed ? b : a).lastModified.getTime() ); const [pinned, unpinned] = partition(files, (e) => e.pinned); files = pinned.concat(unpinned); print(); for (const entry of folders.concat(limit ? files.slice(0, limit) : files)) { print(" " + iconForEntry(entry), false); print(truncateString(entry.title, maxLength)); } print(); } async function addEntry( hsh: Hsh, opts: { title?: string; edit: boolean; name?: string }, words: string[], ): Promise { let p: NotebookPath; if (words.length > 0 && likelyPath(words[0])) { p = hsh.parse(words.shift()!); } else { p = hsh.getNotebook().newPath(); } const n = opts.name ?? opts.title ?? format(new Date(), "yyyy-MM-dd-HHmm"); p.entry = ensureSuffix(toSnakeCase(n), ".md"); if (existsSync(p.getAbsolutePath())) { throw new UserError(`Note "${p.entry}" already exists`); } let content: string; if (Deno.stdin.isTerminal()) { content = opts.title ? `# ${opts.title}\n\n` : ""; if (words.length > 0) content += words.join(" ") + "\n"; } else { const decoder = new TextDecoder(); content = decoder.decode(readAllSync(Deno.stdin)); } p.write(content); let success = true; if (opts.edit || (Deno.stdin.isTerminal() && words.length === 0)) { success = await hsh.edit(p, opts.title == null ? undefined : 3); } if (success) { await hsh.emitNotebookChanged?.( p.notebook, `[hsh]: Added "${p.getTitle()}"`, ); print(`created [${p}]`); return p; } else { Deno.removeSync(p.getAbsolutePath()); } } export default { register };