A command-line journaling application
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}