A command-line journaling application
at main 157 lines 4.7 kB view raw
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}