A command-line journaling application
at bun 170 lines 4.5 kB view raw
1#!/usr/bin/env bun 2import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; 3import { spawnSync } from "node:child_process"; 4import { tmpdir } from "node:os"; 5import { join } from "node:path"; 6import { parseArgs } from "node:util"; 7import { DateTime } from "luxon"; 8import * as chrono from "chrono-node"; 9import { loadConfig } from "./config.ts"; 10import { 11 type Entry, 12 filterEntries, 13 type FilterParams, 14 makeEntry, 15 matchesFilter, 16 parseEntries, 17 printEntry, 18 renderEntry, 19 saveEntries, 20} from "./mod.ts"; 21 22function partition<T>(arr: T[], pred: (e: T) => boolean): [T[], T[]] { 23 const yes: T[] = [], no: T[] = []; 24 for (const e of arr) (pred(e) ? yes : no).push(e); 25 return [yes, no]; 26} 27 28function tryDate(d?: unknown): DateTime | undefined { 29 if (typeof d !== "string") return undefined; 30 const parsed = chrono.parseDate(d); 31 return parsed ? DateTime.fromJSDate(parsed) : undefined; 32} 33 34function printFilteredEntries( 35 entries: Entry[], 36 params: FilterParams, 37 summary: boolean, 38): void { 39 const filtered = filterEntries(entries, params); 40 let first = true; 41 for (const entry of filtered) { 42 if (!first && !summary) { 43 console.log(); 44 } 45 first = false; 46 printEntry(entry, summary); 47 } 48} 49 50function edit( 51 body: string, 52 editor: string[], 53): string | undefined { 54 const temp = join(tmpdir(), `hayom-${crypto.randomUUID()}.hayom`); 55 try { 56 writeFileSync(temp, body); 57 const result = spawnSync(editor[0], [...editor.slice(1), temp], { 58 stdio: "inherit", 59 }); 60 if (result.status === 0) { 61 return readFileSync(temp, "utf-8"); 62 } 63 console.error("Aborted."); 64 } finally { 65 try { unlinkSync(temp); } catch {} 66 } 67} 68 69function editFilteredEntries( 70 entries: Entry[], 71 filter: FilterParams, 72 editor: string[], 73): Entry[] | undefined { 74 const [toEdit, toKeep] = partition(entries, (e) => matchesFilter(e, filter)); 75 const result = edit(toEdit.map(renderEntry).join("\n"), editor); 76 if (result == null) return undefined; 77 return [...toKeep, ...parseEntries(result.replace("\r", ""))]; 78} 79 80function printHelp(): void { 81 console.log(` 82usage: hayom [-j journal] ... 83options: 84 --count | -n: number of entries to print 85 --edit | -e: edit entries 86 --from | -f: from timestamp 87 --journal | -j: journal to use 88 --on: on timestamp 89 --summary | -s: print summary line only 90 --to | -t: to timestamp 91`); 92} 93 94function main(): void { 95 const config = loadConfig(); 96 97 const { values: opts, positionals } = parseArgs({ 98 args: process.argv.slice(2), 99 allowPositionals: true, 100 options: { 101 summary: { type: "boolean", short: "s" }, 102 edit: { type: "boolean", short: "e" }, 103 from: { type: "string", short: "f" }, 104 journal: { type: "string", short: "j" }, 105 count: { type: "string", short: "n" }, 106 to: { type: "string", short: "t" }, 107 on: { type: "string" }, 108 help: { type: "boolean", short: "h" }, 109 }, 110 }); 111 112 if (opts.help) { 113 printHelp(); 114 return; 115 } 116 117 const journal = opts.journal ?? config.default; 118 const path = config.journals[journal].journal; 119 120 if (!existsSync(path)) { 121 writeFileSync(path, ""); 122 } 123 124 const entries = parseEntries(readFileSync(path, "utf-8").replace("\r", "")); 125 126 if ( 127 opts.from != null || opts.to != null || opts.on != null || opts.count != null || 128 (positionals.length > 0 && positionals.every((e) => e[0] === "@")) 129 ) { 130 let from = tryDate(opts.from); 131 let to = tryDate(opts.to); 132 if (opts.on) { 133 const on = tryDate(opts.on); 134 if (typeof on !== "object") { 135 throw new Error("Bad date"); 136 } 137 [from, to] = [on.startOf("day"), on.endOf("day")]; 138 } 139 const tags = positionals.filter((arg) => arg.match(/^@./)); 140 141 const filter: FilterParams = { 142 from, 143 to, 144 tags, 145 limit: opts.count ? parseInt(opts.count) : undefined, 146 }; 147 148 if (opts.edit) { 149 const newEntries = editFilteredEntries(entries, filter, config.editor); 150 if (newEntries != null) { 151 saveEntries(path, newEntries); 152 } 153 } else { 154 printFilteredEntries(entries, filter, opts.summary ?? false); 155 } 156 } else { 157 const rawEntry = positionals.length > 0 158 ? positionals.join(" ") 159 : edit("", config.editor); 160 if (rawEntry && rawEntry.trim() !== "") { 161 const entry = makeEntry(rawEntry); 162 entries.push(entry); 163 saveEntries(path, entries); 164 } 165 } 166} 167 168if (import.meta.main) { 169 main(); 170}