import { chunk } from "@std/collections"; import { DateTime } from "luxon"; /** * A journal entry with a timestamp, title, and body text. */ export interface Entry { /** The date and time of the entry */ date: DateTime; /** The title or first sentence of the entry */ title: string; /** The body text of the entry */ body: string; } /** * Date format string used for parsing and rendering entries. */ export const DATE_STRING = "yyyy-MM-dd HH:mm"; /** * Prints an entry to the console. * * @param entry - The entry to print * @param summary - If true, only prints the title line without the body */ export function printEntry(entry: Entry, summary?: boolean): void { console.log( `[${entry.date.toFormat(DATE_STRING)}] ${entry.title}`, ); if (!summary) console.log(entry.body.trimEnd()); } function parseEntry(header: string, body: string): Entry { const m = header.match( /\[(?\d{4}-\d{2}-\d{2} \d{2}:\d{2})\] (?.*)/, ); if (m == null) throw new Error(`Bad header: ${header} (body is ${body})`); const [dateChunk, title] = [m.groups!["date"], m.groups!["title"]]; const date = DateTime.fromFormat(dateChunk, DATE_STRING); return { date, title, body }; } /** * Parses a string containing multiple journal entries. * * @param bodies - The raw text containing entries in the format `[YYYY-MM-DD HH:mm] Title\nBody` * @returns An array of parsed entries */ export function parseEntries(bodies: string): Entry[] { const entries = bodies.split(/^(\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\] .*)\n/m); if (entries[0].trim() === "") entries.splice(0, 1); return chunk(entries, 2).map((e) => parseEntry(e[0], e[1].trim())); } /** * Renders an entry to its text format. * * @param entry - The entry to render * @returns The formatted entry text */ export function renderEntry(entry: Entry): string { let text = `[${entry.date.toFormat(DATE_STRING)}] ${entry.title.trim()}\n`; const body = entry.body?.trim(); if (body != null && body !== "") { text += body + "\n"; } return text; } /** * Saves entries to a file, sorted by date. * * @param path - The file path to save to * @param entries - The entries to save */ export function saveEntries(path: string, entries: Entry[]): void { let file; const encoder = new TextEncoder(); try { entries.sort((a, b) => a.date.valueOf() - b.date.valueOf()); file = Deno.openSync(path, { write: true }); file.truncateSync(); let first = true; const nl = new Uint8Array([10]); for (const entry of entries) { if (!first) { file.writeSync(nl); } first = false; file.writeSync(encoder.encode(renderEntry(entry))); } } finally { file?.close(); } } /** * Creates an entry from raw text, splitting into title and body. * * The first sentence (ending with `.?!`) becomes the title, * and the rest becomes the body. * * @param rawText - The raw text to convert into an entry * @param created - Optional timestamp for the entry (defaults to now) * @returns A new entry */ export function makeEntry(rawText: string, created?: DateTime): Entry { const components = rawText.match(/([^.?!]+[.?!]*)(.*)/s); const title = components?.[1]?.trim() ?? ""; const body = components?.[2]?.trim() ?? ""; const date = created ?? DateTime.now(); return { date, title, body }; } /** * Parameters for filtering journal entries. */ export interface FilterParams { /** Only include entries on or after this date */ from?: DateTime; /** Only include entries on or before this date */ to?: DateTime; /** Maximum number of entries to return (returns last N) */ limit?: number; /** Only include entries containing all of these tags/strings */ tags?: string[]; } /** * Checks if an entry matches the given filter parameters. * * @param entry - The entry to check * @param params - The filter parameters * @returns True if the entry matches all filter criteria */ export function matchesFilter(entry: Entry, params: FilterParams): boolean { return (params.from ? params.from <= entry.date : true) && (params.to ? params.to >= entry.date : true) && (params.tags ? params.tags.every((t) => entry.title.includes(t) || entry.body.includes(t) ) : true); } /** * Filters entries based on the given parameters. * * @param entries - The entries to filter * @param params - The filter parameters * @returns The filtered entries (last N if limit is specified) */ export function filterEntries(entries: Entry[], params: FilterParams): Entry[] { const filtered = entries.filter((e) => matchesFilter(e, params)); return params.limit ? filtered.slice(-params.limit) : filtered; }