From 9aad6beaa82eb011d60c65921594c9621e06029e Mon Sep 17 00:00:00 2001 From: NilsLeo Date: Thu, 21 May 2026 13:54:49 +0200 Subject: [PATCH] Add recipes command to list custom recipes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements `crono recipes` command to read custom recipe names from Cronometer's #custom-recipes page. Features: - Lists all custom recipes by name - Supports --json flag for machine-readable output - Uses Playwright codegen to scrape div.gwt-Label elements from recipe rows - Integrates with shared automation client via getRecipes() method The recipes are scraped from div[role="button"] rows containing .gwt-Label children on the custom recipes page. The automation waits for the search input and at least one recipe row to ensure the GWT widget has finished rendering before scraping. Example usage: crono recipes crono recipes --json Also includes package-lock.json updates to remove peer: true flags from dev dependencies. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package-lock.json | 6 ------ src/automation/runner.ts | 17 +++++++++++++++++ src/automation/types.ts | 5 +++++ src/commands/recipes.ts | 40 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 9 +++++++++ src/kernel/recipes.ts | 37 +++++++++++++++++++++++++++++++++++++ 6 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 src/commands/recipes.ts create mode 100644 src/kernel/recipes.ts diff --git a/package-lock.json b/package-lock.json index f68a0fb..21874ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1250,7 +1250,6 @@ "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1300,7 +1299,6 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -1641,7 +1639,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2089,7 +2086,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3232,7 +3228,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3970,7 +3965,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/automation/runner.ts b/src/automation/runner.ts index 7895abf..8c942f0 100644 --- a/src/automation/runner.ts +++ b/src/automation/runner.ts @@ -3,6 +3,7 @@ import { getCredential } from "../credentials.js"; import { buildAddCustomFoodCode } from "../kernel/add-custom-food.js"; import { buildDiaryCode } from "../kernel/diary.js"; import { buildLogFoodCode } from "../kernel/log-food.js"; +import { buildRecipesCode } from "../kernel/recipes.js"; import { buildAutoLoginCode, buildLoginCheckCode, @@ -19,6 +20,7 @@ import type { LogFoodEntry, MacroEntry, PlaywrightExecutionResponse, + RecipeData, WeightData, } from "./types.js"; @@ -97,6 +99,21 @@ export function createAutomationClient( ); } }), + getRecipes: (onStatus?: (msg: string) => void) => + withLoggedInRuntime(createRuntime, onStatus, async (runtime) => { + onStatus?.("Reading custom recipes..."); + const data = await executeAutomation<{ + success: boolean; + recipes?: RecipeData[]; + error?: string; + }>(runtime, buildRecipesCode(), 30); + if (!data.success) { + throw new Error( + `Recipes read failed: ${data.error ?? "Unknown error"}` + ); + } + return data.recipes ?? []; + }), }; } diff --git a/src/automation/types.ts b/src/automation/types.ts index df992f9..db3bbfc 100644 --- a/src/automation/types.ts +++ b/src/automation/types.ts @@ -45,6 +45,10 @@ export interface LogFoodEntry { servings?: number; } +export interface RecipeData { + name: string; +} + export interface AutomationClient { addQuickEntry( entry: MacroEntry, @@ -64,6 +68,7 @@ export interface AutomationClient { onStatus?: (msg: string) => void ): Promise; logFood(entry: LogFoodEntry, onStatus?: (msg: string) => void): Promise; + getRecipes(onStatus?: (msg: string) => void): Promise; } export interface PlaywrightExecutionResponse { diff --git a/src/commands/recipes.ts b/src/commands/recipes.ts new file mode 100644 index 0000000..eb357b6 --- /dev/null +++ b/src/commands/recipes.ts @@ -0,0 +1,40 @@ +import * as p from "@clack/prompts"; +import { getAutomationClient } from "../automation/client.js"; +import { formatKernelError } from "../kernel/errors.js"; + +export interface RecipesOptions { + json?: boolean; +} + +export async function recipes(options: RecipesOptions): Promise { + if (!options.json) { + p.intro("Crono recipes"); + } + + const s = options.json ? null : p.spinner(); + s?.start("Connecting..."); + + try { + const client = await getAutomationClient(); + const list = await client.getRecipes((msg) => s?.message(msg)); + + s?.stop("Done."); + + if (options.json) { + console.log(JSON.stringify(list, null, 2)); + } else { + if (list.length === 0) { + p.outro("No custom recipes found."); + } else { + for (const recipe of list) { + p.log.info(recipe.name); + } + p.outro(`${list.length} recipe${list.length === 1 ? "" : "s"}`); + } + } + } catch (error) { + s?.stop("Failed."); + p.log.error(`Failed to read recipes: ${formatKernelError(error)}`); + process.exit(1); + } +} diff --git a/src/index.ts b/src/index.ts index 1799176..840d4a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { log } from "./commands/log.js"; import { diary } from "./commands/diary.js"; import { weight } from "./commands/weight.js"; import { exportCmd } from "./commands/export.js"; +import { recipes } from "./commands/recipes.js"; const require = createRequire(import.meta.url); const { version } = require("../package.json"); @@ -111,4 +112,12 @@ program await exportCmd(type, options); }); +program + .command("recipes") + .description("List your custom recipes from Cronometer") + .option("--json", "Output as JSON") + .action(async (options) => { + await recipes(options); + }); + program.parse(); diff --git a/src/kernel/recipes.ts b/src/kernel/recipes.ts new file mode 100644 index 0000000..3e0d672 --- /dev/null +++ b/src/kernel/recipes.ts @@ -0,0 +1,37 @@ +/** + * Playwright code generator for Cronometer custom recipes scraping. + * + * Recipe names live in div.gwt-Label elements inside div[role="button"] + * rows within the scrollable list on the #custom-recipes page. + * + * Returns { success: true, recipes: [{ name }] } + */ +export function buildRecipesCode(): string { + return ` + await page.goto('https://cronometer.com/#custom-recipes', { waitUntil: 'domcontentloaded', timeout: 15000 }); + + const url = page.url(); + if (url.includes('/login') || url.includes('/signin')) { + return { success: false, error: 'Not logged in. Login may have failed.' }; + } + + // Wait for the GWT widget to render the recipe list. + // The search input appears before list items finish rendering, so we wait + // for an actual recipe row to be present before scraping. + await page.waitForSelector('input[placeholder*="recipes"]', { timeout: 10000 }); + await page.waitForSelector('div[role="button"] .gwt-Label', { timeout: 10000 }).catch(() => {}); + + const recipes = await page.evaluate(() => { + // Recipe rows are div[role="button"] elements containing a div.gwt-Label child + const rows = Array.from(document.querySelectorAll('div[role="button"]')); + return rows + .map((row) => { + const label = row.querySelector('.gwt-Label'); + return label ? { name: label.textContent?.trim() ?? '' } : null; + }) + .filter((r) => r !== null && r.name.length > 0); + }); + + return { success: true, recipes }; + `; +}