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