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 }; + `; +}