A command-line journaling application
1import { chunk } from "@std/collections";
2import { DateTime } from "luxon";
3
4/**
5 * A journal entry with a timestamp, title, and body text.
6 */
7export interface Entry {
8 /** The date and time of the entry */
9 date: DateTime;
10 /** The title or first sentence of the entry */
11 title: string;
12 /** The body text of the entry */
13 body: string;
14}
15
16/**
17 * Date format string used for parsing and rendering entries.
18 */
19export const DATE_STRING = "yyyy-MM-dd HH:mm";
20
21/**
22 * Prints an entry to the console.
23 *
24 * @param entry - The entry to print
25 * @param summary - If true, only prints the title line without the body
26 */
27export function printEntry(entry: Entry, summary?: boolean): void {
28 console.log(
29 `[${entry.date.toFormat(DATE_STRING)}] ${entry.title}`,
30 );
31 if (!summary) console.log(entry.body.trimEnd());
32}
33
34function parseEntry(header: string, body: string): Entry {
35 const m = header.match(
36 /\[(?<date>\d{4}-\d{2}-\d{2} \d{2}:\d{2})\] (?<title>.*)/,
37 );
38 if (m == null) throw new Error(`Bad header: ${header} (body is ${body})`);
39 const [dateChunk, title] = [m.groups!["date"], m.groups!["title"]];
40 const date = DateTime.fromFormat(dateChunk, DATE_STRING);
41 return { date, title, body };
42}
43
44/**
45 * Parses a string containing multiple journal entries.
46 *
47 * @param bodies - The raw text containing entries in the format `[YYYY-MM-DD HH:mm] Title\nBody`
48 * @returns An array of parsed entries
49 */
50export function parseEntries(bodies: string): Entry[] {
51 const entries = bodies.split(/^(\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\] .*)\n/m);
52 if (entries[0].trim() === "") entries.splice(0, 1);
53 return chunk(entries, 2).map((e) => parseEntry(e[0], e[1].trim()));
54}
55
56/**
57 * Renders an entry to its text format.
58 *
59 * @param entry - The entry to render
60 * @returns The formatted entry text
61 */
62export function renderEntry(entry: Entry): string {
63 let text = `[${entry.date.toFormat(DATE_STRING)}] ${entry.title.trim()}\n`;
64 const body = entry.body?.trim();
65 if (body != null && body !== "") {
66 text += body + "\n";
67 }
68 return text;
69}
70
71/**
72 * Saves entries to a file, sorted by date.
73 *
74 * @param path - The file path to save to
75 * @param entries - The entries to save
76 */
77export function saveEntries(path: string, entries: Entry[]): void {
78 let file;
79 const encoder = new TextEncoder();
80 try {
81 entries.sort((a, b) => a.date.valueOf() - b.date.valueOf());
82 file = Deno.openSync(path, { write: true });
83 file.truncateSync();
84 let first = true;
85 const nl = new Uint8Array([10]);
86 for (const entry of entries) {
87 if (!first) {
88 file.writeSync(nl);
89 }
90 first = false;
91 file.writeSync(encoder.encode(renderEntry(entry)));
92 }
93 } finally {
94 file?.close();
95 }
96}
97
98/**
99 * Creates an entry from raw text, splitting into title and body.
100 *
101 * The first sentence (ending with `.?!`) becomes the title,
102 * and the rest becomes the body.
103 *
104 * @param rawText - The raw text to convert into an entry
105 * @param created - Optional timestamp for the entry (defaults to now)
106 * @returns A new entry
107 */
108export function makeEntry(rawText: string, created?: DateTime): Entry {
109 const components = rawText.match(/([^.?!]+[.?!]*)(.*)/s);
110 const title = components?.[1]?.trim() ?? "";
111 const body = components?.[2]?.trim() ?? "";
112 const date = created ?? DateTime.now();
113 return { date, title, body };
114}
115
116/**
117 * Parameters for filtering journal entries.
118 */
119export interface FilterParams {
120 /** Only include entries on or after this date */
121 from?: DateTime;
122 /** Only include entries on or before this date */
123 to?: DateTime;
124 /** Maximum number of entries to return (returns last N) */
125 limit?: number;
126 /** Only include entries containing all of these tags/strings */
127 tags?: string[];
128}
129
130/**
131 * Checks if an entry matches the given filter parameters.
132 *
133 * @param entry - The entry to check
134 * @param params - The filter parameters
135 * @returns True if the entry matches all filter criteria
136 */
137export function matchesFilter(entry: Entry, params: FilterParams): boolean {
138 return (params.from ? params.from <= entry.date : true) &&
139 (params.to ? params.to >= entry.date : true) &&
140 (params.tags
141 ? params.tags.every((t) =>
142 entry.title.includes(t) || entry.body.includes(t)
143 )
144 : true);
145}
146
147/**
148 * Filters entries based on the given parameters.
149 *
150 * @param entries - The entries to filter
151 * @param params - The filter parameters
152 * @returns The filtered entries (last N if limit is specified)
153 */
154export function filterEntries(entries: Entry[], params: FilterParams): Entry[] {
155 const filtered = entries.filter((e) => matchesFilter(e, params));
156 return params.limit ? filtered.slice(-params.limit) : filtered;
157}