diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 4a8bcf3..27464ef 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1079,11 +1079,14 @@ list subcommand **Usage** ```bash -npx thermoworks journal list +npx thermoworks journal list [--meat TEXT] [--min-rating N] [--search TEXT] ``` **Options** +- `--meat TEXT` - Only include entries whose meat field contains this text. +- `--min-rating N` - Only include entries rated at least N stars. +- `--search TEXT` - Search entry titles and notes. - `--json` - Output machine-readable JSON. ### `thermoworks journal show` @@ -1107,11 +1110,14 @@ cost subcommand **Usage** ```bash -npx thermoworks journal cost +npx thermoworks journal cost [--meat TEXT] [--min-rating N] [--search TEXT] ``` **Options** +- `--meat TEXT` - Only include entries whose meat field contains this text. +- `--min-rating N` - Only include entries rated at least N stars. +- `--search TEXT` - Search entry titles and notes. - `--json` - Output machine-readable JSON. ### `thermoworks journal import` @@ -1135,12 +1141,16 @@ export subcommand **Usage** ```bash -npx thermoworks journal export +npx thermoworks journal export [--format json|csv] [--output PATH] [--meat TEXT] [--min-rating N] [--search TEXT] ``` **Options** -None. +- `--format json|csv` - Output format. +- `--output PATH` - Write output to a file. +- `--meat TEXT` - Only include entries whose meat field contains this text. +- `--min-rating N` - Only include entries rated at least N stars. +- `--search TEXT` - Search entry titles and notes. ### `thermoworks journal rm` diff --git a/packages/cli/src/command-registry.ts b/packages/cli/src/command-registry.ts index 9c6eace..94a659e 100644 --- a/packages/cli/src/command-registry.ts +++ b/packages/cli/src/command-registry.ts @@ -855,10 +855,10 @@ export const commandDefinitions: readonly CommandDefinition[] = [ "journal add Log a finished cook to a local logbook", " --cost-meat N Meat cost for the cook", " --cost-fuel N Fuel cost for the cook", - "journal list List logbook entries (newest first)", + "journal list List logbook entries (filters: --meat, --min-rating, --search)", "journal show Show one logbook entry", - "journal cost Summarize cook costs across the logbook", - "journal export Export the local logbook as JSON or CSV", + "journal cost Summarize cook costs across matching logbook entries", + "journal export Export matching logbook entries as JSON or CSV", "journal rm Remove one logbook entry", ], subcommands: journalCommands, diff --git a/packages/cli/src/commands/journal.ts b/packages/cli/src/commands/journal.ts index 336e494..a7651ef 100644 --- a/packages/cli/src/commands/journal.ts +++ b/packages/cli/src/commands/journal.ts @@ -132,6 +132,66 @@ export function sortJournalEntries(entries: JournalEntry[]): JournalEntry[] { return [...entries].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } +export interface JournalFilterOptions { + meat?: string; + minRating?: number; + search?: string; +} + +export function parseJournalFilterArgs(args: string[]): JournalFilterOptions | { error: string } { + const filters: JournalFilterOptions = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === undefined) continue; + if (arg === "--meat") { + const value = args[++i]; + if (value === undefined) return { error: "--meat requires a value" }; + filters.meat = value; + } else if (arg === "--min-rating") { + const value = args[++i]; + if (value === undefined) return { error: "--min-rating requires a value" }; + const rating = Number.parseInt(value, 10); + if (!Number.isInteger(rating) || rating < 1 || rating > 5) { + return { error: `--min-rating must be an integer from 1 to 5, got "${value}"` }; + } + filters.minRating = rating; + } else if (arg === "--search") { + const value = args[++i]; + if (value === undefined) return { error: "--search requires a value" }; + filters.search = value; + } else { + return { error: `Unknown option: ${arg}` }; + } + } + + return filters; +} + +function includesText(value: string | undefined, needle: string): boolean { + return (value ?? "").toLocaleLowerCase().includes(needle.toLocaleLowerCase()); +} + +export function filterJournalEntries( + entries: JournalEntry[], + filters: JournalFilterOptions, +): JournalEntry[] { + return entries.filter((entry) => { + if (filters.meat && !includesText(entry.meat, filters.meat)) return false; + if (filters.minRating != null && (entry.rating == null || entry.rating < filters.minRating)) { + return false; + } + if ( + filters.search && + !includesText(entry.title, filters.search) && + !includesText(entry.notes, filters.search) + ) { + return false; + } + return true; + }); +} + /** Format the list view (newest first). */ export function formatList(entries: JournalEntry[]): string { if (entries.length === 0) { @@ -247,12 +307,14 @@ export function formatCostSummary(summary: CostSummary): string { export interface JournalExportOptions { format: "json" | "csv"; output?: string; + filters: JournalFilterOptions; } /** Parse args after `journal export`. Returns an error message on failure. */ export function parseJournalExportArgs(args: string[]): JournalExportOptions | { error: string } { let format: "json" | "csv" = "json"; let output: string | undefined; + const filterArgs: string[] = []; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -268,12 +330,19 @@ export function parseJournalExportArgs(args: string[]): JournalExportOptions | { const value = args[++i]; if (value === undefined) return { error: "--output requires a file path" }; output = value; + } else if (arg === "--meat" || arg === "--min-rating" || arg === "--search") { + filterArgs.push(arg); + const value = args[++i]; + if (value === undefined) return { error: `${arg} requires a value` }; + filterArgs.push(value); } else { return { error: `Unknown option: ${arg}` }; } } - return { format, output }; + const filters = parseJournalFilterArgs(filterArgs); + if ("error" in filters) return filters; + return { format, output, filters }; } function escapeCsvField(value: string): string { @@ -344,11 +413,13 @@ async function journalExport(args: string[]): Promise { } const entries = await loadJournal(); - const content = parsed.format === "csv" ? formatJournalCsv(entries) : formatJournalJson(entries); + const filtered = filterJournalEntries(entries, parsed.filters); + const content = + parsed.format === "csv" ? formatJournalCsv(filtered) : formatJournalJson(filtered); if (parsed.output) { await writeFile(parsed.output, content, "utf8"); console.error( - `Exported ${entries.length} journal entr${entries.length === 1 ? "y" : "ies"} to ${parsed.output}.`, + `Exported ${filtered.length} journal entr${filtered.length === 1 ? "y" : "ies"} to ${parsed.output}.`, ); return; } @@ -410,11 +481,11 @@ const USAGE = `Usage: thermoworks journal journal add --title "Sunday brisket" [--meat brisket] [--weight 12] [--rating 4] [--cost-meat 40] [--cost-fuel 8] [--notes "..."] [--device SN] [--archive ID] - journal list [--json] + journal list [--meat TEXT] [--min-rating N] [--search TEXT] [--json] journal show [--json] - journal cost [--json] + journal cost [--meat TEXT] [--min-rating N] [--search TEXT] [--json] journal import [SERIAL] [--limit N] [--dry-run] [--json] - journal export [--format json|csv] [--output PATH] + journal export [--format json|csv] [--output PATH] [--meat TEXT] [--min-rating N] [--search TEXT] journal rm `; /** Route `thermoworks journal ` to the right handler. */ @@ -437,12 +508,18 @@ export async function journal(args: string[], options: OutputOptions): Promise b.createdAt.localeCompare(a.createdAt))); + outputJson([...filtered].sort((a, b) => b.createdAt.localeCompare(a.createdAt))); return; } - process.stdout.write(formatList(entries)); + process.stdout.write(formatList(filtered)); break; } case "show": { @@ -464,8 +541,13 @@ export async function journal(args: string[], options: OutputOptions): Promise { }); }); +describe("journal filters", () => { + it("parses meat, minimum rating, and search filters", async () => { + const { parseJournalFilterArgs } = await import("../src/commands/journal.js"); + expect( + parseJournalFilterArgs(["--meat", "brisket", "--min-rating", "4", "--search", "wrap"]), + ).toEqual({ + meat: "brisket", + minRating: 4, + search: "wrap", + }); + }); + + it("rejects invalid filter options", async () => { + const { parseJournalFilterArgs } = await import("../src/commands/journal.js"); + expect(parseJournalFilterArgs(["--min-rating", "6"])).toEqual({ + error: '--min-rating must be an integer from 1 to 5, got "6"', + }); + expect(parseJournalFilterArgs(["--search"])).toEqual({ + error: "--search requires a value", + }); + expect(parseJournalFilterArgs(["--bogus"])).toEqual({ + error: "Unknown option: --bogus", + }); + }); + + it("filters entries by meat, rating, and title or notes search", async () => { + const { filterJournalEntries } = await import("../src/commands/journal.js"); + const entries = [ + sampleEntry({ + id: "match", + title: "Sunday brisket", + meat: "beef brisket", + rating: 5, + notes: "Wrapped at 165", + }), + sampleEntry({ + id: "wrong-rating", + title: "Friday brisket", + meat: "beef brisket", + rating: 3, + notes: "Wrapped early", + }), + sampleEntry({ + id: "wrong-meat", + title: "Pork ribs", + meat: "pork ribs", + rating: 5, + notes: "Wrapped at 165", + }), + ]; + + expect( + filterJournalEntries(entries, { meat: "brisket", minRating: 4, search: "wrapped" }).map( + (entry) => entry.id, + ), + ).toEqual(["match"]); + expect(filterJournalEntries(entries, { search: "sunday" }).map((entry) => entry.id)).toEqual([ + "match", + ]); + }); +}); + describe("formatEntry", () => { it("includes optional fields when present", async () => { const { formatEntry } = await import("../src/commands/journal.js"); @@ -352,10 +414,26 @@ describe("formatCostSummary", () => { describe("journal export helpers", () => { it("parses export options", async () => { const { parseJournalExportArgs } = await import("../src/commands/journal.js"); - expect(parseJournalExportArgs([])).toEqual({ format: "json", output: undefined }); - expect(parseJournalExportArgs(["--format", "csv", "--output", "cooks.csv"])).toEqual({ + expect(parseJournalExportArgs([])).toEqual({ + format: "json", + output: undefined, + filters: {}, + }); + expect( + parseJournalExportArgs([ + "--format", + "csv", + "--output", + "cooks.csv", + "--meat", + "ribs", + "--min-rating", + "4", + ]), + ).toEqual({ format: "csv", output: "cooks.csv", + filters: { meat: "ribs", minRating: 4 }, }); }); @@ -440,4 +518,21 @@ describe("journal export command", () => { ); expect(errorSpy).toHaveBeenCalledWith("Exported 2 journal entries to journal.json."); }); + + it("filters exported entries", async () => { + mockReadFile.mockResolvedValue( + JSON.stringify([ + sampleEntry({ id: "one", title: "Brisket", meat: "brisket", rating: 5 }), + sampleEntry({ id: "two", title: "Ribs", meat: "pork ribs", rating: 5 }), + ]) as never, + ); + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + const { journal } = await import("../src/commands/journal.js"); + await journal(["export", "--meat", "brisket"], { json: false }); + + const output = stdoutSpy.mock.calls.map((call) => call[0]).join(""); + expect(output).toContain('"id": "one"'); + expect(output).not.toContain('"id": "two"'); + }); });