From 2a1e16ed306dce7855f932d127236dd09d502871 Mon Sep 17 00:00:00 2001 From: Nicholas Ward Date: Tue, 18 Aug 2026 18:22:34 -0500 Subject: [PATCH] add core features (#14) Reviewed-on: https://git.uuard.com/nicholas/formulation/pulls/14 Co-authored-by: Nicholas Ward --- .gitignore | 10 +- README.md | 45 +- docs/agent-handoff.md | 57 + docs/api-and-mcp-guide.md | 320 ++++ docs/culinary-data-model.md | 22 +- docs/help/README.md | 28 + .../help/archive/lifecycle-and-restoration.md | 23 + .../costing/purchase-items-and-costing.md | 25 + .../getting-started/workspace-navigation.md | 36 + .../ingredients/units-and-equivalencies.md | 30 + .../inventory/count-sheets-and-locations.md | 26 + .../inventory-calendar-steps-for-success.md | 100 ++ docs/help/recipes/bakers-percentages.md | 28 + docs/help/recipes/scaling-and-yields.md | 41 + docs/help/recipes/sub-recipes-and-prep.md | 45 + docs/local-application.md | 72 +- docs/recipe-style-guide.md | 114 ++ migrations/003_inventory.sql | 37 + migrations/004_archive_parity.sql | 2 + package-lock.json | 1293 +++++++++----- package.json | 15 +- .../circular/CircularCustCapNum-Black.woff2 | Bin 0 -> 35076 bytes .../circular/CircularCustCapNum-Bold.woff2 | Bin 0 -> 36304 bytes .../circular/CircularCustCapNum-Book.woff2 | Bin 0 -> 34248 bytes .../circular/CircularCustCapNum-Light.woff2 | Bin 0 -> 35796 bytes .../circular/CircularCustCapNum-Medium.woff2 | Bin 0 -> 35908 bytes scratch/test-fig-bars.mjs | 51 + scripts/backup.mjs | 145 ++ scripts/db-backup.mjs | 33 + scripts/db-sync.mjs | 19 +- scripts/dev-server.mjs | 15 + scripts/ingest-docling-book.mjs | 795 +++++++++ scripts/lib/site-projection.mjs | 8 +- scripts/lint-recipe-instructions.mjs | 146 ++ scripts/mcp-server.mjs | 335 ++++ .../pages/api/app/backup/export.ts | 37 + .../pages/api/app/backup/import.ts | 37 + .../pages/api/app/backup/validate.ts | 25 + .../api/app/recipes/parse-ingredients.ts | 20 + src/application/pages/api/v1/archive/index.ts | 23 + .../pages/api/v1/archive/restore.ts | 31 + .../pages/api/v1/collections/[id].ts | 73 + .../pages/api/v1/collections/index.ts | 46 + src/application/pages/api/v1/convert.ts | 36 + .../pages/api/v1/equipment/index.ts | 26 + .../pages/api/v1/ingredients/[id].ts | 72 + .../pages/api/v1/ingredients/index.ts | 52 + .../pages/api/v1/inventory/counts.ts | 58 + .../pages/api/v1/inventory/counts/[id].ts | 77 + .../pages/api/v1/prep-actions/index.ts | 23 + .../pages/api/v1/purchases/[id]/index.ts | 73 + .../pages/api/v1/purchases/[id]/prices.ts | 39 + .../pages/api/v1/purchases/index.ts | 54 + .../pages/api/v1/recipes/[id]/cost.ts | 29 + .../pages/api/v1/recipes/[id]/index.ts | 79 + .../pages/api/v1/recipes/[id]/nutrition.ts | 27 + .../pages/api/v1/recipes/[id]/scale.ts | 33 + src/application/pages/api/v1/recipes/index.ts | 53 + .../pages/api/v1/recipes/quality.ts | 23 + src/application/pages/api/v1/units/index.ts | 27 + src/application/pages/app/archive.astro | 669 +++++++- src/application/pages/app/index.astro | 292 +++- .../pages/app/ingredients/[id].astro | 361 +++- .../pages/app/inventory/[id].astro | 657 +++++++ .../pages/app/inventory/index.astro | 519 ++++++ .../pages/app/recipe-books/[id].astro | 245 ++- .../pages/app/recipe-books/new.astro | 95 +- src/application/pages/app/recipes/[id].astro | 454 ++++- .../pages/app/settings/index.astro | 696 ++++++++ .../pages/tools/purchasing-review.astro | 62 +- src/components/DetailUtility.astro | 94 +- src/components/EntityDirectory.tsx | 53 +- src/components/Icon.tsx | 69 + src/components/PurchasingReview.tsx | 303 +++- src/components/RecipeCalculator.tsx | 492 +++++- src/components/RecipeStructureEditor.tsx | 1164 +++++++------ src/components/WorkspaceFilterBar.tsx | 620 +++++++ .../BulkIngredientImportModal.tsx | 87 + .../recipe-editor/BulkPrepStepsModal.tsx | 80 + .../recipe-editor/RecipeItemRow.tsx | 356 ++++ .../recipe-editor/RecipeMethodEditor.tsx | 162 ++ src/components/recipe-editor/index.ts | 6 + src/components/recipe-editor/types.ts | 23 + .../recipe-editor/useRecipeStructure.ts | 676 ++++++++ src/lib/backup/backup.test.ts | 207 +++ src/lib/backup/export-database.ts | 284 +++ src/lib/backup/import-database.ts | 482 ++++++ src/lib/backup/index.ts | 4 + src/lib/backup/types.ts | 297 ++++ src/lib/backup/validate-backup.ts | 110 ++ src/lib/costing.test.ts | 13 + src/lib/costing.ts | 57 +- src/lib/data.ts | 55 +- src/lib/database.test.ts | 280 +++ src/lib/database.ts | 107 +- src/lib/format.test.ts | 13 +- src/lib/format.ts | 13 +- src/lib/icons.ts | 72 + src/lib/ingredient-parser.test.ts | 27 + src/lib/ingredient-parser.ts | 186 ++ src/lib/inventory.test.ts | 122 ++ src/lib/measurement.test.ts | 4 +- src/lib/measurement.ts | 2 +- src/lib/nutrition.ts | 4 +- src/lib/repository/index.ts | 4 + src/lib/repository/ingredient-repository.ts | 100 ++ src/lib/repository/inventory-repository.ts | 285 +++ src/lib/repository/recipe-repository.ts | 506 ++++++ src/lib/repository/types.ts | 191 +++ src/lib/types.ts | 2 + src/mcp/mcp.test.ts | 353 ++++ src/mcp/server.ts | 362 ++++ src/mcp/tools.ts | 1091 ++++++++++++ src/styles/base.css | 371 ++++ src/styles/components/entity-directory.css | 1294 ++++++++++++++ src/styles/components/inventory.css | 84 + src/styles/components/recipe-books.css | 73 + src/styles/components/recipe-edit.css | 864 ++++++++++ src/styles/components/recipe-view.css | 1525 +++++++++++++++++ src/styles/components/tabs.css | 124 ++ src/styles/components/tooltips.css | 640 +++++++ src/styles/global.css | 813 +-------- src/styles/layout.css | 353 ++++ src/styles/responsive.css | 806 +++++++++ src/styles/tokens.css | 70 + 125 files changed, 23392 insertions(+), 2082 deletions(-) create mode 100644 docs/agent-handoff.md create mode 100644 docs/api-and-mcp-guide.md create mode 100644 docs/help/README.md create mode 100644 docs/help/archive/lifecycle-and-restoration.md create mode 100644 docs/help/costing/purchase-items-and-costing.md create mode 100644 docs/help/getting-started/workspace-navigation.md create mode 100644 docs/help/ingredients/units-and-equivalencies.md create mode 100644 docs/help/inventory/count-sheets-and-locations.md create mode 100644 docs/help/inventory/inventory-calendar-steps-for-success.md create mode 100644 docs/help/recipes/bakers-percentages.md create mode 100644 docs/help/recipes/scaling-and-yields.md create mode 100644 docs/help/recipes/sub-recipes-and-prep.md create mode 100644 docs/recipe-style-guide.md create mode 100644 migrations/003_inventory.sql create mode 100644 migrations/004_archive_parity.sql create mode 100644 public/fonts/circular/CircularCustCapNum-Black.woff2 create mode 100644 public/fonts/circular/CircularCustCapNum-Bold.woff2 create mode 100644 public/fonts/circular/CircularCustCapNum-Book.woff2 create mode 100644 public/fonts/circular/CircularCustCapNum-Light.woff2 create mode 100644 public/fonts/circular/CircularCustCapNum-Medium.woff2 create mode 100644 scratch/test-fig-bars.mjs create mode 100644 scripts/backup.mjs create mode 100644 scripts/db-backup.mjs create mode 100644 scripts/dev-server.mjs create mode 100644 scripts/ingest-docling-book.mjs create mode 100644 scripts/lint-recipe-instructions.mjs create mode 100644 scripts/mcp-server.mjs create mode 100644 src/application/pages/api/app/backup/export.ts create mode 100644 src/application/pages/api/app/backup/import.ts create mode 100644 src/application/pages/api/app/backup/validate.ts create mode 100644 src/application/pages/api/app/recipes/parse-ingredients.ts create mode 100644 src/application/pages/api/v1/archive/index.ts create mode 100644 src/application/pages/api/v1/archive/restore.ts create mode 100644 src/application/pages/api/v1/collections/[id].ts create mode 100644 src/application/pages/api/v1/collections/index.ts create mode 100644 src/application/pages/api/v1/convert.ts create mode 100644 src/application/pages/api/v1/equipment/index.ts create mode 100644 src/application/pages/api/v1/ingredients/[id].ts create mode 100644 src/application/pages/api/v1/ingredients/index.ts create mode 100644 src/application/pages/api/v1/inventory/counts.ts create mode 100644 src/application/pages/api/v1/inventory/counts/[id].ts create mode 100644 src/application/pages/api/v1/prep-actions/index.ts create mode 100644 src/application/pages/api/v1/purchases/[id]/index.ts create mode 100644 src/application/pages/api/v1/purchases/[id]/prices.ts create mode 100644 src/application/pages/api/v1/purchases/index.ts create mode 100644 src/application/pages/api/v1/recipes/[id]/cost.ts create mode 100644 src/application/pages/api/v1/recipes/[id]/index.ts create mode 100644 src/application/pages/api/v1/recipes/[id]/nutrition.ts create mode 100644 src/application/pages/api/v1/recipes/[id]/scale.ts create mode 100644 src/application/pages/api/v1/recipes/index.ts create mode 100644 src/application/pages/api/v1/recipes/quality.ts create mode 100644 src/application/pages/api/v1/units/index.ts create mode 100644 src/application/pages/app/inventory/[id].astro create mode 100644 src/application/pages/app/inventory/index.astro create mode 100644 src/application/pages/app/settings/index.astro create mode 100644 src/components/Icon.tsx create mode 100644 src/components/WorkspaceFilterBar.tsx create mode 100644 src/components/recipe-editor/BulkIngredientImportModal.tsx create mode 100644 src/components/recipe-editor/BulkPrepStepsModal.tsx create mode 100644 src/components/recipe-editor/RecipeItemRow.tsx create mode 100644 src/components/recipe-editor/RecipeMethodEditor.tsx create mode 100644 src/components/recipe-editor/index.ts create mode 100644 src/components/recipe-editor/types.ts create mode 100644 src/components/recipe-editor/useRecipeStructure.ts create mode 100644 src/lib/backup/backup.test.ts create mode 100644 src/lib/backup/export-database.ts create mode 100644 src/lib/backup/import-database.ts create mode 100644 src/lib/backup/index.ts create mode 100644 src/lib/backup/types.ts create mode 100644 src/lib/backup/validate-backup.ts create mode 100644 src/lib/database.test.ts create mode 100644 src/lib/icons.ts create mode 100644 src/lib/ingredient-parser.test.ts create mode 100644 src/lib/ingredient-parser.ts create mode 100644 src/lib/inventory.test.ts create mode 100644 src/lib/repository/index.ts create mode 100644 src/lib/repository/ingredient-repository.ts create mode 100644 src/lib/repository/inventory-repository.ts create mode 100644 src/lib/repository/recipe-repository.ts create mode 100644 src/lib/repository/types.ts create mode 100644 src/mcp/mcp.test.ts create mode 100644 src/mcp/server.ts create mode 100644 src/mcp/tools.ts create mode 100644 src/styles/base.css create mode 100644 src/styles/components/entity-directory.css create mode 100644 src/styles/components/inventory.css create mode 100644 src/styles/components/recipe-books.css create mode 100644 src/styles/components/recipe-edit.css create mode 100644 src/styles/components/recipe-view.css create mode 100644 src/styles/components/tabs.css create mode 100644 src/styles/components/tooltips.css create mode 100644 src/styles/layout.css create mode 100644 src/styles/responsive.css create mode 100644 src/styles/tokens.css diff --git a/.gitignore b/.gitignore index f6858d8..95c38cc 100644 --- a/.gitignore +++ b/.gitignore @@ -31,8 +31,10 @@ Thumbs.db /ui-reference*.html /ui-reference*_files/ /ui-reference*.png +/screenshots/ -# Local application databases and SQLite sidecars -/var/*.sqlite -/var/*.sqlite-shm -/var/*.sqlite-wal +# Local application databases, backups, logs, and SQLite sidecars +/var/ +file.json +database.sqlite +/scratch/ diff --git a/README.md b/README.md index f780514..8852fe4 100644 --- a/README.md +++ b/README.md @@ -38,23 +38,46 @@ npm ci python3 -m pip install --user -r requirements-dev.txt ``` -## Database +## Database & Backups SQLite is the canonical writable store. The database is located at -`var/recipe-book.sqlite` and is intentionally excluded from Git. The single -baseline in `migrations/001_initial.sql` defines its complete schema. +`var/recipe-book.sqlite` and is intentionally excluded from Git. -Create a new local database from the portable culinary dataset: +All normal recipe, ingredient, nutrition-mapping, and purchasing changes must +be written to SQLite through the application or its validated database +functions. This rule also applies to automated and AI-assisted edits. Do not +edit `culinary/*.yaml` as a way to update a running application, and do not use +unrestricted SQL when `saveRecipeStructure()` or another domain save function +is available. + +### Backup and Restore + +To create a full, verifiable JSON backup of the active database: ```bash -npm run db:reset +npm run db:backup -- [path/to/backup.json] ``` -This command replaces an existing local database. There is intentionally no -legacy upgrade chain. YAML under `culinary/` is retained as portable seed and -interchange data; normal edits in the management application write to SQLite. +To validate a backup bundle without modifying the database: + +```bash +npm run db:validate -- path/to/backup.json +``` + +To restore a backup into SQLite (transactional replace mode): + +```bash +npm run db:restore -- path/to/backup.json +``` + +Backups can also be downloaded and restored interactively through the web UI at `/app/settings/`. + +Generated projections and future YAML/JSON exports flow outward from SQLite. +They are suitable for presentation, backup, interchange, and Git review, but +must not be edited independently and treated as authoritative. See [Local application](docs/local-application.md) for more detail. +For moving development to another machine, see [Agent handoff](docs/agent-handoff.md). ## Development @@ -64,6 +87,12 @@ Run the editor: npm run dev:app ``` +Ingredient bulk entry uses the local Ollama service through +`http://10.0.10.211:11434/api/chat` and the purpose-built +`qwen3:4b-instruct` parsing prompt. Override these defaults with +`FORMULATION_OLLAMA_URL` and `FORMULATION_INGREDIENT_PARSER_MODEL`. The parser +endpoint is disabled whenever `FORMULATION_READ_ONLY=true`. + Open . To stop any process listening on the application port and start a fresh Astro diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md new file mode 100644 index 0000000..9054c81 --- /dev/null +++ b/docs/agent-handoff.md @@ -0,0 +1,57 @@ +# Agent handoff + +## Repository state + +Development happens on `dev`; `master` is the deployable integration branch. +Use Node.js 22 or newer and install dependencies with `npm ci`. + +SQLite is the canonical writable store. YAML in `culinary/` is portable seed and +interchange data, not the live editing surface. Application and automated edits +should use validated domain functions and transactions rather than unrestricted +SQL or direct YAML changes. + +## Database Management + +SQLite (`var/recipe-book.sqlite`) is the canonical writable store. +Application and automated edits must use validated domain functions and transactions +rather than direct YAML changes or unrestricted SQL. + +### Starting the Application + +```sh +npm ci +npm run dev:app +``` + +### Backups and Transfers + +The runtime database lives under `var/`, which is intentionally ignored by Git. + +To backup or hand off the current live state: + +```sh +# Export full JSON backup +npm run db:backup -- var/recipe-book-backup.json + +# Restore database from backup on receiving machine +npm run db:restore -- var/recipe-book-backup.json +``` + +Place the transferred file at `var/recipe-book.sqlite` on the receiving machine. +The backup command uses SQLite's online backup API, includes committed WAL data, +and refuses to overwrite an existing destination. + +## Validate a change + +```sh +scripts/validate-content +npm run check:app +npm test +npm run build:app +git diff --check +``` + +The application supports a read-only deployment with +`FORMULATION_READ_ONLY=true`. Ingredient bulk parsing additionally accepts +`FORMULATION_OLLAMA_URL` and `FORMULATION_INGREDIENT_PARSER_MODEL`; USDA imports +read `USDA_FDC_API_KEY` from the environment. diff --git a/docs/api-and-mcp-guide.md b/docs/api-and-mcp-guide.md new file mode 100644 index 0000000..5c45960 --- /dev/null +++ b/docs/api-and-mcp-guide.md @@ -0,0 +1,320 @@ +# Formulation API & Model Context Protocol (MCP) Guide + +Formulation exposes two programmatic interfaces for querying, scaling, costing, and manipulating culinary data: +1. **Model Context Protocol (MCP) Server**: A standard stdio JSON-RPC server enabling AI assistants (Claude Desktop, Antigravity, Cursor, Gemini) to directly search, retrieve, cost, scale, and save recipes, ingredients, books, and inventory counts. +2. **REST Web API (`/api/v1/`)**: HTTP JSON endpoints for external systems, webhooks, and programmatic integrations. + +--- + +## 1. Model Context Protocol (MCP) Server + +### Starting the Server +```bash +npm run mcp +# or directly: +node scripts/mcp-server.mjs +``` + +### Client Configuration Examples + +#### Claude Desktop (`%APPDATA%\Claude\claude_desktop_config.json` on Windows / `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS) +```json +{ + "mcpServers": { + "formulation": { + "command": "node", + "args": [ + "C:\\Users\\nicholas\\Documents\\repos\\formulation\\scripts\\mcp-server.mjs" + ] + } + } +} +``` + +#### Antigravity / Gemini (`.gemini/config/mcp_config.json`) +```json +{ + "mcpServers": { + "formulation": { + "command": "node", + "args": [ + "scripts/mcp-server.mjs" + ] + } + } +} +``` + +#### Cursor (`.cursor/mcp.json`) +```json +{ + "mcpServers": { + "formulation": { + "command": "node", + "args": ["scripts/mcp-server.mjs"] + } + } +} +``` + +--- + +### MCP Tools Reference (15 Domain Tools) + +| Tool | Parameters | Description | +| --- | --- | --- | +| `search_recipes` | `query?`, `category?`, `tag?`, `limit?` | Search recipes by keyword, category, or tag | +| `get_recipe` | `id`, `scale_factor?`, `target_yield?`, `target_yield_unit?` | Retrieve full recipe formulation with optional scaling | +| `save_recipe` | `id?`, `title`, `yield_quantity`, `yield_unit_id`, `components`, `steps`, `notes?` | Create or update a recipe formulation with validation | +| `delete_recipe` | `id` | Safely archive a recipe from the active library | +| `calculate_recipe_cost` | `recipe_id`, `currency?` | Compute itemized ingredient & sub-recipe cost breakdown | +| `calculate_recipe_nutrition` | `recipe_id`, `serving_size_g?` | Compute nutrition facts panel (macros/micros) | +| `search_ingredients` | `query?`, `category?`, `missing_cost?`, `limit?` | Search ingredients in the pantry catalog | +| `get_ingredient` | `id` | Get ingredient detail with density, conversions, and prices | +| `save_ingredient` | `id?`, `name`, `description?`, `categories?`, `tags?`, `aliases?`, `density?` | Create or update an ingredient with density & aliases | +| `delete_ingredient` | `id` | Safely archive an ingredient (blocks if used in active recipes) | +| `convert_units` | `ingredient_id?`, `quantity`, `from_unit`, `to_unit` | Convert culinary units safely using density data | +| `list_inventory_counts` | `status?` (`'all' \| 'open' \| 'completed'`) | List inventory counting sessions | +| `get_inventory_count` | `id` | Get full count sheet with locations, items, unit costs, and valuations | +| `list_recipe_books` | *(none)* | List recipe books (collections) with recipe counts | +| `get_recipe_book` | `id` | Get recipe book details with ordered included recipes | +| `save_recipe_book` | `id?`, `name`, `description?`, `recipe_ids?` | Create or update a recipe book / collection | +| `export_database_backup` | *(none)* | Export complete JSON backup of all 25 SQLite tables | +| `get_database_stats` | *(none)* | Get table entity counts across the database | + +--- + +## 2. REST Web API Specification (`/api/v1/`) + +All REST endpoints return standard JSON envelopes: +- Success: `{ "success": true, "data": ... }` +- Error: `{ "success": false, "error": "Description" }` + +### Recipes + +#### List Recipes +```http +GET /api/v1/recipes?q=biscotti&limit=10 +``` + +#### Get Recipe Details & Dynamic Scaling +```http +GET /api/v1/recipes/chocolate_biscotti?scale=2 +``` + +#### Calculate Scaled Quantities +```http +POST /api/v1/recipes/chocolate_biscotti/scale +Content-Type: application/json + +{ + "target_yield": 100, + "target_yield_unit": "each" +} +``` + +#### Itemized Cost Rollup +```http +GET /api/v1/recipes/chocolate_biscotti/cost?currency=USD +``` + +#### Nutrition Facts Rollup +```http +GET /api/v1/recipes/chocolate_biscotti/nutrition +``` + +#### Create Recipe +```http +POST /api/v1/recipes +Content-Type: application/json + +{ + "title": "Vanilla Glaze", + "yield_quantity": 250, + "yield_unit_id": "gram", + "yield_servings": 10, + "categories": ["sauce"], + "components": [ + { + "name": "Main", + "items": [ + { "ingredient_id": "confectioners_sugar", "quantity": 200, "unit_id": "gram" }, + { "ingredient_id": "milk", "quantity": 45, "unit_id": "gram" }, + { "ingredient_id": "vanilla_extract", "quantity": 5, "unit_id": "gram" } + ] + } + ], + "steps": [ + { "instruction": "In a medium bowl, whisk confectioners sugar, milk, and vanilla extract until smooth." } + ] +} +``` + +#### Update Recipe +```http +PUT /api/v1/recipes/vanilla_glaze +``` + +#### Archive Recipe +```http +DELETE /api/v1/recipes/vanilla_glaze +``` + +--- + +### Ingredients + +#### List Ingredients +```http +GET /api/v1/ingredients?q=sugar&limit=10 +``` + +#### Get Ingredient Details +```http +GET /api/v1/ingredients/sugar +``` + +#### Create Ingredient +```http +POST /api/v1/ingredients +Content-Type: application/json + +{ + "name": "Matcha Powder", + "description": "Ceremonial Japanese green tea powder", + "categories": ["tea", "flavoring"], + "tags": ["japanese", "beverage"], + "aliases": [{ "name": "Matcha" }], + "density": { + "mass_quantity": 60, + "mass_unit_id": "gram", + "volume_quantity": 0.25, + "volume_unit_id": "cup_us" + } +} +``` + +#### Update Ingredient +```http +PUT /api/v1/ingredients/matcha_powder +``` + +#### Archive Ingredient +```http +DELETE /api/v1/ingredients/matcha_powder +``` +*(Fails safely with HTTP 409 Conflict if ingredient is referenced by active recipes.)* + +--- + +### Recipe Books / Collections + +#### List Recipe Books +```http +GET /api/v1/collections +``` + +#### Get Recipe Book +```http +GET /api/v1/collections/baking_essentials +``` + +#### Create Recipe Book +```http +POST /api/v1/collections +Content-Type: application/json + +{ + "name": "Holiday Pastries", + "description": "Seasonal baked goods collection", + "recipe_ids": ["chocolate_biscotti", "cinnamon_sugar"] +} +``` + +#### Update Recipe Book +```http +PUT /api/v1/collections/holiday_pastries +``` + +#### Archive Recipe Book +```http +DELETE /api/v1/collections/holiday_pastries +``` + +--- + +### Inventory Counts + +#### List Counting Sessions +```http +GET /api/v1/inventory/counts?status=all +``` + +#### Start New Count Session +```http +POST /api/v1/inventory/counts +Content-Type: application/json + +{ + "title": "August End-of-Month Count", + "counted_at": "2026-08-31T18:00:00Z", + "notes": "Full kitchen and pantry audit", + "prepopulate": true +} +``` + +#### Get Count Sheet & Extended Valuation +```http +GET /api/v1/inventory/counts/count_2026_08_week3 +``` + +#### Save Count Items & Update Status +```http +PUT /api/v1/inventory/counts/count_2026_08_week3 +Content-Type: application/json + +{ + "status": "completed", + "items": [ + { "ingredient_id": "flour_all_purpose", "location_id": "loc_dry_storage", "quantity": 15000, "unit_id": "gram" }, + { "ingredient_id": "sugar", "location_id": "loc_dry_storage", "quantity": 25, "unit_id": "pound" } + ] +} +``` + +--- + +### Unit Conversions + +```http +POST /api/v1/convert +Content-Type: application/json + +{ + "ingredient_id": "salt", + "quantity": 2, + "from_unit": "tbsp", + "to_unit": "gram" +} +``` +```json +{ + "success": true, + "data": { + "from": { "quantity": 2, "unit_id": "tbsp" }, + "to": { "quantity": 36.52, "unit_id": "gram" }, + "ingredient_id": "salt", + "method": "ingredient_measure_conversion" + } +} +``` + +--- + +### Database Backups + +```http +GET /api/app/backup/export +POST /api/app/backup/validate +POST /api/app/backup/import?mode=replace|merge +``` diff --git a/docs/culinary-data-model.md b/docs/culinary-data-model.md index eed6006..8c0a302 100644 --- a/docs/culinary-data-model.md +++ b/docs/culinary-data-model.md @@ -13,10 +13,11 @@ current canonical state. Names used for search or display belong in ingredient ## Canonical and derived boundaries -Canonical records live under `culinary/`. They contain authored or observed +Canonical writable records live in SQLite. They include authored or observed facts: recipes, ingredients, measurements, provenance, suppliers, packages, -and price observations. Projections may later be generated for another -application or database, but they are never canonical. +and price observations. YAML under `culinary/` is portable seed/interchange +data, while generated site projections and exports are downstream products. +Neither is an independently writable source of truth. Derived recipe records contain reproducible nutrition, allergen rollups, and costs. They identify the recipe, calculation version, calculation time, and an @@ -129,7 +130,14 @@ truth. Recipes without authored instructions contain one explicit TODO step. Formula-only conversions use a nominal 100 g basis and a theoretical yield until those values are replaced by observed production data. -Astro reads a generated, read-only SQLite projection and produces static recipe -pages. Interactive calculators are small Preact islands supplied with resolved, -typed recipe data. Astro and Preact remain presentation consumers; culinary -calculations and editing originate in the database and shared calculation tools. +Application and agent changes must use validated domain save functions and +SQLite transactions. Direct SQL is reserved for schema-aware maintenance where +no domain operation exists. Reset/import commands flow from YAML into SQLite and +therefore overwrite the current store; export commands flow from SQLite into a +portable representation. + +Astro reads SQLite through the application data layer. Generated projections +support read-only presentation, and interactive calculators are Preact islands +supplied with resolved, typed recipe data. Astro, Preact, and projections remain +presentation consumers; culinary calculations and editing originate in SQLite +and shared domain tools. diff --git a/docs/help/README.md b/docs/help/README.md new file mode 100644 index 0000000..7b0c3ef --- /dev/null +++ b/docs/help/README.md @@ -0,0 +1,28 @@ +# Formulation Documentation & Knowledge Base + +Welcome to the Formulation Help Center. This documentation explains the architecture, business logic, and operational workflows for managing recipes, ingredients, costs, units of measure, inventory, and archives. + +--- + +## Knowledge Base Directory + +### 🚀 Getting Started +- [Workspace Navigation & Global Search](./getting-started/workspace-navigation.md): Navigating workspaces, filtering catalogs, global search, and keyboard shortcuts. + +### 🍳 Recipes & Formulas +- [Scaling, Batching & Yield Calculations](./recipes/scaling-and-yields.md): Interactive scaling, yield conversions, weight-based auto-yields, and portion control. +- [Baker's & Standard Percentages](./recipes/bakers-percentages.md): Flour basis calculation, dynamic target weights, and formula ratios. +- [Sub-recipes & Prep Methods](./recipes/sub-recipes-and-prep.md): Nesting recipes as ingredients, prep instructions, headers, notes, and equipment tracking. + +### 🌿 Ingredients & Units of Measure +- [Units of Measure & Custom Equivalencies](./ingredients/units-and-equivalencies.md): Dimensional systems (mass, volume, count), canonical conversions, density measures, and USDA nutrition mapping. + +### 💰 Costing & Purchasing +- [Purchase Items, Pack Sizes & Recipe Costing](./costing/purchase-items-and-costing.md): Invoices, pack configurations, yield factors, price history, food cost per batch, and cost per serving. + +### 📦 Inventory Management +- [Inventory Steps for Success (5-Week Implementation Roadmap)](./inventory/inventory-calendar-steps-for-success.md): Full operational guide for going from initial setup to first live period-end inventory count. +- [Count Sheets & Storage Locations](./inventory/count-sheets-and-locations.md): Location-specific sheet-to-shelf counting, on-hand inputs, and live extended valuations. + +### 🗄️ Archive & Trash Lifecycle +- [Archive & Lifecycle Management](./archive/lifecycle-and-restoration.md): Soft-deletion, catalog filtering, multi-item batch restore, and safe permanent deletion guards. diff --git a/docs/help/archive/lifecycle-and-restoration.md b/docs/help/archive/lifecycle-and-restoration.md new file mode 100644 index 0000000..05eea75 --- /dev/null +++ b/docs/help/archive/lifecycle-and-restoration.md @@ -0,0 +1,23 @@ +# Archive & Lifecycle Management + +Formulation implements a two-stage deletion lifecycle (Soft Delete $\rightarrow$ Hard Delete) with automated dependency safeguards to protect culinary data integrity. + +--- + +## 1. Soft Deletion (Archiving) + +- When an ingredient, recipe, or recipe book is deleted, it is **soft-deleted** (`deleted_at` timestamp recorded) rather than purged immediately. +- Archived items are immediately hidden from active searches, auto-complete dropdowns, and directory views. +- Active recipes that historically reference an archived ingredient remain intact without breaking calculations. + +--- + +## 2. Archive Workspace (`/app/archive/`) + +The Archive workspace allows viewing and managing all removed items: +- **Filter by Entity**: Filter by *All*, *Recipes*, *Ingredients*, or *Recipe Books*. +- **Multi-Item Batch Selection**: Select multiple items using checkboxes to perform bulk actions. +- **Batch Restore**: Instantly restore selected items back to the active catalog. +- **Safe Permanent Deletion**: + - Permanently purges items from the database. + - **Dependency Safeguard**: Formulation automatically verifies whether an item is still referenced by any active recipe or sub-recipe. If dependencies exist, hard deletion is blocked with a clear warning explaining where the item is currently used. diff --git a/docs/help/costing/purchase-items-and-costing.md b/docs/help/costing/purchase-items-and-costing.md new file mode 100644 index 0000000..5d025ec --- /dev/null +++ b/docs/help/costing/purchase-items-and-costing.md @@ -0,0 +1,25 @@ +# Purchase Items, Pack Sizes & Recipe Costing + +Accurate recipe food costing relies on mapping real-world vendor purchase packages to canonical ingredients. + +--- + +## 1. Purchase Items & Pack Configurations + +A **Purchase Item** represents a commercial package purchased from a vendor or distributor: +- **Pack Size & Unit**: e.g., `50 lb Bag`, `6 x 1 Gallon Case`, `16 oz Container`. +- **Cost**: Total package purchase price (e.g. `$24.50`). +- **Yield Factor (%)**: The usable portion percentage after trimming or prep (e.g. 85% usable yield on trimmed beef tenderloin, 100% on flour). +- **Unit Cost**: Automatically computed per base unit (e.g. `$0.00108 / gram` or `$0.49 / lb`). + +--- + +## 2. Recipe Food Costing Breakdown + +When viewing a recipe's **Cost** tab: +1. **Line Cost**: Each ingredient's line item cost is calculated as: + $$\text{Line Cost} = \frac{\text{Quantity} \times \text{Unit Cost}}{\text{Yield Factor}}$$ +2. **Total Batch Cost**: The sum of all line item costs for the batch. +3. **Cost per Serving**: Total Batch Cost divided by total yield servings. +4. **Food Cost % (Target Selling Price)**: + $$\text{Suggested Price} = \frac{\text{Cost per Serving}}{\text{Target Food Cost \%}}$$ diff --git a/docs/help/getting-started/workspace-navigation.md b/docs/help/getting-started/workspace-navigation.md new file mode 100644 index 0000000..32d781f --- /dev/null +++ b/docs/help/getting-started/workspace-navigation.md @@ -0,0 +1,36 @@ +# Workspace Navigation & Global Search + +Formulation provides a streamlined, fast, centralized directory for managing all culinary data across your operation. + +--- + +## 1. Directory Workspace & Workspace Pills + +The home directory (`/app/`) categorizes items into distinct workspaces using top pill badges: + +- **Recipes** (Blue badge): Standalone formulas, prep recipes, and batch formulations. +- **Ingredients** (Green badge): Raw culinary ingredients, allergens, density conversions, and supplier links. +- **Recipe Books** (Purple badge): Curated collections and menus of recipes (e.g. *Dinner Menu*, *Cocktails*, *Bakery Line*). +- **Purchase Items** (Cyan badge): Commercial vendor packages, invoice pack sizes, prices, and vendor SKUs. +- **Inventory** (Teal badge): Active and past inventory count sessions with on-hand valuations. +- **Archive** (Neutral link): Soft-deleted items ready for restoration or permanent purge. + +--- + +## 2. Global Search & Autocompletion + +- **Omnibox Search**: Search across recipe titles, ingredient names, aliases, and purchase items simultaneously. +- **Type Filtering**: Narrow search results by specific entity type directly from the search dropdown filter. +- **Keyboard Navigation**: + - Tab / Arrow Down: Highlight matching search candidates. + - Enter: Open the selected recipe or ingredient detail card immediately. + - Escape: Clear search and close active popups. + +--- + +## 3. Detail Utility Bar + +Every single item detail page features a fixed top utility bar containing: +- **Workspace Breadcrumbs**: Direct navigation back to the active directory workspace. +- **Global Search**: Search and jump to other items without returning to the home screen. +- **New Action Button (`+ New`)**: Quick creation modal for recipes, ingredients, recipe books, or count sessions from anywhere in the app. diff --git a/docs/help/ingredients/units-and-equivalencies.md b/docs/help/ingredients/units-and-equivalencies.md new file mode 100644 index 0000000..53b3a77 --- /dev/null +++ b/docs/help/ingredients/units-and-equivalencies.md @@ -0,0 +1,30 @@ +# Units of Measure & Custom Equivalencies + +Formulation maintains a rigorous, multi-dimensional unit conversion engine that enforces physical dimensional rules while supporting culinary volume-to-weight equivalencies. + +--- + +## 1. Dimensional Systems + +Every unit belongs to a fundamental physical dimension: +- **Mass** (Base unit: `gram`): `gram`, `kilogram`, `pound`, `ounce_mass`. +- **Volume** (Base unit: `milliliter`): `milliliter`, `liter`, `cup_us` (240 mL legal), `tablespoon_us`, `teaspoon_us`, `fluid_ounce_us`. +- **Count** (Base unit: `each`): `each`, `clove`, `head`, `bunch`. +- **Temperature** (Affine scale): `fahrenheit`, `celsius`. + +--- + +## 2. Density & Ingredient-Specific UoM Equivalencies + +Because ingredients possess different bulk densities (e.g. 1 cup of all-purpose flour = ~120g, whereas 1 cup of honey = ~340g), volume-to-mass conversions require density records. + +### A. UoM Equivalencies Panel +- On each ingredient page, the **UoM Equivalency** tab allows defining custom portion measurements: + - *Example*: `1 cup = 125 g` + - *Example*: `1 medium apple = 182 g` + - *Example*: `1 clove garlic = 3 g` + +### B. Resolution Precedence +1. **Reviewed Portions / Measures**: Checked first for an exact unit match (e.g. `cup` or `each`). +2. **Bulk Density Measurements**: Checked if converting between standard volume and mass dimensions. +3. **Canonical Unit Factor**: Applied for within-dimension conversions (e.g. `lb` to `oz`). diff --git a/docs/help/inventory/count-sheets-and-locations.md b/docs/help/inventory/count-sheets-and-locations.md new file mode 100644 index 0000000..0d1ba1e --- /dev/null +++ b/docs/help/inventory/count-sheets-and-locations.md @@ -0,0 +1,26 @@ +# Count Sheets & Storage Locations + +Inventory in Formulation is designed for fast, sheet-to-shelf counting across physical kitchen storage locations. + +--- + +## 1. Storage Locations + +Organize physical storage areas into logical zones: +- **Walk-in Cooler**: Dairy, produce, raw proteins, prepped batch items. +- **Dry Storage**: Flours, grains, spices, oils, canned goods. +- **Freezer**: Frozen stocks, puff pastry, frozen proteins. +- **Bar / Front of House**: Spirits, syrups, mixers, garnishes. +- **Line Stations**: Sauté station drawers, prep table bins. + +--- + +## 2. Conducting an Inventory Count + +To conduct a count session: +1. Go to **Inventory** in the directory toolbar. +2. Select **+ New Count Session**. +3. In the count session view, select a location filter to display items in shelf order. +4. For each line item, enter the on-hand quantity in the **Count** box. +5. Review the **Extended Value ($)** column, which automatically computes the value based on current vendor purchase costs. +6. Select **Finalize Count** to complete the count and lock the valuation for accounting. diff --git a/docs/help/inventory/inventory-calendar-steps-for-success.md b/docs/help/inventory/inventory-calendar-steps-for-success.md new file mode 100644 index 0000000..f1f2aa0 --- /dev/null +++ b/docs/help/inventory/inventory-calendar-steps-for-success.md @@ -0,0 +1,100 @@ +# Inventory Steps for Success + +A structured 5-week roadmap to build out recipes, configure purchasing units and costs, organize location-specific count sheets, test inventory counting, and successfully conduct your first live inventory. + +--- + +## 5-Week Roadmap Overview + +```mermaid +gantt + title Inventory Onboarding & Setup Roadmap + dateFormat X + axisFormat Day %d + section Week 1 + Recipes & Ingredients Setup :active, 1, 7 + section Week 2 + Purchasing Units & Costs :2, 14 + section Week 3 + Location Count Sheets :3, 21 + section Week 4 + Dry-Run Test Counts :4, 28 + section Week 5 + First Live Inventory & Analytics :5, 35 +``` + +--- + +## Week 1: Build Out Recipes & Ingredients Tables + +> **Week 1 Goal**: Your complete recipe database and canonical ingredient list are populated and ready for kitchen use. + +| Day | Action Item | Details & Instructions | +|---|---|---| +| **Monday** | **Goal Kickoff** | Define the scope of prep items, sub-recipes, and raw ingredients to be tracked. | +| **Tuesday** | **Start with Prep Recipes** | Begin by entering your prep recipes and sub-recipes. As you add prep recipes, your canonical ingredient list will automatically populate. | +| **Wednesday** | **Audit & Merge Ingredients** | Review your ingredient catalog. Identify duplicates or near-duplicates (e.g. "kosher salt" vs "salt kosher") and merge them into single canonical ingredients. | +| **Thursday** | **Duplicate Multi-Type Items** | Make distinct copies of ingredients where you use multiple varieties or grades of the same item (e.g. *Flour - All Purpose* vs *Flour - Bread High Gluten*). | +| **Friday** | **Review Kitchen Database** | Verify that recipes have components, steps, and yields properly structured. | +| **Saturday & Sunday** | **Milestone Check** | **Look at that!** You now have a complete, standardized recipe database that can be used actively on the kitchen line. | + +--- + +## Week 2: Configure Costs & Purchase Units + +> **Week 2 Goal**: All inventoried ingredients have verified purchase packages, unit costs, and yield factors. + +| Day | Action Item | Details & Instructions | +|---|---|---| +| **Monday** | **Goal Kickoff** | Gather recent supplier invoices, receipts (e.g., Walmart, Sam's Club, US Foods, Sysco), and vendor order guides. | +| **Tuesday** | **Invoice Processing & Linking** | Ingest invoice lines into the system to extract package sizes, prices, and vendor SKU codes. | +| **Wednesday** | **Manual Costing** | For specialty or local market items without digital invoices, enter package costs manually on the ingredient cost panel. | +| **Thursday** | **Spreadsheet Import** | If you maintain vendor price lists in spreadsheets, upload or batch-map purchase packages into your catalog. | +| **Friday** | **Map New Purchase Items** | Use the Purchase Items table to map raw invoice line descriptions to their canonical formulation ingredients. | +| **Saturday** | **Audit Missing Costs** | Filter your ingredient directory to inspect which items are still unpriced. Add missing package sizes. | +| **Sunday** | **Milestone Check** | **Prep recipes now show real costs!** Take a well-deserved break—your recipe costing foundation is complete. | + +--- + +## Week 3: Build Location Count Sheets + +> **Week 3 Goal**: Sheet-to-shelf inventory count lists are configured for each physical storage area. + +| Day | Action Item | Details & Instructions | +|---|---|---| +| **Monday** | **Goal Kickoff** | Identify all physical storage areas across your operation (e.g., *Walk-In Cooler*, *Dry Storage*, *Freezer*, *Bar*, *Line Drawers*). | +| **Tuesday** | **Create Count Sheets** | Go to **+ New** and select **Count Sheet**. Ensure count sheets are strictly location-specific. *(Note: Only managers can create count templates).* | +| **Wednesday** | **Order "Sheet to Shelf"** | Arrange ingredients in the exact physical order they appear on your shelves (top-to-bottom, left-to-right). This maximizes counting speed and prevents missed items. | +| **Thursday** | **Add Ingredients & Batches** | Add raw ingredients (green icon) and prepped batch recipes (blue icon) to each count sheet. Drag and drop to reorder. | +| **Friday** | **Set Count Units** | Verify and adjust count units (e.g. *Cases*, *Bags*, *Each*, *Pounds*) to match how cooks physically count each shelf. Count units default to the ingredient's primary purchase unit. | +| **Saturday & Sunday** | **Milestone Check** | **Almost there!** All location count sheets are structured and ready for validation. | + +--- + +## Week 4: Test Run & Validate Inventory Lists + +> **Week 4 Goal**: Perform a dry-run test count to uncover unit mismatch errors, pack size discrepancies, or missing items. + +| Day | Action Item | Details & Instructions | +|---|---|---| +| **Monday** | **Goal Kickoff** | Schedule a 20-minute test run with key kitchen leads before service. | +| **Tuesday** | **Enter Test Count Values** | Enter a dummy quantity of **`1`** in every column (or enter last month's closing count). Save and submit each location sheet individually (do not submit total final count). | +| **Wednesday** | **Export Valuation Report** | Review the calculated on-hand values and line-item totals in the analytics review. | +| **Thursday** | **Identify Discrepancies** | Look for extended dollar values that look unusually high or low. This highlights where pack sizes (e.g. $50/case counted as 1 ea = $50 vs $2.08) or count units need calibration. | +| **Friday** | **Correct Count Templates** | Update pack sizes, count units, or ingredient equivalencies based on test run findings. | +| **Saturday & Sunday** | **Milestone Check** | **Take a deep breath!** Your inventory templates are calibrated, validated, and ready for real operational use. | + +--- + +## Week 5: Conduct Your First Live Inventory + +> **Week 5 Goal**: Successfully execute full period-end inventory, capture total valuation, and establish your inventory baseline. + +| Day | Action Item | Details & Instructions | +|---|---|---| +| **Monday** | **Conduct Live Count** | Assign team members to their respective locations with mobile devices or clipboards. | +| **Tuesday** | **Add Items On the Fly** | If an unlisted item is discovered on a shelf during the count, add it on the fly. *(Remember to add it to the master count template afterward).* | +| **Wednesday** | **Review & Submit Count** | Once all location lists are filled, managers review pending location totals and submit the total inventory count. | +| **Thursday** | **Analyze Inventory Valuation** | Review the total dollar valuation report by storage location and ingredient category. *(Calculations finalize within minutes).* | +| **Friday** | **Export Accounting Reports** | Export your finalized inventory valuation breakdown categorized by GL accounting codes for bookkeeping. | +| **Saturday & Sunday** | **Celebrate Success!** | You now have a repeatable, accurate, high-speed inventory process embedded into your culinary operations! | diff --git a/docs/help/recipes/bakers-percentages.md b/docs/help/recipes/bakers-percentages.md new file mode 100644 index 0000000..177e6dd --- /dev/null +++ b/docs/help/recipes/bakers-percentages.md @@ -0,0 +1,28 @@ +# Baker's & Standard Percentages + +In baking and commercial food manufacturing, formulas use **percentages** to ensure recipe scalability and hydration control. + +--- + +## 1. Standard Percentage vs. Baker's Percentage + +### Standard % (Total Formulation Basis) +$$\text{Standard \%} = \frac{\text{Ingredient Weight}}{\text{Total Batch Weight}} \times 100$$ +- In Standard Percentage mode, the sum of all ingredient percentages in the recipe equals **100%**. +- Use Standard % for confectionery, dressings, beverages, and general culinary batching. + +### Baker's % (Flour / Basis Member Basis) +$$\text{Baker's \%} = \frac{\text{Ingredient Weight}}{\text{Total Basis Flour Weight}} \times 100$$ +- In Baker's Percentage mode, the flour or designated base ingredients are flagged as **Base Members** (`basis_member = true`) and sum to **100%**. +- All other ingredients (such as hydration water, salt, yeast, sugar, and butter) are expressed as a percentage relative to the total flour weight (such as 75% hydration water, 2% salt, 1.5% yeast). + +--- + +## 2. Using Interactive Percentage Editing + +To configure and edit percentages in a recipe: +1. Open the recipe in edit mode. +2. Turn on the **Calculate %** toggle. +3. Select **Standard %** or **Baker's %**. +4. For Baker's %, select the **Base** check box for each flour or grain ingredient. +5. In the **%** column, enter the desired percentage for an ingredient. Formulation dynamically calculates the required physical weight and quantity in grams. diff --git a/docs/help/recipes/scaling-and-yields.md b/docs/help/recipes/scaling-and-yields.md new file mode 100644 index 0000000..3baefef --- /dev/null +++ b/docs/help/recipes/scaling-and-yields.md @@ -0,0 +1,41 @@ +# Scaling, Batching & Yield Calculations + +Formulation is a weight-first formulation engine designed to scale recipes across commercial batch sizes without calculation rounding drift. + +--- + +## 1. Batch Multipliers vs. Yield-Target Scaling + +Recipes can be scaled in two primary modes: + +### A. Batch Multiplier (`x` Factor) +To scale by a batch multiplier: +1. Open the recipe in view mode or edit mode. +2. In the **Batch** field, enter a multiplier value (such as `0.5`, `2`, `5`, or `10`). +3. Every ingredient quantity scales proportionally by the exact factor. + +### B. Target Yield Scaling +To scale to a specific finished yield target: +1. Open the recipe. +2. In the **Yield** field, enter the target finished quantity. +3. Select the desired yield unit from the unit list. +4. Formulation computes the required scale factor based on total recipe weight and updates all ingredient quantities immediately. + +--- + +## 2. Weight-Based Auto-Yield Calculation + +To enable automatic total yield calculation: +1. Open the recipe in edit mode. +2. Turn on the **Auto calculate total yield** toggle. +3. Formulation calculates the weight in grams for every ingredient using standard conversion factors or ingredient-specific density measurements. +4. The total recipe yield quantity updates automatically to equal the exact sum of all ingredient weights. +5. If an ingredient lacks a volume-to-weight equivalency, a notice appears: *"Auto yield excludes N ingredient amounts without a weight equivalency."* + +--- + +## 3. Unit Conversion Safety + +- Ingredients measured in mass (such as `g`, `kg`, `oz`, `lb`) convert directly across all mass units. +- Ingredients measured in volume (such as `cup`, `tbsp`, `tsp`, `liter`, `ml`) require an ingredient density measurement (such as `1 cup = 120 g`) to convert to mass. +- Cross-dimensional conversions without density data are rejected to maintain strict culinary accuracy. diff --git a/docs/help/recipes/sub-recipes-and-prep.md b/docs/help/recipes/sub-recipes-and-prep.md new file mode 100644 index 0000000..7e41fbb --- /dev/null +++ b/docs/help/recipes/sub-recipes-and-prep.md @@ -0,0 +1,45 @@ +# Sub-recipes & Prep Methods + +Recipes in Formulation can nest other recipes as **sub-recipes**, enabling modular batch preparation and accurate cost and nutrition rollup. + +--- + +## 1. Using Sub-recipes in Formulations + +To add a sub-recipe to a recipe: +1. In the recipe editor, go to the **Formula** section. +2. In the ingredient search box, enter the name of the existing recipe. +3. From the search results, select the recipe (identified by the blue **Recipe** badge). +4. Enter the required quantity and select the unit of measure. + +### Cascading Cost & Nutrition +- The sub-recipe's unit cost and nutritional profile are calculated based on its own ingredients and yield, and cascaded into the parent recipe. +- Changes to a base sub-recipe (such as *House Mayonnaise*) automatically propagate up to all dishes that include it (such as *Aioli*, *Tartar Sauce*, and *Sandwich Spread*). + +--- + +## 2. Structured Prep Method + +The **Prep Method** editor organizes kitchen instructions into ordered, sequential steps. + +### Step-by-Step Instructions +1. In the recipe editor, go to the **Prep Method** section. +2. Select **Add Step** (or press Enter) to create a new step. +3. Enter the step instructions using imperative action verbs (such as *Combine*, *Preheat*, *Whisk*, or *Bake*). +4. To reorder steps, select and drag the 6-dot drag handle to the new position. + +### Section Headings +To break complex multi-stage procedures into logical phases: +- End the step text with a colon `:` (for example, `To prepare the dough:` or `To bake:`). +- Formulation formats these entries as distinct section headers without numbered step bullets. + +### Inline Prep Notes +To add non-actionable tips or precautions: +- Wrap the text in parentheses `(...)` (for example, `(Note: Chill the dough for at least 30 minutes before rolling.)`). +- Formulation formats these entries as italicized notes without numbered step bullets. + +### Bulk Prep Import +To import an existing recipe procedure from text: +1. In the **Prep Method** header, select **Bulk Add**. +2. Paste the multi-line procedure into the text area. +3. Select **Import Steps**. Formulation automatically parses section headings, numbered steps, and notes into structured cards. diff --git a/docs/local-application.md b/docs/local-application.md index b98f8e9..0ad3d3a 100644 --- a/docs/local-application.md +++ b/docs/local-application.md @@ -4,11 +4,30 @@ The local application uses SQLite as its canonical data store. YAML remains a portable import/export format, but normal application saves do not modify it. Derived nutrition and cost are still calculated rather than stored. -Create the initial database from the current portable dataset with Node 22 or -newer, then run either application mode: +## Source-of-truth rule + +- SQLite is the only writable source of truth for a running installation. +- Humans should edit through the management application. +- Automation and AI agents should call validated application commands or domain + save functions such as `saveRecipeStructure()`. +- Agents should not edit YAML to change live data and should not issue + unrestricted SQL when a domain operation exists. +- Generated site projections and exports are downstream products of SQLite. + +A safe automated recipe change follows this flow: + +```text +agent request + -> validate recipe structure and references + -> domain save function + -> SQLite transaction + -> refresh derived projection + -> optional explicit export for backup or review +``` + +Run either application mode with Node 22 or newer: ```sh -npm run db:reset npm run dev:readonly npm run dev:app ``` @@ -18,11 +37,44 @@ editing controls, and rejects modifying HTTP requests. Browser-side scaling, unit conversion, nutrition, and costing calculations remain available. The database is written to `var/recipe-book.sqlite` and is intentionally ignored -by Git. `migrations/001_initial.sql` defines the complete relational schema. -The application does not support upgrading databases from older schemas: rebuild -from portable data with `npm run db:reset`. Recipe edits are transactional and a -private save token prevents stale browser tabs from overwriting newer changes. -There is no recipe revision history. +by Git. Recipe edits are transactional and a private save token prevents stale +browser tabs from overwriting newer changes. -`npm run db:import:yaml` and `npm run db:reset` both replace the database from -portable data and are intended for initial setup or an explicit restore. +### Backup and Restore + +To export or restore database snapshots across all 25 SQLite tables: + +```sh +# Export a JSON backup +npm run db:backup -- [path/to/backup.json] + +# Restore database from backup +npm run db:restore -- path/to/backup.json +``` + +Users can also export and restore backups interactively from the web UI at `/app/settings/`. + +## Windows dev-server notes + +Node is installed at `C:\Program Files\nodejs` but is not on the default +agent shell PATH. Prefix every npm/npx command: + +```bat +cmd /c "set PATH=C:\Program Files\nodejs;%PATH%&& npm run dev:app" +``` + +`astro dev` runs as a detached daemon (Astro 7). To stop it, find the PID +from the port and kill it directly — `scripts/restart-app.mjs` reads `/proc` +and does not work on Windows: + +```bat +netstat -ano | findstr :4322 +taskkill /PID /F /T +``` + +A long-running dev server inherited from another session can degrade +silently: pages render but Preact islands never hydrate (empty +`astro-island`, no console error). Before debugging component code, check +whether the recipe table hydrates and, if not, restart the dev server. The +URL pattern `?astro&type=script` returns 500 even when hydration works — it +is not a valid diagnostic. diff --git a/docs/recipe-style-guide.md b/docs/recipe-style-guide.md new file mode 100644 index 0000000..a1e2cca --- /dev/null +++ b/docs/recipe-style-guide.md @@ -0,0 +1,114 @@ +# Formulation Recipe Instruction Style Guide + +This style guide establishes procedural writing standards for culinary formulas and kitchen preparation methods in Formulation, adapting the [Microsoft Style Guide for Step-by-Step Instructions](https://learn.microsoft.com/en-us/style-guide/procedures-instructions/writing-step-by-step-instructions) and related procedural principles to commercial culinary workflows. + +--- + +## 1. Core Principles + +### A. Use Imperative Verb Forms +In technical and culinary procedures, readers scan instructions to execute immediate actions. Begin each step with a direct, active verb. + +- **Do**: "In a large bowl, whisk the flour, sugar, and baking powder." +- **Do**: "Preheat the deck oven to 450 °F (232 °C)." +- **Don't**: "Dry ingredients should be mixed." *(Passive voice)* +- **Don't**: "Mixing the dry ingredients." *(Gerund fragment)* +- **Don't**: "Next, you will want to whisk the flour..." *(Conversational filler)* + +### B. Place Conditions and Locations First +State prerequisites, equipment, or locations before the action so the cook prepares the workstation before executing the step. + +- **Do**: "In the bowl of a stand mixer fitted with the dough hook, combine the water and yeast." +- **Do**: "On a lightly floured surface, divide the dough into 8 equal portions." +- **Do**: "If the sauce begins to separate, whisk in 1 tablespoon of warm water." +- **Don't**: "Combine the water and yeast in the bowl of a stand mixer fitted with the dough hook." +- **Don't**: "Divide the dough into 8 equal portions on a lightly floured surface." + +### C. Maintain Parallel Grammatical Structure +All steps in a numbered sequence must follow a consistent grammatical pattern. + +- **Do**: + 1. Combine the dry ingredients in a large bowl. + 2. Whisk the eggs and milk in a separate pitcher. + 3. Pour the liquid mixture into the dry ingredients. +- **Don't**: + 1. Combine the dry ingredients. + 2. Eggs and milk are whisked together. + 3. Pouring the liquid into dry ingredients. + +### D. Single Action Units +Limit each numbered entry to one cohesive operational step. Combine only closely coupled micro-actions occurring at the same station. + +- **Do**: + 1. Heat the oil in a heavy-bottomed pot over medium-high heat. + 2. Add the diced onions and cook for 5 minutes, or until translucent. + 3. Stir in the minced garlic and cook for 1 minute until fragrant. +- **Don't**: + 1. Heat the oil, chop and cook the onions until translucent, then add garlic and cook for 1 minute before pouring in the stock. + +### E. Include Sensory Criteria and Measurable Targets +Pair time and temperature measurements with visual, tactile, or olfactory checkpoints. + +- **Do**: "Bake at 375 °F for 45 to 50 minutes, or until a cake tester inserted into the center comes out clean." +- **Do**: "Simmer over low heat for 20 minutes, or until the liquid has reduced by half." +- **Don't**: "Bake for a while until done." +- **Don't**: "Cook for 20 minutes." *(Lacks target consistency cue)* + +--- + +## 2. Formatting, Capitalization, and Punctuation + +| Element | Format Rule | Example | +|---|---|---| +| **Step Sentence** | Capitalize first word; end with a period. | `Transfer the dough to a clean, oiled bowl.` | +| **Section Headings** | Sentence case or Title case; must end with a colon `:`. | `For the dough:` / `To bake and finish:` | +| **Inline Prep Notes** | Wrapped in parentheses `(...)`; italicized in UI. | `(Note: Dough can be refrigerated for up to 48 hours before baking.)` | +| **Temperatures** | Number followed by degree symbol and scale (`°F`, `°C`). | `375 °F (190 °C)` | +| **Dimensions & Times** | Standard units with spaces; hyphenated when modifying nouns. | `1/2-inch dice` / `2 to 3 minutes` | +| **Critical Control Points (CCP)** | Prefix with `[CCP]` followed by regulatory threshold. | `[CCP] Hold hot at 135 °F (57 °C) or above.` | + +--- + +## 3. Sectioning Complex Multi-Stage Recipes + +When a recipe consists of more than 5 to 7 steps, organize the procedure into logical stages using section headers ending with a colon: + +```yaml +steps: + - id: step_01 + order: 1 + instruction: "For the dough:" + - id: step_02 + order: 2 + instruction: "In a stand mixer bowl, combine the flour, yeast, and salt." + - id: step_03 + order: 3 + instruction: "Add the warm water and mix on low speed for 6 minutes." + - id: step_04 + order: 4 + instruction: "To proof and shape:" + - id: step_05 + order: 5 + instruction: "Cover the bowl with plastic wrap and let rise for 1 hour at room temperature." + - id: step_06 + order: 6 + instruction: "Divide the dough into 12 equal rounds and place on a parchment-lined sheet pan." + - id: step_07 + order: 7 + instruction: "To bake:" + - id: step_08 + order: 8 + instruction: "Bake at 425 °F for 18 to 20 minutes, or until deep golden brown." +``` + +--- + +## 4. UI Reference Conventions (Help Docs & Application Copy) + +When writing help articles or in-app instructions describing user interactions with Formulation: + +- **Bold UI Names**: Always bold buttons, fields, tabs, and menu items (e.g. **Save**, **Prep Method**, **Total Yield**). +- **Use "Select"**: Use **Select** rather than *Click*, *Click on*, or *Tap*. +- **Use "Enter"**: Use **Enter** rather than *Type in* or *Input*. +- **Use "Go to"**: Use **Go to** for tab or page navigation (e.g. "Go to **Recipes** > **New Recipe**"). +- **Avoid UI Jargon**: Avoid referring to *dialog boxes*, *blades*, or *dropdown menus* unless essential for clarity. diff --git a/migrations/003_inventory.sql b/migrations/003_inventory.sql new file mode 100644 index 0000000..138c9f5 --- /dev/null +++ b/migrations/003_inventory.sql @@ -0,0 +1,37 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS inventory_locations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + position INTEGER NOT NULL, + deleted_at TEXT +); + +CREATE TABLE IF NOT EXISTS inventory_counts ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + counted_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + notes TEXT, + created_at TEXT NOT NULL, + deleted_at TEXT +); + +CREATE TABLE IF NOT EXISTS inventory_count_items ( + count_id TEXT NOT NULL REFERENCES inventory_counts(id) ON DELETE CASCADE, + location_id TEXT REFERENCES inventory_locations(id), + ingredient_id TEXT NOT NULL REFERENCES ingredients(id), + quantity REAL NOT NULL, + unit_id TEXT NOT NULL REFERENCES units(id), + unit_cost REAL, + extended_cost REAL, + PRIMARY KEY (count_id, location_id, ingredient_id) +); + +-- Seed baseline standard locations if table is empty +INSERT OR IGNORE INTO inventory_locations (id, name, position, deleted_at) VALUES + ('loc_walk_in', 'Walk-in Cooler', 1, NULL), + ('loc_dry_storage', 'Dry Storage', 2, NULL), + ('loc_freezer', 'Freezer', 3, NULL), + ('loc_bar', 'Bar & Service', 4, NULL), + ('loc_line', 'Prep Line', 5, NULL); diff --git a/migrations/004_archive_parity.sql b/migrations/004_archive_parity.sql new file mode 100644 index 0000000..444a125 --- /dev/null +++ b/migrations/004_archive_parity.sql @@ -0,0 +1,2 @@ +-- Soft delete parity for purchase items +ALTER TABLE purchase_items ADD COLUMN deleted_at TEXT; diff --git a/package-lock.json b/package-lock.json index 89bcc2f..fdfd5ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { - "name": "recipe-book", + "name": "formulation", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "recipe-book", + "name": "formulation", "dependencies": { "@astrojs/node": "^11.1.1", "@astrojs/preact": "6.0.2", + "@modelcontextprotocol/sdk": "^1.30.0", "astro": "7.2.1", "preact": "10.29.8", - "yaml": "2.9.0" + "yaml": "2.9.0", + "zod": "^4.4.3" }, "devDependencies": { "@astrojs/check": "0.9.4", @@ -1272,6 +1274,18 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1786,20 +1800,44 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, "engines": { - "node": "^22.20 || ^24.12 || >=25" + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } } }, "node_modules/@napi-rs/wasm-runtime": { @@ -2173,331 +2211,6 @@ } } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", - "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", - "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", - "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", - "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", - "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", - "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", - "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", - "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", - "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", - "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", - "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", - "cpu": [ - "loong64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", - "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", - "cpu": [ - "loong64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", - "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", - "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", - "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", - "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", - "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", - "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", - "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", - "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "openbsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", - "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "openharmony" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", - "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", - "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", - "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", - "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, "node_modules/@shikijs/core": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", @@ -2882,11 +2595,23 @@ "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", "dev": true }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2912,6 +2637,23 @@ } } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ajv-i18n": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/ajv-i18n/-/ajv-i18n-4.2.0.tgz", @@ -3127,6 +2869,43 @@ "node": ">=6.0.0" } }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -3164,6 +2943,44 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001809", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", @@ -3386,6 +3203,28 @@ "node": ">= 18" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3408,6 +3247,46 @@ "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==" }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/crossws": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", @@ -3629,6 +3508,20 @@ "node": ">=4" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3668,11 +3561,41 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -3744,6 +3667,27 @@ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -3753,6 +3697,77 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3761,8 +3776,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", @@ -3781,7 +3795,6 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", - "dev": true, "funding": [ { "type": "github", @@ -3817,6 +3830,27 @@ } } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/flattie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", @@ -3844,6 +3878,15 @@ "node": ">=20" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -3865,6 +3908,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3882,6 +3934,43 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "5.0.0-beta.4", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", @@ -3901,6 +3990,18 @@ "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==" }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/h3": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", @@ -3917,6 +4018,30 @@ "uncrypto": "^0.1.3" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hast-util-from-html": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", @@ -4023,6 +4148,15 @@ "he": "bin/he" } }, + "node_modules/hono": { + "version": "4.13.2", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz", + "integrity": "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-escaper": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", @@ -4061,11 +4195,45 @@ "url": "https://opencollective.com/express" } }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -4108,6 +4276,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz", + "integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4148,8 +4337,13 @@ "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" }, "node_modules/json5": { "version": "2.2.3", @@ -4445,6 +4639,15 @@ "source-map-js": "^1.2.1" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdast-util-to-hast": { "version": "13.2.1", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", @@ -4470,6 +4673,31 @@ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==" }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/micromark-util-character": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", @@ -4613,6 +4841,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/neotraverse": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-1.0.1.tgz", @@ -4679,6 +4916,27 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -4717,6 +4975,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/oniguruma-parser": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", @@ -4788,12 +5055,40 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", "dev": true }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4821,6 +5116,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -4913,6 +5217,35 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/radix3": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", @@ -4930,6 +5263,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -4983,7 +5331,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -5041,52 +5388,28 @@ "@rolldown/binding-win32-x64-msvc": "1.2.3" } }, - "node_modules/rollup": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", - "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", - "optional": true, - "peer": true, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.62.4", - "@rollup/rollup-android-arm64": "4.62.4", - "@rollup/rollup-darwin-arm64": "4.62.4", - "@rollup/rollup-darwin-x64": "4.62.4", - "@rollup/rollup-freebsd-arm64": "4.62.4", - "@rollup/rollup-freebsd-x64": "4.62.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", - "@rollup/rollup-linux-arm-musleabihf": "4.62.4", - "@rollup/rollup-linux-arm64-gnu": "4.62.4", - "@rollup/rollup-linux-arm64-musl": "4.62.4", - "@rollup/rollup-linux-loong64-gnu": "4.62.4", - "@rollup/rollup-linux-loong64-musl": "4.62.4", - "@rollup/rollup-linux-ppc64-gnu": "4.62.4", - "@rollup/rollup-linux-ppc64-musl": "4.62.4", - "@rollup/rollup-linux-riscv64-gnu": "4.62.4", - "@rollup/rollup-linux-riscv64-musl": "4.62.4", - "@rollup/rollup-linux-s390x-gnu": "4.62.4", - "@rollup/rollup-linux-x64-gnu": "4.62.4", - "@rollup/rollup-linux-x64-musl": "4.62.4", - "@rollup/rollup-openbsd-x64": "4.62.4", - "@rollup/rollup-openharmony-arm64": "4.62.4", - "@rollup/rollup-win32-arm64-msvc": "4.62.4", - "@rollup/rollup-win32-ia32-msvc": "4.62.4", - "@rollup/rollup-win32-x64-gnu": "4.62.4", - "@rollup/rollup-win32-x64-msvc": "4.62.4", - "fsevents": "~2.3.2" + "node": ">= 18" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/satteri": { "version": "0.9.5", "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.9.5.tgz", @@ -5150,6 +5473,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/server-destroy": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz", @@ -5221,6 +5563,27 @@ "node": ">=10" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/shiki": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", @@ -5239,6 +5602,78 @@ "node": ">=20" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -5442,6 +5877,37 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "optional": true }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typesafe-path": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz", @@ -5594,6 +6060,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unstorage": { "version": "1.17.5", "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", @@ -5752,6 +6227,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -6274,6 +6758,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -6290,6 +6789,12 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/xxhash-wasm": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", @@ -6455,10 +6960,20 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index f121da4..c35b4af 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "recipe-book", + "name": "formulation", "private": true, "type": "module", "engines": { @@ -19,20 +19,27 @@ "start:readonly": "FORMULATION_READ_ONLY=true HOST=127.0.0.1 PORT=4399 node dist/app/server/entry.mjs", "preview:app": "astro preview --config astro.app.config.mjs --port 4322", "test": "vitest run", - "db:reset": "node scripts/db-sync.mjs --reset", - "db:import:yaml": "node scripts/db-sync.mjs --reset" + "db:backup": "node scripts/backup.mjs export", + "db:restore": "node scripts/backup.mjs import", + "db:validate": "node scripts/backup.mjs validate", + "mcp": "node scripts/mcp-server.mjs" }, "dependencies": { "@astrojs/node": "^11.1.1", "@astrojs/preact": "6.0.2", + "@modelcontextprotocol/sdk": "^1.30.0", "astro": "7.2.1", "preact": "10.29.8", - "yaml": "2.9.0" + "yaml": "2.9.0", + "zod": "^4.4.3" }, "devDependencies": { "@astrojs/check": "0.9.4", "@types/node": "^22.10.0", "typescript": "5.9.2", "vitest": "4.1.10" + }, + "allowScripts": { + "esbuild@0.28.2": true } } diff --git a/public/fonts/circular/CircularCustCapNum-Black.woff2 b/public/fonts/circular/CircularCustCapNum-Black.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e4e918769835ad97aa275fc03143076c7e91c35b GIT binary patch literal 35076 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�)T`SI^~k&dSQr|x4} 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 literal 0 HcmV?d00001 diff --git a/public/fonts/circular/CircularCustCapNum-Bold.woff2 b/public/fonts/circular/CircularCustCapNum-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..459da908b321d7dcecdc2e2fcc99a47be33119e0 GIT binary patch literal 36304 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%0A90SWL3YwTm13ejo6r%}K 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

1Vb;sxZjzW`Eu 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%0D;}wuF)Hlhk5WWTCo1C2 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%0D#yCJ`{Kv9r(UKNYMGC<4@@zc;u(( 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 literal 0 HcmV?d00001 diff --git a/scratch/test-fig-bars.mjs b/scratch/test-fig-bars.mjs new file mode 100644 index 0000000..1e07b31 --- /dev/null +++ b/scratch/test-fig-bars.mjs @@ -0,0 +1,51 @@ +import fs from "node:fs"; + +const doc = JSON.parse(fs.readFileSync("file.json", "utf8")); + +function isProcedureHeader(text, label) { + if (label !== "section_header") return false; + const s = text.trim(); + if (/^chef's notes?:?$/i.test(s)) return false; + if (/^table of contents|foreword|introduction|acknowledgements|how to use/i.test(s)) return false; + return /procedure|assembly|variations?|baking|shaping|finishing|glaz|infusion/i.test(s) || s.endsWith(":"); +} + +function cleanProcedureHeader(raw) { + const t = raw.trim().replace(/^['"]+|['":]+$/g, "").trim(); + if (/^procedure$/i.test(t)) return ""; + const cleaned = t.replace(/\s+procedure$/i, "").trim(); + return cleaned ? `${cleaned}:` : ""; +} + +function testPages(pages, title) { + const texts = doc.texts.filter(t => pages.includes(t.prov?.[0]?.page_no)); + console.log(`\nTesting on ${title} (pages ${pages.join("-")}):`); + + let inProcedure = false; + const steps = []; + + for (const t of texts) { + const text = t.text.trim(); + if (/^chef's notes?:?$/i.test(text)) { + inProcedure = false; + continue; + } + if (isProcedureHeader(text, t.label)) { + inProcedure = true; + const heading = cleanProcedureHeader(text); + if (heading) { + steps.push({ id: `step_${steps.length + 1}`, order: steps.length + 1, instruction: heading }); + } + continue; + } + if (inProcedure && (t.label === "list_item" || t.label === "text") && !/^\d+$/.test(text) && !/^yields?:/i.test(text)) { + steps.push({ id: `step_${steps.length + 1}`, order: steps.length + 1, instruction: text }); + } + } + + console.log("Extracted steps count:", steps.length); + steps.forEach(s => console.log(" " + (s.instruction.endsWith(":") ? "📂 " : " • ") + s.instruction)); +} + +testPages([312, 313], "Fig Bars"); +testPages([26, 27], "Chocolate Puff Pastry"); diff --git a/scripts/backup.mjs b/scripts/backup.mjs new file mode 100644 index 0000000..8002be6 --- /dev/null +++ b/scripts/backup.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { DatabaseSync } from "node:sqlite"; + +const root = path.resolve(import.meta.dirname, ".."); +const databasePath = path.join(root, "var", "recipe-book.sqlite"); + +// Dynamically import compiled or source backup engine +import { exportDatabase } from "../src/lib/backup/export-database.ts"; +import { importDatabase } from "../src/lib/backup/import-database.ts"; +import { validateBackupBundle } from "../src/lib/backup/validate-backup.ts"; + +function printUsage() { + console.log(` +Formulation Database Backup & Restore Tool + +Usage: + node scripts/backup.mjs export [output-path.json] + node scripts/backup.mjs import [--replace | --merge] + node scripts/backup.mjs validate + +Commands: + export Extracts all 25 SQLite tables into a standardized JSON backup bundle. + import Restores or merges a backup bundle into the active SQLite database. + validate Checks a backup JSON file for schema integrity without modifying the database. + +Options: + --replace (Default for import) Atomically replaces existing database records. + --merge Upserts imported records without deleting unmentioned existing data. +`); +} + +async function main() { + const args = process.argv.slice(2); + const command = args[0]?.toLowerCase(); + + if (!command || command === "--help" || command === "-h" || command === "help") { + printUsage(); + process.exit(0); + } + + if (command === "export") { + if (!fs.existsSync(databasePath)) { + console.error(`Error: Database not found at ${databasePath}`); + process.exit(1); + } + const db = new DatabaseSync(databasePath); + try { + const bundle = exportDatabase(db); + const defaultName = `formulation-backup-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}.json`; + const outputPath = args[1] ? path.resolve(process.cwd(), args[1]) : path.join(process.cwd(), defaultName); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, JSON.stringify(bundle, null, 2), "utf8"); + console.log(`\n✅ Backup exported successfully to: ${outputPath}`); + console.log(` - Recipes: ${bundle.summary.recipes_count}`); + console.log(` - Ingredients: ${bundle.summary.ingredients_count}`); + console.log(` - Purchase Items: ${bundle.summary.purchase_items_count}`); + console.log(` - Collections: ${bundle.summary.collections_count}`); + console.log(` - Inventory Counts: ${bundle.summary.inventory_counts_count}`); + console.log(` - Total Entities: ${bundle.summary.total_records_count}`); + } finally { + db.close(); + } + return; + } + + if (command === "validate") { + const inputPath = args[1]; + if (!inputPath) { + console.error("Error: Please specify the path to a backup JSON file to validate."); + process.exit(1); + } + const resolvedPath = path.resolve(process.cwd(), inputPath); + if (!fs.existsSync(resolvedPath)) { + console.error(`Error: File not found: ${resolvedPath}`); + process.exit(1); + } + + const content = JSON.parse(fs.readFileSync(resolvedPath, "utf8")); + const result = validateBackupBundle(content); + + if (result.valid) { + console.log(`\n✅ Backup file '${inputPath}' is valid!`); + if (result.summary) { + console.log(` - Format Version: ${content.format_version}`); + console.log(` - Exported At: ${content.exported_at}`); + console.log(` - Recipes: ${result.summary.recipes_count}`); + console.log(` - Ingredients: ${result.summary.ingredients_count}`); + console.log(` - Purchase Items: ${result.summary.purchase_items_count}`); + console.log(` - Inventory Counts: ${result.summary.inventory_counts_count}`); + console.log(` - Total Entities: ${result.summary.total_records_count}`); + } + } else { + console.error(`\n❌ Backup validation failed:`); + for (const err of result.errors) console.error(` - ${err}`); + process.exit(1); + } + return; + } + + if (command === "import") { + const inputPath = args[1]; + if (!inputPath) { + console.error("Error: Please specify the path to a backup JSON file to import."); + process.exit(1); + } + const resolvedPath = path.resolve(process.cwd(), inputPath); + if (!fs.existsSync(resolvedPath)) { + console.error(`Error: File not found: ${resolvedPath}`); + process.exit(1); + } + + const mode = args.includes("--merge") ? "merge" : "replace"; + const content = JSON.parse(fs.readFileSync(resolvedPath, "utf8")); + + if (!fs.existsSync(databasePath)) { + console.error(`Error: Database not found at ${databasePath}`); + process.exit(1); + } + + const db = new DatabaseSync(databasePath); + try { + console.log(`Importing '${inputPath}' into database (mode: ${mode})...`); + const result = importDatabase(db, content, { mode, rebuildProjections: true }); + console.log(`\n✅ ${result.message}`); + } catch (err) { + console.error(`\n❌ Import failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } finally { + db.close(); + } + return; + } + + console.error(`Error: Unknown command '${command}'`); + printUsage(); + process.exit(1); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/scripts/db-backup.mjs b/scripts/db-backup.mjs new file mode 100644 index 0000000..3d78cd4 --- /dev/null +++ b/scripts/db-backup.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { backup, DatabaseSync } from "node:sqlite"; + +const source = path.resolve(process.cwd(), "var", "recipe-book.sqlite"); +const requestedTarget = process.argv[2]; + +if (!requestedTarget) { + throw new Error("Usage: npm run db:backup -- /path/to/recipe-book.sqlite"); +} +if (!fs.existsSync(source)) { + throw new Error(`Database not found: ${source}`); +} + +const target = path.resolve(requestedTarget); +if (target === source) { + throw new Error("Backup target must differ from the live database."); +} +if (fs.existsSync(target)) { + throw new Error(`Refusing to overwrite existing backup: ${target}`); +} + +fs.mkdirSync(path.dirname(target), { recursive: true }); +const database = new DatabaseSync(source, { readOnly: true }); +try { + await backup(database, target); +} finally { + database.close(); +} + +console.log(`Backed up ${source} to ${target}`); diff --git a/scripts/db-sync.mjs b/scripts/db-sync.mjs index f93fe78..8341e09 100644 --- a/scripts/db-sync.mjs +++ b/scripts/db-sync.mjs @@ -8,8 +8,14 @@ import { writeSiteProjection } from "./lib/site-projection.mjs"; const root = path.resolve(import.meta.dirname, ".."); const databasePath = path.join(root, "var", "recipe-book.sqlite"); -if (!process.argv.includes("--reset")) throw new Error("Database initialization replaces the local database. Re-run with --reset."); -if (fs.existsSync(databasePath)) fs.rmSync(databasePath); + +console.error( + "❌ ERROR: Direct SQLite database resets from culinary YAML files are disabled.\n" + + "The SQLite database (var/recipe-book.sqlite) and its JSON backup snapshots (scripts/backup.mjs) are the canonical source of truth.\n" + + "To backup the database: npm run db:backup -- [backup.json]\n" + + "To restore from backup: npm run db:restore -- \n" +); +process.exit(1); fs.mkdirSync(path.dirname(databasePath), { recursive: true }); const db = new DatabaseSync(databasePath); db.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;"); @@ -22,7 +28,12 @@ const records = (directory) => { const run = (sql, values) => db.prepare(sql).run(...values); const json = (value) => JSON.stringify(value ?? []); -db.exec(fs.readFileSync(path.join(root, "migrations", "001_initial.sql"), "utf8")); +const migrationsDir = path.join(root, "migrations"); +for (const file of fs.readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort()) { + try { + db.exec(fs.readFileSync(path.join(migrationsDir, file), "utf8")); + } catch {} +} db.exec("BEGIN IMMEDIATE"); try { @@ -61,7 +72,7 @@ try { JOIN prep_actions a ON a.id = p.action_id WHERE i.ingredient_id IS NOT NULL ON CONFLICT(ingredient_id, action_id) DO NOTHING`); for (const item of records("purchase_items")) { - run("INSERT INTO purchase_items VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [item.id, item.ingredient_id, item.name, item.brand ?? null, item.supplier_id ?? null, item.supplier_sku ?? null, item.status, item.package.quantity, item.package.unit_id, item.package.units_per_case ?? 1, item.package.usable_yield_factor ?? 1]); + run("INSERT INTO purchase_items(id, ingredient_id, name, brand, supplier_id, supplier_sku, status, package_quantity, package_unit_id, units_per_case, usable_yield_factor, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)", [item.id, item.ingredient_id, item.name, item.brand ?? null, item.supplier_id ?? null, item.supplier_sku ?? null, item.status, item.package.quantity, item.package.unit_id, item.package.units_per_case ?? 1, item.package.usable_yield_factor ?? 1]); for (const price of item.prices) run("INSERT INTO price_observations VALUES (?, ?, ?, ?, ?)", [item.id, price.effective_at, price.currency, price.amount, json(price.source)]); } for (const mapping of records("source_mappings")) run("INSERT INTO source_mappings VALUES (?, ?, ?, ?, ?, ?, ?)", [mapping.id, mapping.subject.type, mapping.subject.id, mapping.mapping_type, mapping.status, json(mapping.source), mapping.nutrition_per_100g ? json(mapping.nutrition_per_100g) : null]); diff --git a/scripts/dev-server.mjs b/scripts/dev-server.mjs new file mode 100644 index 0000000..5b4ca35 --- /dev/null +++ b/scripts/dev-server.mjs @@ -0,0 +1,15 @@ +import { dev } from "astro"; + +try { + const server = await dev({ + configFile: "astro.app.config.mjs", + server: { + port: 4322, + host: true + } + }); + console.log("Astro dev server is running on http://localhost:4322/app/"); +} catch (err) { + console.error("Failed to start Astro dev server:", err); + process.exit(1); +} diff --git a/scripts/ingest-docling-book.mjs b/scripts/ingest-docling-book.mjs new file mode 100644 index 0000000..23984fb --- /dev/null +++ b/scripts/ingest-docling-book.mjs @@ -0,0 +1,795 @@ +#!/usr/bin/env node +/** + * Advanced Docling Recipe Ingestor for "The Pastry Chef's Little Black Book, Vol. I" + * + * Supports: + * - 2-page facing spreads (Table on Left, Procedure on Right) + * - Multi-component formulation splitting (Dough Packet, Butter Packet, Filling, Crust) + * - Fractional spoon & unit fallbacks across all columns + * - Multi-stage procedure preservation with equipment inference + * - Shelf-life & chef's notes extraction + * + * Usage: + * node scripts/ingest-docling-book.mjs --dry-run --pages 26-27 + * node scripts/ingest-docling-book.mjs --save + */ +import fs from "node:fs"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { titleCase } from "../src/lib/format.ts"; + +const root = path.resolve(import.meta.dirname, ".."); +const databasePath = path.join(root, "var", "recipe-book.sqlite"); +const doclingPath = path.join(root, "file.json"); + +// --------------------------------------------------------------------------- +// Table of Contents Chapter Ranges +// --------------------------------------------------------------------------- +export const CHAPTER_PAGE_RANGES = [ + { name: "Doughs", startPage: 11, endPage: 38, category: "doughs" }, + { name: "Tart, Pie & Strudel Fillings", startPage: 39, endPage: 64, category: "tart_pie_fillings" }, + { name: "Cakes & Souffles", startPage: 65, endPage: 122, category: "cakes_souffles" }, + { name: "Sheet Cakes", startPage: 123, endPage: 166, category: "sheet_cakes" }, + { name: "Buttercreams, Frostings & Glazes", startPage: 167, endPage: 190, category: "frostings_glazes" }, + { name: "Custards, Creams & Fillings", startPage: 191, endPage: 234, category: "custards_creams" }, + { name: "Mousses & Bavarian Creams", startPage: 235, endPage: 296, category: "mousses_bavarians" }, + { name: "Cookies & Tuiles", startPage: 297, endPage: 350, category: "cookies_tuiles" }, + { name: "Sauces & Poaching Liquids", startPage: 351, endPage: 380, category: "sauces_liquids" }, + { name: "Chocolates & Confections", startPage: 381, endPage: 424, category: "confections" }, + { name: "Frozen Desserts", startPage: 425, endPage: 472, category: "frozen_desserts" }, + { name: "Breakfast", startPage: 473, endPage: 516, category: "breakfast" }, + { name: "Breads", startPage: 517, endPage: 537, category: "breads" }, +]; + +export function getChapterForPage(pageNo) { + for (const ch of CHAPTER_PAGE_RANGES) { + if (pageNo >= ch.startPage && pageNo <= ch.endPage) return ch; + } + return { name: "General Pastry", category: "pastry" }; +} + +// --------------------------------------------------------------------------- +// Extended Ingredient Normalization Map +// --------------------------------------------------------------------------- +const KNOWN_INGREDIENT_MAP = { + "butter": "butter", + "unsalted butter": "butter", + "salted butter": "butter_salted", + "clarified butter": "clarified_butter", + "brown butter": "brown_butter", + "beurre noisette": "brown_butter", + "granulated sugar": "sugar", + "sugar": "sugar", + "powdered sugar": "confectioners_sugar", + "confectioners sugar": "confectioners_sugar", + "icing sugar": "confectioners_sugar", + "brown sugar": "brown_sugar", + "light brown sugar": "brown_sugar", + "dark brown sugar": "brown_sugar", + "all-purpose flour": "flour_all_purpose", + "all purpose flour": "flour_all_purpose", + "ap flour": "flour_all_purpose", + "pastry flour": "flour_pastry", + "cake flour": "flour_cake", + "bread flour": "flour_bread", + "fine whole wheat flour": "flour_whole_wheat", + "whole wheat flour": "flour_whole_wheat", + "almond flour": "almond_flour", + "hazelnut flour": "hazelnut_flour", + "whole eggs": "egg_whole", + "eggs": "egg_whole", + "whole egg": "egg_whole", + "egg yolks": "egg_yolk", + "egg yolk": "egg_yolk", + "egg whites": "egg_whites", + "egg white": "egg_whites", + "whole milk": "milk_whole", + "milk": "milk_whole", + "milk powder": "milk_powder", + "nonfat dry milk": "milk_powder", + "heavy cream": "heavy_cream", + "cream": "heavy_cream", + "heavy cream 36%": "heavy_cream", + "heavy cream 40%": "heavy_cream", + "sour cream": "sour_cream", + "creme fraiche": "sour_cream", + "mascarpone": "mascarpone", + "cream cheese": "cream_cheese", + "buttermilk": "buttermilk", + "salt": "salt", + "fine salt": "salt", + "kosher salt": "salt", + "sea salt": "salt", + "baking powder": "baking_powder", + "baking soda": "baking_soda", + "cream of tartar": "cream_of_tartar", + "vanilla extract": "vanilla_extract", + "vanilla bean": "vanilla_bean", + "vanilla beans": "vanilla_bean", + "vanilla paste": "vanilla_extract", + "almond extract": "almond_extract", + "cinnamon (ground)": "cinnamon", + "cinnamon": "cinnamon", + "ground cinnamon": "cinnamon", + "nutmeg": "nutmeg", + "ground nutmeg": "nutmeg", + "black pepper": "black_pepper", + "white vinegar": "white_vinegar", + "vinegar": "white_vinegar", + "water": "water", + "water (cold)": "water", + "water (warm)": "water", + "water (hot)": "water", + "cocoa powder": "cocoa_powder", + "dutch-process cocoa powder": "cocoa_powder", + "cocoa butter": "cocoa_butter", + "dark chocolate": "chocolate_dark", + "chocolate": "chocolate_dark", + "dark chocolate 64%": "chocolate_dark", + "dark chocolate 70%": "chocolate_dark", + "semisweet chocolate": "chocolate_dark", + "bittersweet chocolate": "chocolate_dark", + "milk chocolate": "chocolate_milk", + "white chocolate": "chocolate_white", + "cornstarch": "cornstarch", + "gelatin (sheet)": "gelatin_sheet", + "gelatin (powder)": "gelatin_powder", + "gelatin sheets": "gelatin_sheet", + "sheet gelatin": "gelatin_sheet", + "powdered gelatin": "gelatin_powder", + "honey": "honey", + "glucose syrup": "glucose_syrup", + "glucose": "glucose_syrup", + "powdered glucose": "powdered_glucose", + "corn syrup": "corn_syrup", + "trimoline": "invert_sugar", + "invert sugar": "invert_sugar", + "canola oil": "canola_oil", + "vegetable oil": "canola_oil", + "olive oil": "olive_oil", + "lemon juice": "lemon_juice", + "lemon zest": "lemon_zest", + "lemon or lime zest": "lemon_zest", + "lime zest": "lime_zest", + "lemons": "lemon", + "orange juice": "orange_juice", + "orange zest": "orange_zest", + "lime juice": "lime_juice", + "loose tea": "tea_loose", + "chopped nuts": "walnut", + "passion fruit puree": "passion_fruit_puree", + "raspberry puree": "raspberry_puree", + "strawberry puree": "strawberry_puree", + "mango puree": "mango_puree", + "almond paste": "almond_paste", + "marzipan": "marzipan", + "praline paste": "praline_paste", + "hazelnut paste": "hazelnut_paste", + "pistachio paste": "pistachio_paste", + "walnuts": "walnut", + "walnut": "walnut", + "pecans": "pecan", + "almonds": "almond", + "hazelnuts": "hazelnut", + "pistachios": "pistachio", + "fresh yeast": "yeast_fresh", + "yeast (fresh)": "yeast_fresh", + "instant yeast": "yeast_instant", + "yeast (instant)": "yeast_instant", + "active dry yeast": "yeast_active_dry", + "ice cream stabilizer": "ice_cream_stabilizer", + "sorbet stabilizer": "sorbet_stabilizer", + "pectin nh": "pectin_nh", + "pectin yellow": "pectin_yellow", + "pectin": "pectin", +}; + +function slugify(text) { + return text + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") || "recipe"; +} + +function parseMetricAmount(str) { + if (!str) return null; + const s = str.trim().toLowerCase(); + + const kgMatch = s.match(/^([\d.,]+)\s*kg$/i); + if (kgMatch) { + return { quantity: Math.round(parseFloat(kgMatch[1].replace(/,/g, "")) * 1000 * 100) / 100, unit_id: "gram" }; + } + + const gMatch = s.match(/^([\d.,]+)\s*g$/i); + if (gMatch) { + return { quantity: Math.round(parseFloat(gMatch[1].replace(/,/g, "")) * 100) / 100, unit_id: "gram" }; + } + + const mlMatch = s.match(/^([\d.,]+)\s*ml$/i); + if (mlMatch) { + return { quantity: Math.round(parseFloat(mlMatch[1].replace(/,/g, "")) * 100) / 100, unit_id: "milliliter" }; + } + + const lMatch = s.match(/^([\d.,]+)\s*l$/i); + if (lMatch) { + return { quantity: Math.round(parseFloat(lMatch[1].replace(/,/g, "")) * 1000 * 100) / 100, unit_id: "milliliter" }; + } + + const numMatch = s.match(/^([\d.,]+)$/); + if (numMatch) { + return { quantity: Math.round(parseFloat(numMatch[1].replace(/,/g, "")) * 100) / 100, unit_id: "gram" }; + } + + return null; +} + +export function cleanFractionText(str) { + if (!str) return ""; + return str + .replace(/[\r\n]+/g, " ") + .replace(/(\d+)\s*\/\s*\1\s*\/\s*(\d+)/g, (m, a, b) => `${a}/${b}`) + .replace(/(\d+)\s*\/\s*(\d+)/g, (m, a, b) => `${a}/${b}`) + .replace(/\s+/g, " ") + .trim(); +} + +function parseUsFallback(text, name) { + if (!text) { + if (/zest/i.test(name)) return { quantity: 1, unit_id: "each", notes: "Zest of 1" }; + if (/vanilla bean/i.test(name)) return { quantity: 1, unit_id: "each", notes: "1 bean" }; + return { quantity: 1, unit_id: "gram", notes: "To taste / as needed" }; + } + const s = cleanFractionText(text).toLowerCase(); + if (s.includes("1/8") || s.includes("⅛")) return { quantity: 0.6, unit_id: "gram", notes: "⅛ tsp" }; + if (s.includes("1/4") || s.includes("¼")) return { quantity: 1.25, unit_id: "gram", notes: "¼ tsp" }; + if (s.includes("1/2") || s.includes("½")) return { quantity: 2.5, unit_id: "gram", notes: "½ tsp" }; + if (s.includes("3/4") || s.includes("¾")) return { quantity: 3.75, unit_id: "gram", notes: "¾ tsp" }; + if (s.includes("1 1/4") || s.includes("1¼")) return { quantity: 6.25, unit_id: "gram", notes: "1¼ tsp" }; + if (s.includes("1 1/2") || s.includes("1½")) return { quantity: 7.5, unit_id: "gram", notes: "1½ tsp" }; + if (s.includes("2 t")) return { quantity: 10, unit_id: "gram", notes: "2 tsp" }; + if (s.includes("1 t") && !s.includes("tbsp")) return { quantity: 5, unit_id: "gram", notes: "1 tsp" }; + if (s.includes("tbsp") || s.includes("1 t") || s.includes("2 t")) return { quantity: 15, unit_id: "gram", notes: "1 Tbsp" }; + + const eachMatch = s.match(/^([\d.]+)\s*(?:each|pc|ea)?$/); + if (eachMatch && parseFloat(eachMatch[1]) > 0) return { quantity: parseFloat(eachMatch[1]), unit_id: "each", notes: null }; + + const ozMatch = s.match(/^([\d.]+)\s*oz$/); + if (ozMatch && parseFloat(ozMatch[1]) > 0) { + return { quantity: Math.round(parseFloat(ozMatch[1]) * 28.3495 * 100) / 100, unit_id: "gram", notes: cleanFractionText(text) }; + } + + return { quantity: 1, unit_id: "gram", notes: cleanFractionText(text) }; +} + +function cleanIngredientName(raw) { + let cleaned = cleanFractionText(raw).replace(/^[\s•\-\*]+/, ""); + let notes = null; + + const parenMatch = cleaned.match(/^([^(]+)\s*\(([^)]+)\)$/); + if (parenMatch) { + const baseName = parenMatch[1].trim(); + const parenContent = parenMatch[2].trim(); + + if (/streusel/i.test(baseName)) { + if (/zest/i.test(parenContent)) { + const fruit = baseName.replace(/streusel/i, "").trim(); + cleaned = `${fruit} Zest`; + notes = `Zest of whole fruit (for ${baseName})`; + } else if (/loose tea/i.test(parenContent)) { + cleaned = "Loose Tea"; + notes = `For ${baseName}`; + } else if (/chopped/i.test(parenContent)) { + const nutType = baseName.replace(/streusel/i, "").trim(); + cleaned = /nut/i.test(nutType) ? "Chopped Nuts" : (nutType || "Nuts"); + notes = `${parenContent} (for ${baseName})`; + } else { + cleaned = baseName; + notes = parenContent; + } + } else { + cleaned = baseName; + notes = parenContent; + } + } + + return { name: cleaned, notes }; +} + +function inferIngredientId(name) { + const norm = name.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); + if (KNOWN_INGREDIENT_MAP[norm]) return KNOWN_INGREDIENT_MAP[norm]; + return slugify(norm); +} + +function isFlourBasis(ingredientId, name) { + const n = (ingredientId + " " + name).toLowerCase(); + return ( + n.includes("flour") && + !n.includes("almond") && + !n.includes("hazelnut") && + !n.includes("cornstarch") + ); +} + +function inferEquipment(instruction) { + const text = instruction.toLowerCase(); + const eq = new Set(); + if (text.includes("mixer") || text.includes("paddle") || text.includes("whip") || text.includes("dough hook")) eq.add("stand_mixer"); + if (text.includes("whisk")) eq.add("whisk"); + if (text.includes("bowl")) eq.add("mixing_bowl"); + if (text.includes("scale") || text.includes("weigh")) eq.add("kitchen_scale"); + if (text.includes("bake") || text.includes("oven") || text.includes("375°f") || text.includes("350°f") || text.includes("325°f")) eq.add("oven"); + if (text.includes("sheet pan") || text.includes("parchment") || text.includes("silpat")) eq.add("sheet_pan"); + if (text.includes("saucepan") || text.includes("simmer") || text.includes("boil") || text.includes("pot")) eq.add("saucepan"); + if (text.includes("food processor") || text.includes("process") || text.includes("robot coupe")) eq.add("food_processor"); + if (text.includes("blender") || text.includes("blend") || text.includes("immersion blender")) eq.add("blender"); + if (text.includes("thermometer") || text.includes("degrees") || text.includes("°c") || text.includes("°f")) eq.add("thermometer"); + return [...eq]; +} + +function parseShelfLife(notesList) { + for (const note of notesList) { + const text = note.toLowerCase(); + const dayMatch = text.match(/refrigerat\w*\s+for\s+(\d+)\s+days?/i); + if (dayMatch) { + return { + quantity: parseInt(dayMatch[1], 10), + unit: "day", + storage_condition: "refrigerated", + }; + } + const monthMatch = text.match(/frozen\s+(?:up\s+to\s+)?(\d+)\s+months?/i); + if (monthMatch) { + return { + quantity: parseInt(monthMatch[1], 10) * 30, + unit: "day", + storage_condition: "frozen", + }; + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Page & Recipe Extractor +// --------------------------------------------------------------------------- +export function parseDoclingBook(doclingJson, targetPages = null) { + const pagesMap = new Map(); + + for (const textNode of doclingJson.texts || []) { + const pageNo = textNode.prov?.[0]?.page_no; + if (!pageNo) continue; + if (targetPages && !targetPages.includes(pageNo)) continue; + + if (!pagesMap.has(pageNo)) pagesMap.set(pageNo, { pageNo, texts: [], tables: [] }); + pagesMap.get(pageNo).texts.push(textNode); + } + + for (const tableNode of doclingJson.tables || []) { + const pageNo = tableNode.prov?.[0]?.page_no; + if (!pageNo) continue; + if (targetPages && !targetPages.includes(pageNo)) continue; + + if (!pagesMap.has(pageNo)) pagesMap.set(pageNo, { pageNo, texts: [], tables: [] }); + pagesMap.get(pageNo).tables.push(tableNode); + } + + const recipes = []; + const usedSlugs = new Map(); + const sortedPages = [...pagesMap.keys()].sort((a, b) => a - b); + + for (const pageNo of sortedPages) { + const page = pagesMap.get(pageNo); + const chapterInfo = getChapterForPage(pageNo); + + if (page.tables.length === 0) continue; + + for (const table of page.tables) { + const cells = table.data?.table_cells || []; + if (cells.length < 4) continue; + + const grid = new Map(); + let maxRow = 0; + let maxCol = 0; + for (const cell of cells) { + const r = cell.start_row_offset_idx; + const c = cell.start_col_offset_idx; + if (!grid.has(r)) grid.set(r, new Map()); + grid.get(r).set(c, cell.text?.trim() || ""); + if (r > maxRow) maxRow = r; + if (c > maxCol) maxCol = c; + } + + const headerRow = grid.get(0); + const isIngredientTable = headerRow && [...headerRow.values()].some((v) => /ingredients/i.test(v)); + if (!isIngredientTable) continue; + + // Find Recipe Title on this page + const titleNode = page.texts.find( + (t) => t.label === "section_header" && !/procedure|chef's notes|notes|table of contents|scaling|baking/i.test(t.text) + ); + const title = titleNode ? titleNode.text.trim() : `Recipe Page ${pageNo}`; + let slugId = slugify(title); + if (usedSlugs.has(slugId)) { + const count = usedSlugs.get(slugId) + 1; + usedSlugs.set(slugId, count); + slugId = `${slugId}_p${pageNo}`; + } else { + usedSlugs.set(slugId, 1); + } + + // Parse ingredients into components + const components = []; + let currentComponent = { id: "main", name: "Main", items: [] }; + components.push(currentComponent); + + let totalYieldGrams = null; + + for (let r = 1; r <= maxRow; r++) { + const row = grid.get(r); + if (!row) continue; + + const ingText = cleanFractionText(row.get(0) || ""); + const metricText = cleanFractionText(row.get(1) || ""); + const usText = cleanFractionText(row.get(2) || ""); + + if (/total weight/i.test(ingText)) { + const parsedTotal = parseMetricAmount(metricText) || parseMetricAmount(usText); + if (parsedTotal) totalYieldGrams = parsedTotal.quantity; + continue; + } + + if (!ingText) continue; + + // Detect sub-component headers inside tables like "Dough Packet (Détrempe):" or "Filling:" + if (ingText.endsWith(":") && !metricText && !usText) { + const compName = ingText.replace(/:$/, "").trim(); + const compSlug = slugify(compName); + if (currentComponent.items.length === 0 && components.length === 1) { + currentComponent.id = compSlug; + currentComponent.name = compName; + } else { + currentComponent = { id: compSlug, name: compName, items: [] }; + components.push(currentComponent); + } + continue; + } + + const { name, notes: parenNotes } = cleanIngredientName(ingText); + const ingredientId = inferIngredientId(name); + + let parsedMetric = parseMetricAmount(metricText); + let notes = parenNotes; + let quantity = parsedMetric ? parsedMetric.quantity : 0; + let unitId = parsedMetric ? parsedMetric.unit_id : "gram"; + + if (quantity <= 0) { + const fallback = parseUsFallback(metricText || usText, name); + quantity = fallback.quantity; + unitId = fallback.unit_id; + if (fallback.notes) { + notes = notes ? `${notes} (${fallback.notes})` : fallback.notes; + } + } else if (usText && !notes) { + if (/[½¼¾t]/i.test(usText)) { + notes = usText; + } + } + + currentComponent.items.push({ + raw_name: ingText, + clean_name: name, + ingredient_id: ingredientId, + quantity, + unit_id: unitId, + us_measure: usText, + notes: notes || null, + basis_member: isFlourBasis(ingredientId, name), + }); + } + + // Filter out empty components + const validComponents = components.filter((c) => c.items.length > 0); + if (validComponents.length === 0) continue; + + // Calculate Baker's Percentages across all components + const allItems = validComponents.flatMap((c) => c.items); + const flourBasisWeight = allItems + .filter((i) => i.basis_member) + .reduce((sum, i) => sum + i.quantity, 0); + + let itemCounter = 1; + const formattedComponents = validComponents.map((comp) => ({ + id: comp.id, + name: comp.name, + notes: [], + items: comp.items.map((item) => { + let pct = null; + if (flourBasisWeight > 0 && item.quantity > 0) { + pct = Number(((item.quantity / flourBasisWeight) * 100).toFixed(2)); + } + return { + id: `line_${String(itemCounter++).padStart(2, "0")}_${item.ingredient_id}`, + ingredient_id: item.ingredient_id, + name: titleCase(item.clean_name), + quantity: item.quantity, + unit_id: item.unit_id, + percentage: pct, + basis_member: item.basis_member, + notes: item.notes, + }; + }), + })); + + // Find procedure steps on current page or facing spread page (pageNo + 1) + const textSources = [...page.texts]; + const nextPage = pagesMap.get(pageNo + 1); + if (nextPage && nextPage.tables.length === 0) { + textSources.push(...nextPage.texts); + } + +const WORD_TO_NUMBER = { + "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10, "dozen": 12, "half": 0.5 +}; + +function parseYieldServings(yieldText) { + if (!yieldText) return null; + const s = yieldText.replace(/^yields?:\s*/i, "").trim().toLowerCase(); + const digitMatch = s.match(/^(\d+)/); + if (digitMatch) return parseInt(digitMatch[1], 10); + const wordMatch = s.match(/^(one|two|three|four|five|six|seven|eight|nine|ten|dozen|half)/); + if (wordMatch && WORD_TO_NUMBER[wordMatch[1]]) return WORD_TO_NUMBER[wordMatch[1]]; + return null; +} + +function isProcedureHeader(text, label, recipeTitle) { + if (label !== "section_header") return false; + const s = text.trim(); + if (recipeTitle && s.toLowerCase() === recipeTitle.toLowerCase()) return false; + if (/^chef's notes?:?$/i.test(s)) return false; + if (/^table of contents|foreword|introduction|acknowledgements|how to use/i.test(s)) return false; + return /procedure|assembly|variations?|baking|shaping|finishing|glaz|infusion/i.test(s) || s.endsWith(":"); +} + +function cleanProcedureHeader(raw) { + const t = raw.trim().replace(/^['"]+|['":]+$/g, "").trim(); + if (/^procedure$/i.test(t)) return ""; + const cleaned = t.replace(/\s+procedure$/i, "").trim(); + return cleaned ? `${cleaned}:` : ""; +} + + const steps = []; + let inProcedure = false; + let inChefNotes = false; + let yieldDescription = null; + const chefNotesList = []; + const narrativeTexts = []; + + for (const textNode of textSources) { + const text = textNode.text.trim(); + + if (/^yields?:\s*/i.test(text)) { + yieldDescription = text; + continue; + } + + if (/^chef's notes?:?$/i.test(text)) { + inProcedure = false; + inChefNotes = true; + continue; + } + + if (isProcedureHeader(text, textNode.label, title)) { + inProcedure = true; + inChefNotes = false; + const heading = cleanProcedureHeader(text); + if (heading) { + steps.push({ + id: `step_${steps.length + 1}`, + order: steps.length + 1, + instruction: heading, + equipment_ids: [], + }); + } + continue; + } + + if (inProcedure) { + if ((textNode.label === "list_item" || textNode.label === "text") && !/^\d+$/.test(text) && !/^yields?:/i.test(text)) { + steps.push({ + id: `step_${steps.length + 1}`, + order: steps.length + 1, + instruction: text, + equipment_ids: inferEquipment(text), + }); + } + } else if (inChefNotes) { + if (textNode.label === "list_item") { + chefNotesList.push(text); + } else if (textNode.label === "text" && !/^\d+$/.test(text)) { + narrativeTexts.push(text); + } + } + } + + const sumWeight = allItems.reduce((s, i) => s + (i.unit_id === "gram" ? i.quantity : 0), 0); + const yieldQuantity = totalYieldGrams || (sumWeight > 0 ? sumWeight : 1000); + const yieldServings = parseYieldServings(yieldDescription); + + const allNotes = [...chefNotesList]; + if (yieldDescription) { + allNotes.unshift(yieldDescription); + } + + const summary = narrativeTexts.length > 0 + ? (yieldDescription ? `${yieldDescription}. ${narrativeTexts.join(" ")}` : narrativeTexts.join(" ")) + : yieldDescription || null; + + recipes.push({ + id: slugId, + title, + page_no: pageNo, + chapter: chapterInfo.name, + summary, + categories: [chapterInfo.category], + tags: ["pastry_chefs_little_black_book", chapterInfo.category, "classic"], + yield_quantity: Math.round(yieldQuantity * 100) / 100, + yield_unit_id: "gram", + yield_servings: yieldServings, + yield_basis: "theoretical", + yield: { + quantity: Math.round(yieldQuantity * 100) / 100, + unit_id: "gram", + servings: yieldServings, + basis: "theoretical", + }, + components: formattedComponents, + steps: steps.length > 0 ? steps : [{ id: "step_1", order: 1, instruction: "Prepare formulation according to standard pastry method.", equipment_ids: [] }], + notes: allNotes, + shelf_life: parseShelfLife(chefNotesList), + }); + } + } + + return recipes; +} + +// --------------------------------------------------------------------------- +// Batch Ingestion Runner +// --------------------------------------------------------------------------- +async function main() { + const args = process.argv.slice(2); + const isSave = args.includes("--save"); + const isDryRun = args.includes("--dry-run"); + + let targetPages = null; + const pageIdx = args.indexOf("--page"); + if (pageIdx !== -1 && args[pageIdx + 1]) { + targetPages = [parseInt(args[pageIdx + 1], 10)]; + } + const pagesIdx = args.indexOf("--pages"); + if (pagesIdx !== -1 && args[pagesIdx + 1]) { + const [start, end] = args[pagesIdx + 1].split("-").map((n) => parseInt(n, 10)); + targetPages = []; + for (let p = start; p <= end; p++) targetPages.push(p); + } + + console.log("Loading Docling JSON from file.json..."); + const rawData = fs.readFileSync(doclingPath, "utf8"); + const doc = JSON.parse(rawData); + console.log(`Document loaded: ${doc.texts?.length || 0} texts, ${doc.tables?.length || 0} tables.`); + + const recipes = parseDoclingBook(doc, targetPages); + console.log(`\nFound ${recipes.length} formulation(s).`); + + // Detailed inspect for targeted page runs + if (targetPages && targetPages.length <= 5) { + for (const recipe of recipes) { + console.log(`\n================================================================`); + console.log(`📖 Page ${recipe.page_no}: ${recipe.title} (${recipe.chapter})`); + console.log(` ID: ${recipe.id}`); + console.log(` Categories: ${recipe.categories.join(", ")}`); + console.log(` Yield: ${recipe.yield.quantity} ${recipe.yield.unit_id} (${recipe.yield.basis})`); + if (recipe.summary) console.log(` Summary: ${recipe.summary}`); + if (recipe.shelf_life) console.log(` Shelf Life: ${recipe.shelf_life.quantity} ${recipe.shelf_life.unit} (${recipe.shelf_life.storage_condition})`); + + for (const comp of recipe.components) { + console.log(`\n Component: [${comp.name}] (${comp.items.length} lines):`); + for (const item of comp.items) { + const pct = item.percentage !== null ? `(${item.percentage}%)` : ""; + const basis = item.basis_member ? "[BASIS]" : ""; + const note = item.notes ? `[${item.notes}]` : ""; + console.log(` - ${item.name.padEnd(26)} ${String(item.quantity).padStart(5)} ${item.unit_id.padEnd(5)} ${pct.padStart(9)} ${basis} ${note}`); + } + } + + console.log(`\n Procedure:`); + let stepNum = 1; + for (const step of recipe.steps) { + const isHeading = step.instruction.endsWith(":"); + if (isHeading) { + console.log(`\n 📂 ${step.instruction}`); + stepNum = 1; + } else { + const eq = step.equipment_ids.length > 0 ? ` [Equip: ${step.equipment_ids.join(", ")}]` : ""; + console.log(` ${stepNum++}. ${step.instruction}${eq}`); + } + } + + if (recipe.notes.length > 0) { + console.log(`\n Chef's Notes:`); + for (const n of recipe.notes) console.log(` * ${n}`); + } + } + } + + if (isSave) { + console.log(`\n💾 Ingesting ${recipes.length} recipes into Formulation database...`); + const { createMcpTools } = await import("../src/mcp/tools.ts"); + const { openDatabase, refreshSiteProjection } = await import("../src/lib/database.ts"); + const db = openDatabase({ readOnly: false }); + const tools = createMcpTools(() => db); + + try { + let newIngredientsCount = 0; + const recipeIds = []; + + for (let i = 0; i < recipes.length; i++) { + const recipe = recipes[i]; + + for (const comp of recipe.components) { + for (const item of comp.items) { + const exists = db.prepare("SELECT 1 FROM ingredients WHERE id = ?").get(item.ingredient_id); + if (!exists) { + tools.saveIngredient({ + id: item.ingredient_id, + name: item.name, + categories: ["pantry", "baking", "imported_stub"], + }); + newIngredientsCount++; + } + } + } + + const res = tools.saveRecipe(recipe); + recipeIds.push(res.recipe_id); + + if ((i + 1) % 50 === 0 || i + 1 === recipes.length) { + console.log(` [${i + 1}/${recipes.length}] Processed: ${recipe.title} (${res.recipe_id})`); + } + } + + // Update or create Master Collection + if (!targetPages) { + const collectionId = "the_pastry_chefs_little_black_book_vol_1"; + tools.saveRecipeBook({ + id: collectionId, + name: "The Pastry Chef's Little Black Book (Vol. I)", + description: "Classic culinary pastry reference by Michael Zebrowski & Michael Mignano (477 formulations across 13 chapters).", + recipe_ids: recipeIds, + }); + } + + refreshSiteProjection(db); + + console.log(`\n================================================================`); + console.log(`🎉 INGESTION COMPLETE!`); + console.log(`================================================================`); + console.log(` • Recipes Ingested: ${recipes.length}`); + console.log(` • New Ingredients Stubbed: ${newIngredientsCount}`); + console.log(` • Site Projection: Refreshed successfully`); + } catch (error) { + console.error("Ingestion failed:", error); + process.exit(1); + } finally { + db.close(); + } + } +} + +main().catch((err) => { + console.error("Ingestion error:", err); + process.exit(1); +}); diff --git a/scripts/lib/site-projection.mjs b/scripts/lib/site-projection.mjs index d87f77c..a9264da 100644 --- a/scripts/lib/site-projection.mjs +++ b/scripts/lib/site-projection.mjs @@ -3,6 +3,8 @@ import path from "node:path"; const json = (value, fallback) => { try { return JSON.parse(value); } catch { return fallback; } }; +export const titleCase = (input) => input.split(/\s+/).map((word) => { const index = word.search(/\p{L}/u); return index === -1 ? word : word.slice(0, index) + word[index].toLocaleUpperCase() + word.slice(index + 1); }).join(" "); + export function createSiteProjection(database) { const units = database.prepare("SELECT * FROM units ORDER BY id").all().map((row) => ({ schema_version:2,id:row.id,name:row.name,symbol:row.symbol,dimension:row.dimension,system:row.system,...(row.base_unit_id?{base_conversion:{base_unit_id:row.base_unit_id,factor:row.factor,...(row.offset!=null?{offset:row.offset}:{})}}:{}) })); const aliasQuery=database.prepare("SELECT name,kind FROM ingredient_aliases WHERE ingredient_id=? ORDER BY name"); @@ -12,7 +14,7 @@ export function createSiteProjection(database) { const mappingIdsQuery=database.prepare("SELECT id,mapping_type FROM source_mappings WHERE subject_type='ingredient' AND subject_id=? AND status='reviewed' ORDER BY id"); const ingredients = database.prepare("SELECT * FROM ingredients ORDER BY id").all().map((row) => { const mappings=mappingIdsQuery.all(row.id), source=json(row.source_json,"{}"); - return { schema_version:row.schema_version,id:row.id,name:row.name,...(row.description?{description:row.description}:{}),status:row.status,categories:json(row.categories_json,[]),tags:json(row.tags_json,[]), + return { schema_version:row.schema_version,id:row.id,name:titleCase(row.name),...(row.description?{description:row.description}:{}),status:row.status,categories:json(row.categories_json,[]),tags:json(row.tags_json,[]), aliases:aliasQuery.all(row.id), density_measurements:densityQuery.all(row.id).map((value)=>({id:value.id,mass:{quantity:value.mass_quantity,unit_id:value.mass_unit_id},volume:{quantity:value.volume_quantity,unit_id:value.volume_unit_id},...(value.temperature_c!=null?{temperature_c:value.temperature_c}:{}),...(value.state?{state:value.state}:{}),source:json(value.source_json,{})})), measure_conversions:conversionQuery.all(row.id).map((value)=>({id:value.id,from:{quantity:value.from_quantity,unit_id:value.from_unit_id},to:{quantity:value.to_quantity,unit_id:value.to_unit_id},...(value.state?{state:value.state}:{}),source:json(value.source_json,{})})), @@ -46,8 +48,6 @@ export function createSiteProjection(database) { export function writeSiteProjection(database, target=path.resolve(process.cwd(),"generated","site-projection.json")) { fs.mkdirSync(path.dirname(target),{recursive:true}); - const temporary=`${target}.tmp`; - fs.writeFileSync(temporary,`${JSON.stringify(createSiteProjection(database))}\n`); - fs.renameSync(temporary,target); + fs.writeFileSync(target,`${JSON.stringify(createSiteProjection(database))}\n`); return target; } diff --git a/scripts/lint-recipe-instructions.mjs b/scripts/lint-recipe-instructions.mjs new file mode 100644 index 0000000..d66adf7 --- /dev/null +++ b/scripts/lint-recipe-instructions.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import YAML from "yaml"; + +const root = path.resolve(import.meta.dirname, ".."); +const recipesDir = path.join(root, "culinary", "recipes"); + +const autofix = process.argv.includes("--fix") || process.argv.includes("--autofix"); + +const gerundMap = { + "mixing": "Mix", + "combining": "Combine", + "adding": "Add", + "whisking": "Whisk", + "stirring": "Stir", + "baking": "Bake", + "cooking": "Cook", + "heating": "Heat", + "pouring": "Pour", + "placing": "Place", + "cutting": "Cut", + "dicing": "Dice", + "chopping": "Chop", + "kneading": "Knead", + "rolling": "Roll", + "folding": "Fold", + "preheating": "Preheat", + "seasoning": "Season", + "simmering": "Simmer", + "boiling": "Boil", + "cooling": "Cool", + "refrigerating": "Refrigerate", + "freezing": "Freeze", + "storing": "Store", + "serving": "Serve", + "garnishing": "Garnish", +}; + +function standardizeInstruction(text) { + let clean = text.trim(); + if (!clean) return clean; + + // Check if it's an inline note: (Note: ...) or (...) + if (clean.startsWith("(") && clean.endsWith(")")) { + return clean; + } + + // Check if it's a section heading: ends with colon + if (clean.endsWith(":")) { + // Capitalize first character + clean = clean.charAt(0).toUpperCase() + clean.slice(1); + return clean; + } + + // Check for common gerund starts + const words = clean.split(/\s+/); + const firstWordLower = words[0].toLowerCase(); + if (gerundMap[firstWordLower]) { + words[0] = gerundMap[firstWordLower]; + clean = words.join(" "); + } + + // Capitalize first letter + clean = clean.charAt(0).toUpperCase() + clean.slice(1); + + // Replace trailing comma, semicolon, or dash with period + clean = clean.replace(/[,;\-\s]+$/, ""); + + // Ensure terminal punctuation if not ending with : or ) + if (!clean.endsWith(".") && !clean.endsWith("!") && !clean.endsWith("?") && !clean.endsWith(":") && !clean.endsWith(")")) { + clean += "."; + } + + return clean; +} + +function lintRecipeFile(filePath) { + const content = fs.readFileSync(filePath, "utf8"); + const data = YAML.parse(content); + if (!data || !Array.isArray(data.steps)) return { warnings: [], errors: [], changed: false }; + + const issues = []; + let changed = false; + + const newSteps = data.steps.map((step, index) => { + const original = step.instruction ?? ""; + const standardized = standardizeInstruction(original); + + if (original !== standardized) { + issues.push({ + stepOrder: step.order ?? index + 1, + original, + standardized, + }); + if (autofix) { + changed = true; + return { ...step, instruction: standardized }; + } + } + return step; + }); + + if (changed && autofix) { + data.steps = newSteps; + fs.writeFileSync(filePath, YAML.stringify(data, { indent: 2, lineWidth: 0 }), "utf8"); + } + + return { issues, changed }; +} + +function main() { + const files = fs.readdirSync(recipesDir).filter((f) => f.endsWith(".yaml")).sort(); + let totalIssues = 0; + let filesModified = 0; + + console.log(`Auditing ${files.length} recipe instruction files against Microsoft procedural guidelines...`); + + for (const file of files) { + const filePath = path.join(recipesDir, file); + const { issues, changed } = lintRecipeFile(filePath); + if (issues.length > 0) { + totalIssues += issues.length; + if (changed) filesModified++; + console.log(`\n📄 ${file} (${issues.length} issue${issues.length > 1 ? "s" : ""}):`); + for (const issue of issues) { + console.log(` Step ${issue.stepOrder}:`); + console.log(` - Current: "${issue.original}"`); + console.log(` + Standard: "${issue.standardized}"`); + } + } + } + + console.log("\n--------------------------------------------------"); + if (autofix) { + console.log(`✅ Standardized ${totalIssues} instructions across ${filesModified} recipe files.`); + } else { + console.log(`Found ${totalIssues} non-standard instructions.`); + if (totalIssues > 0) { + console.log("Run with --fix to apply automated standardization."); + } + } +} + +main(); diff --git a/scripts/mcp-server.mjs b/scripts/mcp-server.mjs new file mode 100644 index 0000000..52a5fee --- /dev/null +++ b/scripts/mcp-server.mjs @@ -0,0 +1,335 @@ +#!/usr/bin/env node +/** + * Formulation MCP Server — standalone stdio entry point. + * + * This script opens the SQLite database directly (bypassing Astro runtime) + * and starts the Model Context Protocol server on stdin/stdout. + * + * Usage: + * node scripts/mcp-server.mjs + * npm run mcp + */ +import fs from "node:fs"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { createSiteProjection } from "./lib/site-projection.mjs"; + +// --------------------------------------------------------------------------- +// Database helpers (standalone, no Astro dependency) +// --------------------------------------------------------------------------- +const root = path.resolve(import.meta.dirname, ".."); +const databasePath = path.join(root, "var", "recipe-book.sqlite"); + +function openDb(readOnly = true) { + if (!fs.existsSync(databasePath)) throw new Error(`Database not found at ${databasePath}`); + const db = new DatabaseSync(databasePath, { readOnly }); + db.exec("PRAGMA foreign_keys = ON"); + return db; +} + +function loadCatalogs() { + const db = openDb(true); + try { + const projection = createSiteProjection(db); + const map = (values) => new Map(values.map((v) => [v.id, v])); + return { + ingredients: map(projection.ingredients), + recipes: map(projection.recipes), + units: map(projection.units), + equipment: map(projection.equipment), + prepActions: map(projection.prepActions), + purchaseItems: map(projection.purchaseItems), + sourceMappings: map(projection.sourceMappings), + }; + } finally { + db.close(); + } +} + +// --------------------------------------------------------------------------- +// Dynamic imports for domain modules (TypeScript, processed by Node's ESM) +// --------------------------------------------------------------------------- +const { calculateCost } = await import("../src/lib/costing.ts"); +const { calculateNutrition } = await import("../src/lib/nutrition.ts"); +const { convert, convertWithIngredientMeasures } = await import("../src/lib/measurement.ts"); +const { exportDatabase } = await import("../src/lib/backup/export-database.ts"); +const { titleCase } = await import("../src/lib/format.ts"); +const { getInventoryCountDetail, getInventoryCounts } = await import("../src/lib/repository/inventory-repository.ts"); +const { recipeQualityRows, restoreArchivedItems } = await import("../src/lib/database.ts"); + +// --------------------------------------------------------------------------- +// Tool implementations +// --------------------------------------------------------------------------- +function searchRecipes(args) { + const catalogs = loadCatalogs(); + const q = (args.query ?? "").trim().toLowerCase(); + const limit = Math.min(Math.max(args.limit ?? 25, 1), 100); + let results = [...catalogs.recipes.values()]; + + if (q) results = results.filter((r) => r.title.toLowerCase().includes(q) || r.id.toLowerCase().includes(q) || (r.summary ?? "").toLowerCase().includes(q)); + if (args.category) results = results.filter((r) => r.categories.includes(args.category)); + if (args.tag) results = results.filter((r) => (r.tags ?? []).includes(args.tag)); + + return results.slice(0, limit).map((r) => ({ + id: r.id, title: r.title, summary: r.summary ?? null, + categories: r.categories, tags: r.tags, + yield: { quantity: r.yield.amount.quantity, unit_id: r.yield.amount.unit_id, servings: r.yield.servings ?? null }, + component_count: r.components.length, + item_count: r.components.reduce((n, c) => n + c.items.length, 0), + step_count: r.steps.length, + })); +} + +function getRecipe(args) { + const catalogs = loadCatalogs(); + const recipe = catalogs.recipes.get(args.id); + if (!recipe) throw new Error(`Recipe not found: ${args.id}`); + + let sf = 1; + if (args.scale_factor > 0) sf = args.scale_factor; + else if (args.target_yield > 0 && recipe.yield.amount.quantity > 0) { + if (args.target_yield_unit && args.target_yield_unit !== recipe.yield.amount.unit_id) { + try { sf = convert(args.target_yield, args.target_yield_unit, recipe.yield.amount.unit_id, catalogs.units) / recipe.yield.amount.quantity; } catch { sf = args.target_yield / recipe.yield.amount.quantity; } + } else sf = args.target_yield / recipe.yield.amount.quantity; + } + + return { + id: recipe.id, title: recipe.title, summary: recipe.summary ?? null, + categories: recipe.categories, tags: recipe.tags, station: recipe.station ?? null, + yield: { quantity: recipe.yield.amount.quantity * sf, base_quantity: recipe.yield.amount.quantity, unit_id: recipe.yield.amount.unit_id, servings: recipe.yield.servings ? recipe.yield.servings * sf : null, basis: recipe.yield.basis ?? null }, + scale_factor: sf, scaling: recipe.scaling ?? null, + components: recipe.components.map((c) => ({ + id: c.id, name: c.name, notes: c.notes ?? [], + items: c.items.map((item) => { + const ref = item.reference; + const isSub = "recipe_id" in ref; + const sid = isSub ? ref.recipe_id : ref.ingredient_id; + const subject = isSub ? catalogs.recipes.get(sid) : catalogs.ingredients.get(sid); + return { + id: item.id, + ingredient_id: isSub ? undefined : sid, + subrecipe_id: isSub ? sid : undefined, + name: subject ? (isSub ? subject.title : titleCase(subject.name)) : sid, + is_subrecipe: isSub, + quantity: item.amount.quantity * sf, + base_quantity: item.amount.quantity, + unit_id: item.amount.unit_id, + percentage: item.percentage ?? null, + basis_member: item.basis_member ?? false, + optional: item.optional ?? false, + notes: item.notes ?? null, + prep: item.prep ?? [], + }; + }), + })), + steps: recipe.steps.map((s, i) => ({ id: s.id, order: i + 1, instruction: s.instruction, critical_control_point: s.critical_control_point ?? false, equipment_ids: s.equipment_ids ?? [] })), + equipment_ids: recipe.equipment_ids ?? [], + notes: recipe.notes ?? [], + shelf_life: recipe.shelf_life ?? null, + }; +} + +function calcCost(args) { + const catalogs = loadCatalogs(); + const recipe = catalogs.recipes.get(args.recipe_id); + if (!recipe) throw new Error(`Recipe not found: ${args.recipe_id}`); + return calculateCost(recipe, { recipes: catalogs.recipes, ingredients: catalogs.ingredients, units: catalogs.units, purchaseItems: catalogs.purchaseItems, prepActions: catalogs.prepActions }, args.currency ?? "USD"); +} + +function calcNutrition(args) { + const catalogs = loadCatalogs(); + const recipe = catalogs.recipes.get(args.recipe_id); + if (!recipe) throw new Error(`Recipe not found: ${args.recipe_id}`); + return calculateNutrition(recipe, { recipes: catalogs.recipes, ingredients: catalogs.ingredients, units: catalogs.units, mappings: catalogs.sourceMappings }); +} + +function searchIngredients(args) { + const catalogs = loadCatalogs(); + const q = (args.query ?? "").trim().toLowerCase(); + const limit = Math.min(Math.max(args.limit ?? 25, 1), 100); + let results = [...catalogs.ingredients.values()]; + + if (q) results = results.filter((ing) => ing.name.toLowerCase().includes(q) || ing.id.toLowerCase().includes(q) || (ing.aliases ?? []).some((a) => a.name.toLowerCase().includes(q))); + if (args.category) results = results.filter((ing) => ing.categories.includes(args.category)); + if (args.missing_cost) { + const priced = new Set([...catalogs.purchaseItems.values()].filter((pi) => pi.status === "active").map((pi) => pi.ingredient_id)); + results = results.filter((ing) => !priced.has(ing.id)); + } + + return results.slice(0, limit).map((ing) => ({ + id: ing.id, name: titleCase(ing.name), status: ing.status, categories: ing.categories, + alias_count: (ing.aliases ?? []).length, + has_nutrition: (ing.nutrition_mapping_ids ?? []).length > 0, + has_cost: [...catalogs.purchaseItems.values()].some((pi) => pi.ingredient_id === ing.id && pi.status === "active"), + })); +} + +function getIngredientDetail(args) { + const catalogs = loadCatalogs(); + const ing = catalogs.ingredients.get(args.id); + if (!ing) throw new Error(`Ingredient not found: ${args.id}`); + const purchases = [...catalogs.purchaseItems.values()].filter((pi) => pi.ingredient_id === args.id); + return { + id: ing.id, name: titleCase(ing.name), raw_name: ing.name, status: ing.status, + categories: ing.categories, tags: ing.tags ?? [], aliases: ing.aliases ?? [], + density_measurements: (ing.density_measurements ?? []).map((d) => ({ id: d.id, mass: d.mass, volume: d.volume, state: d.state ?? null })), + measure_conversions: (ing.measure_conversions ?? []).map((c) => ({ id: c.id, from: c.from, to: c.to, state: c.state ?? null })), + prep_actions: ing.prep_actions ?? [], + nutrition_mapping_ids: ing.nutrition_mapping_ids ?? [], + purchase_items: purchases.map((p) => ({ id: p.id, name: p.name, brand: p.brand ?? null, status: p.status, package_quantity: p.package.quantity, package_unit_id: p.package.unit_id, latest_price: p.prices.length > 0 ? p.prices[p.prices.length - 1].amount : null, currency: p.prices.length > 0 ? p.prices[p.prices.length - 1].currency : null })), + }; +} + +function convertUnits(args) { + const catalogs = loadCatalogs(); + const units = catalogs.units; + const fromUnit = units.get(args.from_unit); + const toUnit = units.get(args.to_unit); + if (!fromUnit) throw new Error(`Unknown unit: ${args.from_unit}`); + if (!toUnit) throw new Error(`Unknown unit: ${args.to_unit}`); + + let result, method; + if (fromUnit.dimension === toUnit.dimension) { + result = convert(args.quantity, args.from_unit, args.to_unit, units); + method = "dimension_conversion"; + } else if (args.ingredient_id) { + const ing = catalogs.ingredients.get(args.ingredient_id); + if (!ing) throw new Error(`Unknown ingredient: ${args.ingredient_id}`); + const converted = convertWithIngredientMeasures({ quantity: args.quantity, unit_id: args.from_unit }, args.to_unit, ing, units); + result = converted.quantity; + method = "ingredient_measure_conversion"; + } else { + throw new Error(`Cannot convert ${fromUnit.dimension} to ${toUnit.dimension} without an ingredient_id for density lookup.`); + } + return { from: { quantity: args.quantity, unit_id: args.from_unit }, to: { quantity: result, unit_id: args.to_unit }, ingredient_id: args.ingredient_id ?? null, method }; +} + +function listUnits(args) { + const catalogs = loadCatalogs(); + let units = [...catalogs.units.values()]; + if (args?.dimension) units = units.filter((u) => u.dimension === args.dimension); + if (args?.system) units = units.filter((u) => u.system === args.system); + return units; +} + +function listEquipment(args) { + const catalogs = loadCatalogs(); + let items = [...catalogs.equipment.values()]; + if (args?.category) items = items.filter((e) => e.category === args.category); + return items; +} + +function listPrepActions() { + const catalogs = loadCatalogs(); + return [...catalogs.prepActions.values()]; +} + +function listPurchaseItems(args) { + const db = openDb(true); + try { + let query = ` + SELECT p.id, p.ingredient_id, i.name as ingredient_name, p.name, p.brand, + p.supplier_id, p.supplier_sku, p.status, p.package_quantity, p.package_unit_id, + p.units_per_case, p.usable_yield_factor + FROM purchase_items p + JOIN ingredients i ON i.id = p.ingredient_id + WHERE p.deleted_at IS NULL + `; + const params = []; + if (args.ingredient_id) { query += " AND p.ingredient_id = ?"; params.push(args.ingredient_id); } + if (args.status) { query += " AND p.status = ?"; params.push(args.status); } + query += " ORDER BY p.name LIMIT ?"; + params.push(Math.min(Math.max(args.limit ?? 50, 1), 200)); + + const rows = db.prepare(query).all(...params); + const priceStmt = db.prepare("SELECT amount, currency, effective_at FROM price_observations WHERE purchase_item_id = ? ORDER BY effective_at DESC LIMIT 1"); + return rows.map((r) => { + const p = priceStmt.get(r.id); + return { ...r, ingredient_name: titleCase(r.ingredient_name), latest_price: p?.amount ?? null, currency: p?.currency ?? null }; + }); + } finally { db.close(); } +} + +function getDatabaseStats() { + const db = openDb(true); + try { + const c = (t) => db.prepare(`SELECT count(*) as c FROM ${t}`).get().c; + return { recipes: c("recipes"), ingredients: c("ingredients"), units: c("units"), equipment: c("equipment"), purchase_items: c("purchase_items"), collections: c("collections"), inventory_counts: c("inventory_counts") }; + } finally { db.close(); } +} + +function listRecipeBooks() { + const db = openDb(true); + try { + return db.prepare( + `SELECT c.id, c.name, c.description, COUNT(cr.recipe_id) as recipe_count + FROM collections c LEFT JOIN collection_recipes cr ON cr.collection_id = c.id + WHERE c.deleted_at IS NULL GROUP BY c.id ORDER BY c.name` + ).all(); + } finally { db.close(); } +} + +function getRecipeBook(args) { + const db = openDb(true); + try { + const book = db.prepare("SELECT id, name, description FROM collections WHERE id = ? AND deleted_at IS NULL").get(args.id); + if (!book) throw new Error(`Recipe book not found: ${args.id}`); + const recipes = db.prepare( + `SELECT r.id, r.title, r.summary, cr.position + FROM collection_recipes cr JOIN recipes r ON r.id = cr.recipe_id + WHERE cr.collection_id = ? AND r.deleted_at IS NULL ORDER BY cr.position, r.title` + ).all(args.id); + return { ...book, recipes }; + } finally { db.close(); } +} + +function auditQuality() { + const db = openDb(true); + try { + const rows = recipeQualityRows(db); + const clean = rows.filter((r) => r.placeholder_steps === 0 && r.unpriced_items === 0 && r.yield_basis !== null).length; + return { summary: { total: rows.length, clean, needs_attention: rows.length - clean }, recipes: rows }; + } finally { db.close(); } +} + +// --------------------------------------------------------------------------- +// MCP Server setup +// --------------------------------------------------------------------------- +const server = new McpServer({ name: "formulation", version: "2.0.0" }); + +function wrap(fn) { + return async (args) => { + try { + const result = fn(args); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true }; + } + }; +} + +server.tool("search_recipes", "Search recipes by keyword, category, or tag.", { query: z.string().optional(), category: z.string().optional(), tag: z.string().optional(), limit: z.number().int().min(1).max(100).default(25) }, wrap(searchRecipes)); +server.tool("get_recipe", "Get full recipe formulation with optional scaling.", { id: z.string(), scale_factor: z.number().positive().optional(), target_yield: z.number().positive().optional(), target_yield_unit: z.string().optional() }, wrap(getRecipe)); +server.tool("audit_recipe_quality", "Run automated quality audit on all recipes.", {}, wrap(auditQuality)); +server.tool("calculate_recipe_cost", "Compute itemized cost breakdown for a recipe.", { recipe_id: z.string(), currency: z.string().default("USD") }, wrap(calcCost)); +server.tool("calculate_recipe_nutrition", "Calculate nutrition facts per 100g and per serving.", { recipe_id: z.string(), serving_size_g: z.number().positive().optional() }, wrap(calcNutrition)); +server.tool("search_ingredients", "Search ingredients with cost/nutrition status.", { query: z.string().optional(), category: z.string().optional(), missing_cost: z.boolean().optional(), limit: z.number().int().min(1).max(100).default(25) }, wrap(searchIngredients)); +server.tool("get_ingredient", "Get ingredient detail with density, equivalencies, and prices.", { id: z.string() }, wrap(getIngredientDetail)); +server.tool("list_purchase_items", "List purchase items with current prices.", { ingredient_id: z.string().optional(), status: z.string().optional(), limit: z.number().int().default(50) }, wrap(listPurchaseItems)); +server.tool("convert_units", "Convert culinary units using ingredient density data.", { ingredient_id: z.string().optional(), quantity: z.number().positive(), from_unit: z.string(), to_unit: z.string() }, wrap(convertUnits)); +server.tool("list_units", "List measurement units with conversion factors.", { dimension: z.string().optional(), system: z.string().optional() }, wrap(listUnits)); +server.tool("list_equipment", "List kitchen equipment.", { category: z.string().optional() }, wrap(listEquipment)); +server.tool("list_prep_actions", "List culinary prep actions.", {}, wrap(listPrepActions)); +server.tool("list_inventory_counts", "List inventory counting sessions.", { status: z.enum(["all", "open", "completed"]).default("all") }, wrap((args) => { const db = openDb(true); try { let counts = getInventoryCounts(db); if (args.status !== "all") counts = counts.filter((c) => c.status === args.status); return counts; } finally { db.close(); } })); +server.tool("get_inventory_count", "Get full inventory count sheet with items and valuations.", { id: z.string() }, wrap((args) => { const db = openDb(true); try { const d = getInventoryCountDetail(db, args.id); if (!d) throw new Error(`Count not found: ${args.id}`); return d; } finally { db.close(); } })); +server.tool("list_recipe_books", "List recipe books with recipe counts.", {}, wrap(listRecipeBooks)); +server.tool("get_recipe_book", "Get recipe book detail with included recipes.", { id: z.string() }, wrap(getRecipeBook)); +server.tool("export_database_backup", "Export complete JSON backup of all 25 tables.", {}, wrap(() => { const db = openDb(true); try { return exportDatabase(db); } finally { db.close(); } })); +server.tool("get_database_stats", "Get entity count statistics.", {}, wrap(getDatabaseStats)); + +const transport = new StdioServerTransport(); +await server.connect(transport); +console.error("Formulation MCP Server running on stdio"); diff --git a/src/application/pages/api/app/backup/export.ts b/src/application/pages/api/app/backup/export.ts new file mode 100644 index 0000000..1e6a45c --- /dev/null +++ b/src/application/pages/api/app/backup/export.ts @@ -0,0 +1,37 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { exportDatabase } from "../../../../../lib/backup"; + +export const prerender = false; + +export const GET: APIRoute = async () => { + const database = openDatabase({ readOnly: true }); + if (!database) { + return Response.json( + { error: "Database is unavailable." }, + { status: 503 } + ); + } + + try { + const bundle = exportDatabase(database); + const dateStr = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const filename = `formulation-backup-${dateStr}.json`; + + return new Response(JSON.stringify(bundle, null, 2), { + status: 200, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + "Cache-Control": "no-cache, no-store, must-revalidate", + }, + }); + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : "Export failed." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/app/backup/import.ts b/src/application/pages/api/app/backup/import.ts new file mode 100644 index 0000000..446d512 --- /dev/null +++ b/src/application/pages/api/app/backup/import.ts @@ -0,0 +1,37 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { importDatabase, type FormulationBackupBundle, type ImportMode } from "../../../../../lib/backup"; + +export const prerender = false; + +export const POST: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: false }); + if (!database) { + return Response.json( + { error: "Database is unavailable." }, + { status: 503 } + ); + } + + try { + const url = new URL(request.url); + const mode = (url.searchParams.get("mode") ?? "replace") as ImportMode; + const bundle = (await request.json()) as FormulationBackupBundle; + + const result = importDatabase(database, bundle, { + mode: mode === "merge" ? "merge" : "replace", + rebuildProjections: true, + }); + + return Response.json(result, { status: 200 }); + } catch (error) { + return Response.json( + { + error: error instanceof Error ? error.message : "Import failed.", + }, + { status: 400 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/app/backup/validate.ts b/src/application/pages/api/app/backup/validate.ts new file mode 100644 index 0000000..9ab857c --- /dev/null +++ b/src/application/pages/api/app/backup/validate.ts @@ -0,0 +1,25 @@ +import type { APIRoute } from "astro"; +import { validateBackupBundle } from "../../../../../lib/backup"; + +export const prerender = false; + +export const POST: APIRoute = async ({ request }) => { + try { + const payload = await request.json(); + const result = validateBackupBundle(payload); + return Response.json(result, { status: result.valid ? 200 : 400 }); + } catch (error) { + return Response.json( + { + valid: false, + errors: [ + error instanceof Error + ? `Invalid JSON: ${error.message}` + : "Invalid JSON payload.", + ], + warnings: [], + }, + { status: 400 } + ); + } +}; diff --git a/src/application/pages/api/app/recipes/parse-ingredients.ts b/src/application/pages/api/app/recipes/parse-ingredients.ts new file mode 100644 index 0000000..b29518e --- /dev/null +++ b/src/application/pages/api/app/recipes/parse-ingredients.ts @@ -0,0 +1,20 @@ +import type { APIRoute } from "astro"; +import { parseIngredientsWithOllama } from "../../../../../lib/ingredient-parser"; +import { readOnlyMode } from "../../../../../lib/runtime"; + +export const prerender = false; + +export const POST: APIRoute = async ({ request }) => { + if (readOnlyMode) return Response.json({ error: "Ingredient parsing is unavailable in read-only mode." }, { status: 403 }); + try { + const body = await request.json() as { text?: unknown }; + if (typeof body.text !== "string") return Response.json({ error: "Ingredient text is required." }, { status: 400 }); + if (body.text.length > 20_000) return Response.json({ error: "Ingredient text is too long." }, { status: 413 }); + return Response.json(await parseIngredientsWithOllama(body.text)); + } catch (error) { + const message = error instanceof Error && error.name === "TimeoutError" + ? "Ingredient parser timed out. Try again." + : error instanceof Error ? error.message : "Unable to parse ingredients."; + return Response.json({ error: message }, { status: 502 }); + } +}; diff --git a/src/application/pages/api/v1/archive/index.ts b/src/application/pages/api/v1/archive/index.ts new file mode 100644 index 0000000..bcaf142 --- /dev/null +++ b/src/application/pages/api/v1/archive/index.ts @@ -0,0 +1,23 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async () => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const archived = tools.listArchivedItems(); + return Response.json({ success: true, data: archived }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to list archived items." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/archive/restore.ts b/src/application/pages/api/v1/archive/restore.ts new file mode 100644 index 0000000..0451072 --- /dev/null +++ b/src/application/pages/api/v1/archive/restore.ts @@ -0,0 +1,31 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const POST: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + if (!Array.isArray(body.items) || body.items.length === 0) { + return Response.json( + { success: false, error: "Items array with { id, type } elements is required." }, + { status: 400 } + ); + } + + const tools = createMcpTools(() => database); + const result = tools.restoreArchived({ items: body.items }); + return Response.json(result); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to restore items." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/collections/[id].ts b/src/application/pages/api/v1/collections/[id].ts new file mode 100644 index 0000000..3a9c9ca --- /dev/null +++ b/src/application/pages/api/v1/collections/[id].ts @@ -0,0 +1,73 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing recipe book ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const book = tools.getRecipeBook({ id }); + return Response.json({ success: true, data: book }); + } catch (error) { + const status = error instanceof Error && error.message.includes("not found") ? 404 : 500; + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to get recipe book." }, + { status } + ); + } finally { + database.close(); + } +}; + +export const PUT: APIRoute = async ({ params, request }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing recipe book ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + body.id = id; + const tools = createMcpTools(() => database); + const result = tools.saveRecipeBook(body); + return Response.json(result); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to update recipe book." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; + +export const DELETE: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing recipe book ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const existing = database.prepare("SELECT 1 FROM collections WHERE id = ? AND deleted_at IS NULL").get(id); + if (!existing) return Response.json({ success: false, error: "Recipe book not found." }, { status: 404 }); + + database.prepare("UPDATE collections SET deleted_at = datetime('now') WHERE id = ?").run(id); + return Response.json({ success: true, message: `Recipe book '${id}' archived.` }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to archive recipe book." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/collections/index.ts b/src/application/pages/api/v1/collections/index.ts new file mode 100644 index 0000000..f231221 --- /dev/null +++ b/src/application/pages/api/v1/collections/index.ts @@ -0,0 +1,46 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async () => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const books = tools.listRecipeBooks(); + return Response.json({ success: true, count: books.length, data: books }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to list recipe books." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; + +export const POST: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + if (!body.name || !String(body.name).trim()) { + return Response.json({ success: false, error: "Recipe book name is required." }, { status: 400 }); + } + + const tools = createMcpTools(() => database); + const result = tools.saveRecipeBook(body); + return Response.json(result, { status: 201 }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to create recipe book." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/convert.ts b/src/application/pages/api/v1/convert.ts new file mode 100644 index 0000000..d4d49ef --- /dev/null +++ b/src/application/pages/api/v1/convert.ts @@ -0,0 +1,36 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../lib/database"; +import { createMcpTools } from "../../../../mcp/tools"; + +export const prerender = false; + +export const POST: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + if (!body.quantity || !body.from_unit || !body.to_unit) { + return Response.json( + { success: false, error: "Required fields: quantity, from_unit, to_unit" }, + { status: 400 } + ); + } + + const tools = createMcpTools(() => database); + const result = tools.convertUnits({ + ingredient_id: body.ingredient_id, + quantity: Number(body.quantity), + from_unit: body.from_unit, + to_unit: body.to_unit, + }); + return Response.json({ success: true, data: result }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Conversion failed." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/equipment/index.ts b/src/application/pages/api/v1/equipment/index.ts new file mode 100644 index 0000000..f7d0b8e --- /dev/null +++ b/src/application/pages/api/v1/equipment/index.ts @@ -0,0 +1,26 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const url = new URL(request.url); + const tools = createMcpTools(() => database); + const equipment = tools.listEquipment({ + category: url.searchParams.get("category") ?? undefined, + }); + return Response.json({ success: true, count: equipment.length, data: equipment }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to list equipment." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/ingredients/[id].ts b/src/application/pages/api/v1/ingredients/[id].ts new file mode 100644 index 0000000..cea441f --- /dev/null +++ b/src/application/pages/api/v1/ingredients/[id].ts @@ -0,0 +1,72 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing ingredient ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const ingredient = tools.getIngredient({ id }); + return Response.json({ success: true, data: ingredient }); + } catch (error) { + const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500; + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to get ingredient." }, + { status } + ); + } finally { + database.close(); + } +}; + +export const PUT: APIRoute = async ({ params, request }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing ingredient ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + body.id = id; + const tools = createMcpTools(() => database); + const result = tools.saveIngredient(body); + return Response.json(result); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to update ingredient." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; + +export const DELETE: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing ingredient ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const result = tools.deleteIngredient({ id }); + return Response.json(result); + } catch (error) { + const status = error instanceof Error && error.message.includes("Cannot archive") ? 409 : 500; + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to delete ingredient." }, + { status } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/ingredients/index.ts b/src/application/pages/api/v1/ingredients/index.ts new file mode 100644 index 0000000..f4886dc --- /dev/null +++ b/src/application/pages/api/v1/ingredients/index.ts @@ -0,0 +1,52 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const url = new URL(request.url); + const tools = createMcpTools(() => database); + const ingredients = tools.searchIngredients({ + query: url.searchParams.get("q") ?? url.searchParams.get("query") ?? undefined, + category: url.searchParams.get("category") ?? undefined, + missing_cost: url.searchParams.get("missing_cost") === "true" ? true : undefined, + limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : 50, + }); + return Response.json({ success: true, count: ingredients.length, data: ingredients }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to list ingredients." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; + +export const POST: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + if (!body.name || !String(body.name).trim()) { + return Response.json({ success: false, error: "Ingredient name is required." }, { status: 400 }); + } + + const tools = createMcpTools(() => database); + const result = tools.saveIngredient(body); + return Response.json(result, { status: 201 }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to create ingredient." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/inventory/counts.ts b/src/application/pages/api/v1/inventory/counts.ts new file mode 100644 index 0000000..fc88b8b --- /dev/null +++ b/src/application/pages/api/v1/inventory/counts.ts @@ -0,0 +1,58 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const url = new URL(request.url); + const statusParam = url.searchParams.get("status"); + const status = statusParam === "open" || statusParam === "completed" ? statusParam : "all"; + + const tools = createMcpTools(() => database); + const counts = tools.listInventoryCounts({ status }); + return Response.json({ success: true, count: counts.length, data: counts }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to list inventory counts." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; + +export const POST: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + if (!body.title || !body.counted_at) { + return Response.json( + { success: false, error: "Required fields: title, counted_at" }, + { status: 400 } + ); + } + + const tools = createMcpTools(() => database); + const result = tools.createInventoryCountSession({ + title: body.title, + counted_at: body.counted_at, + notes: body.notes, + prepopulate: body.prepopulate !== false, + }); + return Response.json(result, { status: 201 }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to create inventory count." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/inventory/counts/[id].ts b/src/application/pages/api/v1/inventory/counts/[id].ts new file mode 100644 index 0000000..e87823a --- /dev/null +++ b/src/application/pages/api/v1/inventory/counts/[id].ts @@ -0,0 +1,77 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../../lib/database"; +import { createMcpTools } from "../../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing count ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const count = tools.getInventoryCount({ id }); + return Response.json({ success: true, data: count }); + } catch (error) { + const status = error instanceof Error && error.message.includes("not found") ? 404 : 500; + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to get inventory count." }, + { status } + ); + } finally { + database.close(); + } +}; + +export const PUT: APIRoute = async ({ params, request }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing count ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + const tools = createMcpTools(() => database); + const result = tools.updateInventoryCount({ + count_id: id, + status: body.status, + notes: body.notes, + items: body.items, + }); + return Response.json(result); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to update inventory count." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; + +export const DELETE: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing count ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const existing = database.prepare("SELECT 1 FROM inventory_counts WHERE id = ? AND deleted_at IS NULL").get(id); + if (!existing) return Response.json({ success: false, error: "Inventory count not found." }, { status: 404 }); + + database.prepare("UPDATE inventory_counts SET deleted_at = datetime('now') WHERE id = ?").run(id); + return Response.json({ success: true, message: `Inventory count '${id}' archived.` }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to archive inventory count." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/prep-actions/index.ts b/src/application/pages/api/v1/prep-actions/index.ts new file mode 100644 index 0000000..55f7d35 --- /dev/null +++ b/src/application/pages/api/v1/prep-actions/index.ts @@ -0,0 +1,23 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async () => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const actions = tools.listPrepActions(); + return Response.json({ success: true, count: actions.length, data: actions }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to list prep actions." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/purchases/[id]/index.ts b/src/application/pages/api/v1/purchases/[id]/index.ts new file mode 100644 index 0000000..4bca43e --- /dev/null +++ b/src/application/pages/api/v1/purchases/[id]/index.ts @@ -0,0 +1,73 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../../lib/database"; +import { createMcpTools } from "../../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing purchase item ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const item = tools.getPurchaseItem({ id }); + return Response.json({ success: true, data: item }); + } catch (error) { + const status = error instanceof Error && error.message.includes("not found") ? 404 : 500; + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to get purchase item." }, + { status } + ); + } finally { + database.close(); + } +}; + +export const PUT: APIRoute = async ({ params, request }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing purchase item ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + body.id = id; + const tools = createMcpTools(() => database); + const result = tools.savePurchaseItem(body); + return Response.json(result); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to update purchase item." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; + +export const DELETE: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing purchase item ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const existing = database.prepare("SELECT 1 FROM purchase_items WHERE id = ? AND deleted_at IS NULL").get(id); + if (!existing) return Response.json({ success: false, error: "Purchase item not found." }, { status: 404 }); + + database.prepare("UPDATE purchase_items SET deleted_at = datetime('now') WHERE id = ?").run(id); + return Response.json({ success: true, message: `Purchase item '${id}' archived.` }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to archive purchase item." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/purchases/[id]/prices.ts b/src/application/pages/api/v1/purchases/[id]/prices.ts new file mode 100644 index 0000000..a9ec617 --- /dev/null +++ b/src/application/pages/api/v1/purchases/[id]/prices.ts @@ -0,0 +1,39 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../../lib/database"; +import { createMcpTools } from "../../../../../../mcp/tools"; + +export const prerender = false; + +export const POST: APIRoute = async ({ params, request }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing purchase item ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + if (typeof body.amount !== "number" || body.amount <= 0) { + return Response.json( + { success: false, error: "A positive price amount is required." }, + { status: 400 } + ); + } + + const tools = createMcpTools(() => database); + const result = tools.recordPriceObservation({ + purchase_item_id: id, + amount: body.amount, + currency: body.currency, + effective_at: body.effective_at, + }); + return Response.json(result, { status: 201 }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to record price observation." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/purchases/index.ts b/src/application/pages/api/v1/purchases/index.ts new file mode 100644 index 0000000..75c851b --- /dev/null +++ b/src/application/pages/api/v1/purchases/index.ts @@ -0,0 +1,54 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const url = new URL(request.url); + const tools = createMcpTools(() => database); + const items = tools.listPurchaseItems({ + ingredient_id: url.searchParams.get("ingredient_id") ?? undefined, + status: url.searchParams.get("status") ?? undefined, + limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : 50, + }); + return Response.json({ success: true, count: items.length, data: items }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to list purchase items." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; + +export const POST: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + if (!body.ingredient_id || !body.name || !body.package_quantity || !body.package_unit_id) { + return Response.json( + { success: false, error: "Required fields: ingredient_id, name, package_quantity, package_unit_id" }, + { status: 400 } + ); + } + + const tools = createMcpTools(() => database); + const result = tools.savePurchaseItem(body); + return Response.json(result, { status: 201 }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to create purchase item." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/recipes/[id]/cost.ts b/src/application/pages/api/v1/recipes/[id]/cost.ts new file mode 100644 index 0000000..b8c497d --- /dev/null +++ b/src/application/pages/api/v1/recipes/[id]/cost.ts @@ -0,0 +1,29 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../../lib/database"; +import { createMcpTools } from "../../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params, request }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing recipe ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const url = new URL(request.url); + const currency = url.searchParams.get("currency") ?? "USD"; + const tools = createMcpTools(() => database); + const cost = tools.calculateRecipeCost({ recipe_id: id, currency }); + return Response.json({ success: true, data: cost }); + } catch (error) { + const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500; + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Cost calculation failed." }, + { status } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/recipes/[id]/index.ts b/src/application/pages/api/v1/recipes/[id]/index.ts new file mode 100644 index 0000000..810a429 --- /dev/null +++ b/src/application/pages/api/v1/recipes/[id]/index.ts @@ -0,0 +1,79 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../../lib/database"; +import { createMcpTools } from "../../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params, request }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing recipe ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const url = new URL(request.url); + const tools = createMcpTools(() => database); + const recipe = tools.getRecipe({ + id, + scale_factor: url.searchParams.get("scale") ? Number(url.searchParams.get("scale")) : undefined, + target_yield: url.searchParams.get("yield") ? Number(url.searchParams.get("yield")) : undefined, + target_yield_unit: url.searchParams.get("yield_unit") ?? undefined, + }); + return Response.json({ success: true, data: recipe }); + } catch (error) { + const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500; + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to get recipe." }, + { status } + ); + } finally { + database.close(); + } +}; + +export const PUT: APIRoute = async ({ params, request }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing recipe ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + body.id = id; + const tools = createMcpTools(() => database); + const result = tools.saveRecipe(body); + return Response.json(result); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to update recipe." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; + +export const DELETE: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing recipe ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const existing = database.prepare("SELECT 1 FROM recipes WHERE id = ? AND deleted_at IS NULL").get(id); + if (!existing) return Response.json({ success: false, error: "Recipe not found." }, { status: 404 }); + + database.prepare("UPDATE recipes SET deleted_at = datetime('now') WHERE id = ?").run(id); + return Response.json({ success: true, message: `Recipe '${id}' archived.` }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to archive recipe." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/recipes/[id]/nutrition.ts b/src/application/pages/api/v1/recipes/[id]/nutrition.ts new file mode 100644 index 0000000..cd5b920 --- /dev/null +++ b/src/application/pages/api/v1/recipes/[id]/nutrition.ts @@ -0,0 +1,27 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../../lib/database"; +import { createMcpTools } from "../../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing recipe ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const nutrition = tools.calculateRecipeNutrition({ recipe_id: id }); + return Response.json({ success: true, data: nutrition }); + } catch (error) { + const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500; + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Nutrition calculation failed." }, + { status } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/recipes/[id]/scale.ts b/src/application/pages/api/v1/recipes/[id]/scale.ts new file mode 100644 index 0000000..b04cd64 --- /dev/null +++ b/src/application/pages/api/v1/recipes/[id]/scale.ts @@ -0,0 +1,33 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../../lib/database"; +import { createMcpTools } from "../../../../../../mcp/tools"; + +export const prerender = false; + +export const POST: APIRoute = async ({ params, request }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing recipe ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json().catch(() => ({})); + const tools = createMcpTools(() => database); + const recipe = tools.getRecipe({ + id, + scale_factor: body.scale_factor, + target_yield: body.target_yield, + target_yield_unit: body.target_yield_unit, + }); + return Response.json({ success: true, data: recipe }); + } catch (error) { + const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500; + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Scaling failed." }, + { status } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/recipes/index.ts b/src/application/pages/api/v1/recipes/index.ts new file mode 100644 index 0000000..bde8e8e --- /dev/null +++ b/src/application/pages/api/v1/recipes/index.ts @@ -0,0 +1,53 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: true }); + if (!database) { + return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + } + + try { + const url = new URL(request.url); + const tools = createMcpTools(() => database); + const recipes = tools.searchRecipes({ + query: url.searchParams.get("q") ?? url.searchParams.get("query") ?? undefined, + category: url.searchParams.get("category") ?? undefined, + tag: url.searchParams.get("tag") ?? undefined, + limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : 50, + }); + + return Response.json({ success: true, count: recipes.length, data: recipes }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to list recipes." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; + +export const POST: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: false }); + if (!database) { + return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + } + + try { + const body = await request.json(); + const tools = createMcpTools(() => database); + const result = tools.saveRecipe(body); + return Response.json(result, { status: 201 }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to create recipe." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/recipes/quality.ts b/src/application/pages/api/v1/recipes/quality.ts new file mode 100644 index 0000000..94797ec --- /dev/null +++ b/src/application/pages/api/v1/recipes/quality.ts @@ -0,0 +1,23 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async () => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const audit = tools.auditRecipeQuality(); + return Response.json({ success: true, data: audit }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to run quality audit." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/units/index.ts b/src/application/pages/api/v1/units/index.ts new file mode 100644 index 0000000..905603d --- /dev/null +++ b/src/application/pages/api/v1/units/index.ts @@ -0,0 +1,27 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const url = new URL(request.url); + const tools = createMcpTools(() => database); + const units = tools.listUnits({ + dimension: url.searchParams.get("dimension") ?? undefined, + system: url.searchParams.get("system") ?? undefined, + }); + return Response.json({ success: true, count: units.length, data: units }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to list units." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/app/archive.astro b/src/application/pages/app/archive.astro index c35c20c..55aa46a 100644 --- a/src/application/pages/app/archive.astro +++ b/src/application/pages/app/archive.astro @@ -1,9 +1,666 @@ --- -export const prerender=false; +export const prerender = false; import BaseLayout from "../../../layouts/BaseLayout.astro"; -import {openDatabase,refreshSiteProjection} from "../../../lib/database"; -const database=openDatabase({readOnly:false});if(!database)return Astro.redirect("/app/",303); -if(Astro.request.method==="POST"){const form=await Astro.request.formData(),type=String(form.get("type")),id=String(form.get("id"));const tables:{[key:string]:string}={recipe:"recipes",ingredient:"ingredients",book:"collections"};if(tables[type])database.prepare(`UPDATE ${tables[type]} SET deleted_at=NULL${type==="ingredient"?",status='active'":""} WHERE id=?`).run(id);refreshSiteProjection(database);database.close();return Astro.redirect("/app/archive/",303);} -const items=[...(database.prepare("SELECT id,title name,deleted_at FROM recipes WHERE deleted_at IS NOT NULL").all() as any[]).map(x=>({...x,type:"recipe"})),...(database.prepare("SELECT id,name,deleted_at FROM ingredients WHERE deleted_at IS NOT NULL").all() as any[]).map(x=>({...x,type:"ingredient"})),...(database.prepare("SELECT id,name,deleted_at FROM collections WHERE deleted_at IS NOT NULL").all() as any[]).map(x=>({...x,type:"book"}))].sort((a,b)=>a.name.localeCompare(b.name));database.close(); +import DetailUtility from "../../../components/DetailUtility.astro"; +import { + openDatabase, + permanentlyDeleteArchivedItems, + restoreArchivedItems, +} from "../../../lib/database"; +import { titleCase } from "../../../lib/format"; +import { readOnlyMode } from "../../../lib/runtime"; +import { TYPE_ICONS, TYPE_ICON_TRANSFORMS, type EntityIconKey } from "../../../lib/icons"; + +const database = openDatabase({ readOnly: false }); +if (!database) return Astro.redirect("/app/", 303); + +let error = ""; +if (Astro.request.method === "POST") { + try { + const form = await Astro.request.formData(); + const intent = String(form.get("intent") ?? "restore"); + + if (intent === "restore") { + const type = String(form.get("type")); + const id = String(form.get("id")); + restoreArchivedItems(database, [{ id, type }]); + database.close(); + return Astro.redirect("/app/archive/", 303); + } + + if (intent === "delete") { + const type = String(form.get("type")); + const id = String(form.get("id")); + permanentlyDeleteArchivedItems(database, [{ id, type }]); + database.close(); + return Astro.redirect("/app/archive/", 303); + } + + if (intent === "batch_restore") { + const selectedItems = form.getAll("selected_item").map((val) => { + const [type, id] = String(val).split(":", 2); + return { type, id }; + }); + if (selectedItems.length > 0) { + restoreArchivedItems(database, selectedItems); + } + database.close(); + return Astro.redirect("/app/archive/", 303); + } + + if (intent === "batch_delete") { + const selectedItems = form.getAll("selected_item").map((val) => { + const [type, id] = String(val).split(":", 2); + return { type, id }; + }); + if (selectedItems.length > 0) { + permanentlyDeleteArchivedItems(database, selectedItems); + } + database.close(); + return Astro.redirect("/app/archive/", 303); + } + } catch (cause) { + error = cause instanceof Error ? cause.message : "Action failed."; + } +} + +const recipes = ( + database + .prepare( + "SELECT id, title AS name, deleted_at FROM recipes WHERE deleted_at IS NOT NULL" + ) + .all() as any[] +).map((x) => ({ ...x, type: "recipe" as const })); + +const ingredients = ( + database + .prepare( + "SELECT id, name, deleted_at FROM ingredients WHERE deleted_at IS NOT NULL" + ) + .all() as any[] +).map((x) => ({ ...x, type: "ingredient" as const })); + +const books = ( + database + .prepare( + "SELECT id, name, deleted_at FROM collections WHERE deleted_at IS NOT NULL" + ) + .all() as any[] +).map((x) => ({ ...x, type: "book" as const })); + +const purchases = ( + database + .prepare( + "SELECT id, name, deleted_at FROM purchase_items WHERE deleted_at IS NOT NULL" + ) + .all() as any[] +).map((x) => ({ ...x, type: "purchase" as const })); + +const allItems = [...recipes, ...ingredients, ...books, ...purchases].sort( + (a, b) => a.name.localeCompare(b.name) +); +database.close(); + +const requestedFilter = Astro.url.searchParams.get("type") ?? "all"; +const query = (Astro.url.searchParams.get("q") ?? "").trim().toLowerCase(); + +const filteredItems = allItems.filter((item) => { + if (requestedFilter !== "all" && item.type !== requestedFilter) return false; + if (query && !item.name.toLowerCase().includes(query)) return false; + return true; +}); + +function formatDeleteDate(dateStr: string | null) { + if (!dateStr) return ""; + try { + const d = new Date( + dateStr.includes("Z") || dateStr.includes("T") + ? dateStr + : `${dateStr.replace(" ", "T")}Z` + ); + if (isNaN(d.getTime())) return dateStr; + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + } catch { + return dateStr; + } +} --- -

← All items

Archive

Restore recipes, ingredients, and recipe books removed from the active workspace.

{items.length?items.map(item=>
{item.type==="recipe"?"▦":item.type==="book"?"▣":"●"}{item.name}{item.type} · deleted {item.deleted_at}
):

Nothing has been archived.

}
+ + +
+ + +
+ {error &&
{error}
} + +
+
+

Archive

+

+ Restore recipes, ingredients, recipe books, and purchase items or permanently purge them. +

+
+
+ + + + {filteredItems.length > 0 ? ( +
+ + +
+
+ {!readOnlyMode && ( + + + + )} + Type + Item Name + Deleted Date + Action +
+ +
+ {filteredItems.map((item) => ( +
+ {!readOnlyMode && ( +
+ +
+ )} + +
+ + + + + +
+ +
+ {titleCase(item.name)} + {item.type} +
+ +
+ Deleted {formatDeleteDate(item.deleted_at)} +
+ +
+ {!readOnlyMode && ( +
+ + +
+ )} +
+
+ ))} +
+
+ + + {!readOnlyMode && ( + + )} +
+ ) : ( +
+
+ + + + + +
+

Nothing in the archive

+

Archived recipes, ingredients, and recipe books will appear here and can be restored anytime.

+ Return to workspace +
+ )} +
+
+
+ + + + diff --git a/src/application/pages/app/index.astro b/src/application/pages/app/index.astro index c19ce3b..6afa085 100644 --- a/src/application/pages/app/index.astro +++ b/src/application/pages/app/index.astro @@ -2,92 +2,276 @@ export const prerender = false; import BaseLayout from "../../../layouts/BaseLayout.astro"; import EntityDirectory from "../../../components/EntityDirectory"; +import WorkspaceFilterBar from "../../../components/WorkspaceFilterBar"; +import { TYPE_ICONS, TYPE_ICON_TRANSFORMS } from "../../../lib/icons"; import { openDatabase } from "../../../lib/database"; import { readOnlyMode } from "../../../lib/runtime"; +import { titleCase } from "../../../lib/format"; +import type { + DirectoryBookRow, + DirectoryIngredientRow, + DirectoryPurchaseRow, + DirectoryRecipeRow, +} from "../../../lib/repository"; const database = openDatabase(); -if (!database) return new Response("Database unavailable", { status:503 }); -const recipes=database.prepare(`SELECT r.id,r.title,r.yield_quantity,r.yield_unit_id, +if (!database) return new Response("Database unavailable", { status: 503 }); +const recipes = database.prepare(`SELECT r.id,r.title,r.yield_quantity,r.yield_unit_id,r.tags_json,r.categories_json,r.station, (SELECT count(*) FROM recipe_items ri WHERE ri.recipe_id=r.id) item_count, (SELECT count(*) FROM recipe_steps rs WHERE rs.recipe_id=r.id AND rs.instruction LIKE 'TODO:%') placeholder_count - FROM recipes r WHERE r.deleted_at IS NULL ORDER BY r.title`).all() as any[]; -const ingredients=database.prepare(`SELECT i.id,i.name,i.status, + FROM recipes r WHERE r.deleted_at IS NULL ORDER BY r.title`).all() as unknown as DirectoryRecipeRow[]; +const ingredients = database.prepare(`SELECT i.id,i.name,i.status,i.tags_json,i.categories_json, (SELECT count(*) FROM recipe_items r WHERE r.ingredient_id=i.id) recipe_count, (SELECT count(*) FROM price_observations po JOIN purchase_items p ON p.id=po.purchase_item_id WHERE p.ingredient_id=i.id) price_count, (SELECT count(*) FROM source_mappings m WHERE m.subject_type='ingredient' AND m.subject_id=i.id AND m.mapping_type='nutrition' AND m.status='reviewed') nutrition_count - FROM ingredients i WHERE i.deleted_at IS NULL ORDER BY i.name`).all() as any[]; -const books=database.prepare("SELECT c.id,c.name,c.description,(SELECT count(*) FROM collection_recipes r WHERE r.collection_id=c.id) recipe_count FROM collections c WHERE c.deleted_at IS NULL ORDER BY c.name").all() as any[]; -const purchases=database.prepare(`SELECT p.id,p.ingredient_id,p.name,p.supplier_id,p.status,i.name ingredient_name,p.package_quantity,p.package_unit_id, + FROM ingredients i WHERE i.deleted_at IS NULL ORDER BY i.name`).all() as unknown as DirectoryIngredientRow[]; +const books = database.prepare("SELECT c.id,c.name,c.description,(SELECT count(*) FROM collection_recipes r WHERE r.collection_id=c.id) recipe_count FROM collections c WHERE c.deleted_at IS NULL ORDER BY c.name").all() as unknown as DirectoryBookRow[]; +const purchases = database.prepare(`SELECT p.id,p.ingredient_id,p.name,p.supplier_id,p.status,i.name ingredient_name,p.package_quantity,p.package_unit_id, (SELECT amount FROM price_observations x WHERE x.purchase_item_id=p.id ORDER BY effective_at DESC LIMIT 1) latest_price - FROM purchase_items p JOIN ingredients i ON i.id=p.ingredient_id ORDER BY p.name`).all() as any[]; + FROM purchase_items p JOIN ingredients i ON i.id=p.ingredient_id ORDER BY p.name`).all() as unknown as DirectoryPurchaseRow[]; +const recipeItemRows = database.prepare("SELECT recipe_id, ingredient_id FROM recipe_items WHERE ingredient_id IS NOT NULL").all() as { recipe_id: string; ingredient_id: string }[]; +const inventoryCounts = database.prepare("SELECT count(*) as c FROM inventory_counts WHERE deleted_at IS NULL").get() as { c: number } | undefined; database.close(); -const requested=Astro.url.searchParams.get("type")??"ingredient"; -const type=["recipe","ingredient","book","purchase"].includes(requested)?requested:"ingredient"; +const recipeIngredientsMap = new Map>(); +const ingredientRecipeCounts = new Map(); +recipeItemRows.forEach(({ recipe_id, ingredient_id }) => { + if (!recipeIngredientsMap.has(recipe_id)) { + recipeIngredientsMap.set(recipe_id, new Set()); + } + const set = recipeIngredientsMap.get(recipe_id)!; + if (!set.has(ingredient_id)) { + set.add(ingredient_id); + ingredientRecipeCounts.set(ingredient_id, (ingredientRecipeCounts.get(ingredient_id) || 0) + 1); + } +}); + +const requested=Astro.url.searchParams.get("type"); +if (requested === "inventory") return Astro.redirect("/app/inventory/", 303); const query=(Astro.url.searchParams.get("q")??"").trim(); const normalizedQuery=query.toLocaleLowerCase(); const validSearchTypes=["recipe","ingredient","book","purchase"]; const selectedSearchTypes=Astro.url.searchParams.getAll("item_type").filter((value)=>validSearchTypes.includes(value)); const filteringSearchTypes=selectedSearchTypes.length>0; -const attention=Astro.url.searchParams.get("attention")==="1", missingCost=Astro.url.searchParams.get("missing_cost")==="1", noUsda=Astro.url.searchParams.get("no_usda")==="1",unused=Astro.url.searchParams.get("unused")==="1",emptyRecipe=Astro.url.searchParams.get("empty_recipe")==="1",placeholderSteps=Astro.url.searchParams.get("placeholder_steps")==="1"; -const filtering=attention||missingCost||noUsda||unused||emptyRecipe||placeholderSteps; -const filteredIngredients=ingredients.filter((ingredient)=>{if(!filtering)return true;const selected=[missingCost&&ingredient.price_count===0,noUsda&&ingredient.nutrition_count===0,unused&&ingredient.recipe_count===0].filter(Boolean);return missingCost||noUsda||unused?selected.length>0:ingredient.price_count===0||ingredient.nutrition_count===0||ingredient.recipe_count===0;}); -const filteredRecipes=recipes.filter(recipe=>!filtering||(emptyRecipe&&recipe.item_count===0)||(placeholderSteps&&recipe.placeholder_count>0)||(!emptyRecipe&&!placeholderSteps&&(recipe.item_count===0||recipe.placeholder_count>0))); -const tabs=[ - {type:"recipe",label:"Recipes",count:recipes.length,icon:"▦",kind:"recipe"}, - {type:"ingredient",label:"Ingredients",count:ingredients.length,icon:"●",kind:"ingredient"}, - {type:"book",label:"Recipe books",count:books.length,icon:"▣",kind:"book"}, - {type:"purchase",label:"Purchase items",count:purchases.length,icon:"$",kind:"purchase"}, +const selectedTags=Astro.url.searchParams.getAll("tag").map(t=>t.trim().toLowerCase()).filter(Boolean); +const selectedIngredients=Astro.url.searchParams.getAll("ingredient").map(i=>i.trim().toLowerCase()).filter(Boolean); + +// Default to "recipe" when not in a global search +const type = ["recipe","ingredient","book","purchase"].includes(requested ?? "") + ? requested + : (query ? undefined : "recipe"); + +// Recipe attention filters +const emptyRecipe=Astro.url.searchParams.get("empty_recipe")==="1"; +const missingYield=Astro.url.searchParams.get("missing_yield")==="1"; +const placeholderSteps=Astro.url.searchParams.get("placeholder_steps")==="1"; +const attentionRecipe=Astro.url.searchParams.get("attention")==="1"; +const recipeFilterActive=emptyRecipe||missingYield||placeholderSteps||attentionRecipe; + +// Ingredient attention filters +const unused=Astro.url.searchParams.get("unused")==="1"; +const missingCost=Astro.url.searchParams.get("missing_cost")==="1"; +const noPurchase=Astro.url.searchParams.get("no_purchase")==="1" || Astro.url.searchParams.get("no_usda")==="1"; +const attentionIngredient=Astro.url.searchParams.get("attention")==="1"; +const ingredientFilterActive=unused||missingCost||noPurchase||attentionIngredient; + +const recipeAttentionCounts = { + empty: recipes.filter(r => r.item_count === 0).length, + missingYield: recipes.filter(r => !r.yield_quantity || Number(r.yield_quantity) <= 0 || !r.yield_unit_id).length, + placeholder: recipes.filter(r => r.placeholder_count > 0).length +}; + +const ingredientAttentionCounts = { + unused: ingredients.filter(i => i.recipe_count === 0).length, + missingCost: ingredients.filter(i => i.price_count === 0).length, + noPurchase: ingredients.filter(i => i.price_count === 0 || i.nutrition_count === 0).length +}; + +const searchItemTypeCounts = { + recipe: recipes.filter(item => `${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).length, + ingredient: ingredients.filter(item => `${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).length, + book: books.filter(item => `${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).length, + purchase: purchases.filter(item => `${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).length +}; + +function parseItemTags(tagsJson?: string, categoriesJson?: string): string[] { + const set = new Set(); + if (tagsJson) { + try { + const arr = JSON.parse(tagsJson); + if (Array.isArray(arr)) arr.forEach((t: string) => t && set.add(String(t).trim().toLowerCase())); + } catch {} + } + if (categoriesJson) { + try { + const arr = JSON.parse(categoriesJson); + if (Array.isArray(arr)) arr.forEach((c: string) => c && set.add(String(c).trim().toLowerCase())); + } catch {} + } + return Array.from(set); +} + +// Compute tag counts +const activeDataset = type === "ingredient" ? ingredients : recipes; +const tagCountsMap = new Map(); +activeDataset.forEach((item) => { + const tags = parseItemTags(item.tags_json, item.categories_json); + tags.forEach((t) => { + tagCountsMap.set(t, (tagCountsMap.get(t) || 0) + 1); + }); +}); + +const tagOptions = Array.from(tagCountsMap.entries()) + .map(([tag, count]) => ({ + id: tag, + name: titleCase(tag.replace(/_/g, " ")), + count, + checked: selectedTags.includes(tag) + })) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); + +const ingredientOptions = ingredients + .filter(i => (ingredientRecipeCounts.get(i.id) || 0) > 0) + .map(i => ({ + id: i.id, + name: titleCase(i.name), + count: ingredientRecipeCounts.get(i.id) || 0, + checked: selectedIngredients.includes(i.id) + })) + .sort((a, b) => (b.count ?? 0) - (a.count ?? 0) || a.name.localeCompare(b.name)); + +const currentParams = { + type: type ?? "", + q: query, + empty_recipe: Astro.url.searchParams.get("empty_recipe") ?? "", + missing_yield: Astro.url.searchParams.get("missing_yield") ?? "", + placeholder_steps: Astro.url.searchParams.get("placeholder_steps") ?? "", + unused: Astro.url.searchParams.get("unused") ?? "", + missing_cost: Astro.url.searchParams.get("missing_cost") ?? "", + no_purchase: Astro.url.searchParams.get("no_purchase") ?? "", + no_usda: Astro.url.searchParams.get("no_usda") ?? "", + item_type: selectedSearchTypes, + tag: selectedTags, + ingredient: selectedIngredients +}; + +const filteredIngredients=ingredients.filter((ingredient)=>{ + if (ingredientFilterActive) { + const conditions = [ + unused && ingredient.recipe_count === 0, + missingCost && ingredient.price_count === 0, + noPurchase && (ingredient.price_count === 0 || ingredient.nutrition_count === 0) + ]; + const matchesAttention = (unused || missingCost || noPurchase) + ? conditions.some(Boolean) + : (ingredient.recipe_count === 0 || ingredient.price_count === 0 || ingredient.nutrition_count === 0); + if (!matchesAttention) return false; + } + + if (selectedTags.length > 0) { + const tags = parseItemTags(ingredient.tags_json, ingredient.categories_json); + const matchesTags = selectedTags.some(t => tags.includes(t)); + if (!matchesTags) return false; + } + + return true; +}); + +const filteredRecipes=recipes.filter((recipe)=>{ + if (recipeFilterActive) { + const isMissingYield = !recipe.yield_quantity || Number(recipe.yield_quantity) <= 0 || !recipe.yield_unit_id; + const conditions = [ + emptyRecipe && recipe.item_count === 0, + missingYield && isMissingYield, + placeholderSteps && recipe.placeholder_count > 0 + ]; + const matchesAttention = (emptyRecipe || missingYield || placeholderSteps) + ? conditions.some(Boolean) + : (recipe.item_count === 0 || isMissingYield || recipe.placeholder_count > 0); + if (!matchesAttention) return false; + } + + if (selectedTags.length > 0) { + const tags = parseItemTags(recipe.tags_json, recipe.categories_json); + const matchesTags = selectedTags.some(t => tags.includes(t)); + if (!matchesTags) return false; + } + + if (selectedIngredients.length > 0) { + const ingIds = recipeIngredientsMap.get(recipe.id) ?? new Set(); + const matchesIngredients = selectedIngredients.some(id => ingIds.has(id)); + if (!matchesIngredients) return false; + } + + return true; +}); + +const tabs:Array<{type:string;label:string;count:number;kind:"recipe"|"ingredient"|"book"|"purchase"|"inventory";href?:string}>=[ + {type:"recipe",label:"Recipes",count:recipes.length,kind:"recipe"}, + {type:"ingredient",label:"Ingredients",count:ingredients.length,kind:"ingredient"}, + {type:"book",label:"Recipe books",count:books.length,kind:"book"}, + {type:"purchase",label:"Purchase items",count:purchases.length,kind:"purchase"}, + {type:"inventory",label:"Inventory",count:inventoryCounts?.c ?? 0,kind:"inventory",href:"/app/inventory/"}, ]; const allSearchResults=normalizedQuery ? [ - ...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"recipe",label:"Recipe",icon:"▦",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})), - ...ingredients.filter((item)=>`${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"ingredient",label:"Ingredient",icon:"●",name:item.name,detail:`Used in ${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/ingredients/${item.id}/`})), - ...books.filter((item)=>`${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"book",label:"Recipe book",icon:"▣",name:item.name,detail:`${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:"/app/?type=book"})), - ...purchases.filter((item)=>`${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"purchase",label:"Purchase item",icon:"$",name:item.name,detail:item.ingredient_name,href:`/app/ingredients/${item.ingredient_id}/#costs`})), + ...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery) && (selectedTags.length === 0 || parseItemTags(item.tags_json, item.categories_json).some(t => selectedTags.includes(t))) && (selectedIngredients.length === 0 || Array.from(recipeIngredientsMap.get(item.id) ?? []).some((id: string) => selectedIngredients.includes(id)))).map((item)=>({id:item.id,kind:"recipe" as const,label:"Recipe",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})), + ...ingredients.filter((item)=>`${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery) && (selectedTags.length === 0 || parseItemTags(item.tags_json, item.categories_json).some(t => selectedTags.includes(t)))).map((item)=>({id:item.id,kind:"ingredient" as const,label:"Ingredient",name:titleCase(item.name),detail:`Used in ${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/ingredients/${item.id}/`})), + ...books.filter((item)=>`${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery) && selectedTags.length === 0).map((item)=>({id:item.id,kind:"book" as const,label:"Recipe book",name:item.name,detail:`${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/recipe-books/${item.id}/`})), + ...purchases.filter((item)=>`${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery) && selectedTags.length === 0).map((item)=>({id:item.id,kind:"purchase" as const,label:"Purchase item",name:item.name,detail:titleCase(item.ingredient_name),href:`/app/ingredients/${item.ingredient_id}/#costs`})), ].sort((a,b)=>a.name.localeCompare(b.name)):[]; const searchResults=filteringSearchTypes?allSearchResults.filter((result)=>selectedSearchTypes.includes(result.kind)):allSearchResults; -const ingredientRows=filteredIngredients.map(item=>({id:item.id,name:item.name,href:`/app/ingredients/${item.id}/`,kind:"ingredient" as const,icon:"●"})); -const recipeRows=filteredRecipes.map(item=>({id:item.id,name:item.title,href:`/app/recipes/${item.id}/`,kind:"recipe" as const,icon:"▦"})); -const bookRows=books.map(item=>({id:item.id,name:item.name,href:`/app/recipe-books/${item.id}/`,kind:"book" as const,icon:"▣"})); -const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/ingredients/${item.ingredient_id}/#costs`,kind:"purchase" as const,icon:"$"})); +const searchRows=searchResults.map(({id,kind,name,href,label,detail})=>({id,name,href,kind,detail:`${label} · ${detail}`})); +const ingredientRows=filteredIngredients.map(item=>({id:item.id,name:titleCase(item.name),href:`/app/ingredients/${item.id}/`,kind:"ingredient" as const})); +const recipeRows=filteredRecipes.map(item=>({id:item.id,name:item.title,href:`/app/recipes/${item.id}/`,kind:"recipe" as const})); +const bookRows=books.map(item=>({id:item.id,name:item.name,href:`/app/recipe-books/${item.id}/`,kind:"book" as const})); +const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/ingredients/${item.ingredient_id}/#costs`,kind:"purchase" as const})); --- -
-
+
-
- ☷   Item type{filteringSearchTypes?` · ${selectedSearchTypes.length}`:""} -
- - - - -
{filteringSearchTypes&&All types}
-
-
{!readOnlyMode&&
- New + New
} +
- - {query?

{searchResults.length} {searchResults.length===1?"result":"results"} for “{query}”{filteringSearchTypes&&` · ${selectedSearchTypes.length} item ${selectedSearchTypes.length===1?"type":"types"}`}

{searchResults.length?:
No items of the selected types match this search.
}
:<> +
+
+ {!readOnlyMode&&} + +
+ {query?

{searchResults.length} {searchResults.length===1?"result":"results"} for “{query}”{filteringSearchTypes&&` · ${selectedSearchTypes.length} item ${selectedSearchTypes.length===1?"type":"types"}`}

{searchRows.length?:
No items of the selected types match this search.
}
:<> {type==="ingredient"&&} {type==="recipe"&&} {type==="book"&&} {type==="purchase"&&} + {!type&&

Select a workspace or search to browse recipes, ingredients, and purchase items.

} } -
+
+
diff --git a/src/application/pages/app/ingredients/[id].astro b/src/application/pages/app/ingredients/[id].astro index 125e7f1..03ee7ac 100644 --- a/src/application/pages/app/ingredients/[id].astro +++ b/src/application/pages/app/ingredients/[id].astro @@ -4,9 +4,10 @@ import BaseLayout from "../../../../layouts/BaseLayout.astro"; import { openDatabase, refreshSiteProjection } from "../../../../lib/database"; import { readOnlyMode } from "../../../../lib/runtime"; import { bestUsdaPortions, fetchUsdaFood, usdaNutrition } from "../../../../lib/usda"; -import { number } from "../../../../lib/format"; +import { number, titleCase } from "../../../../lib/format"; import PurchaseItemForm from "../../../../components/PurchaseItemForm.astro"; import DetailUtility from "../../../../components/DetailUtility.astro"; +import { getTabIconHtml, type TabIconKey } from "../../../../lib/icons"; const id = Astro.params.id!; const editing = !readOnlyMode && Astro.url.searchParams.get("edit") === "1"; const database = openDatabase({ readOnly: false }); @@ -127,28 +128,326 @@ const prepDisplay = prep.map((row) => { }; return {...row,weight:showMeasure("mass"),volume:showMeasure("volume"),each:showMeasure("count")}; }); +const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey); database.close(); --- - -
- -

← Ingredients

{editing?:

{ingredient.name}

}
{!readOnlyMode&&
{editing?:✎ Edit}{editing&&
}
}
- {message&&
{message}
}{error&&
{error}
} -
-
- {editing&&
} -

Prep Actions

Any action taken on an ingredient that changes its yield or its weight-to-volume equivalency from the original raw state.

{editing?<>
{prepDisplay.map(x=>{[x.weight,x.volume,x.each].map(value=>)})}
Prep ActionYield %WeightVolumeEach
%

Use 100% for no change, 80% for trim or cooking loss, or 250% when cooking produces 2.5 times the original weight. Select Weight, Volume, or Each to define its equivalency.

:prep.length?{prepDisplay.map(x=>)}
Prep ActionYield %WeightVolumeEach
{x.name}{x.notes&&{x.notes}}{number(x.yield_factor*100)}%{x.weight}{x.volume}{x.each}
:

No prep actions defined.

}
-

Additional Details

{editing?
:<>
Recipes On {usedIn.length}{usedIn.length?:

This ingredient is not used by a recipe.

}
{ingredientTags.length>0&&
Tags{ingredientTags.map(tag=>{tag})}
}{ingredient.description&&
Description

{ingredient.description}

}
Ingredient aliases {aliases.length}{aliases.length?
    {aliases.map(x=>
  • {x.name}
  • )}
:

No aliases.

}
}
+ +
+ +
+ {editing ? ( + + ) : !readOnlyMode && ( + Edit + )} + {editing && ( +
+ +
+
+ + +
+
+
+ )} +
+
+ +
+
+
+

← Ingredients

+ {editing ? ( + + ) : ( +

{titleCase(ingredient.name)}

+ )} +
+
+ + {message &&
{message}
} + {error &&
{error}
} + +
+ {editing && ( +
+ + + +
+ )} + +
+

Prep Actions

+

Any action taken on an ingredient that changes its yield or its weight-to-volume equivalency from the original raw state.

+ {editing ? ( + <> + +
+ + + + + + + + + + + + + {prepDisplay.map(x => ( + + + + {[x.weight, x.volume, x.each].map(value => ( + + ))} + + + ))} + +
Prep ActionYield %WeightVolumeEach
+ + + + + + % + + + + + +
+
+

Use 100% for no change, 80% for trim or cooking loss, or 250% when cooking produces 2.5 times the original weight. Select Weight, Volume, or Each to define its equivalency.

+ + ) : prep.length ? ( + + + + + + + + + + + + {prepDisplay.map(x => ( + + + + + + + + ))} + +
Prep ActionYield %WeightVolumeEach
{x.name}{x.notes && {x.notes}}{number(x.yield_factor * 100)}%{x.weight}{x.volume}{x.each}
+ ) : ( +

This ingredient currently has no prep actions. Edit ingredient to add prep actions.

+ )} +
+ +
+

Additional Details

+ {editing ? ( +
+ + + +
+ ) : ( + <> +
+ Recipes On {usedIn.length} + {usedIn.length ? :

This ingredient is not used by a recipe.

} +
+ {ingredientTags.length > 0 && ( +
+ Tags + {ingredientTags.map(tag => {tag})} +
+ )} + {ingredient.description && ( +
+ Description +

{ingredient.description}

+
+ )} +
+ Ingredient aliases {aliases.length} + {aliases.length ?
    {aliases.map(x =>
  • {x.name}
  • )}
:

No aliases.

} +
+ + )} +
- +
+ +
+
+ + + + +
+ +
+
+

Ingredient Cost

+

Purchase packages, usable yield, and current prices.

+ {editing ? ( +
+
+ + Add purchase item + +
+ {purchases.map(x => ( +
+
+

{x.name}

+
+ + +
+
+ +
+ ))} +
+ ) : purchases.length ? ( + purchases.map(x => ( +
+ + {x.name} + {x.supplier_id || "No supplier"} · {x.status} + + + {x.latest_price != null ? `${x.latest_currency} ${Number(x.latest_price).toFixed(2)}` : "No price"} + {number(x.package_quantity)} {unitById.get(x.package_unit_id)?.symbol ?? x.package_unit_id}{x.units_per_case > 1 ? ` × ${x.units_per_case}` : ""} + +
+ )) + ) : ( +

No purchase cost has been entered.

+ )} +
+ +
+

U of M Equivalency

+

XX Weight = XX Volume = XX Each
You can define a custom ingredient conversion from weight to volume and to a pc/each of the ingredient.

+
+ {densities.map(x => ( +

+ {number(x.volume_quantity)} {unitById.get(x.volume_unit_id)?.symbol ?? x.volume_unit_id} + = + {number(x.mass_quantity)} {unitById.get(x.mass_unit_id)?.symbol ?? x.mass_unit_id} + {x.state || "Density"} · sourced density +

+ ))} + {conversionRows.map(x => ( +
+

+ {number(x.from_quantity)} {unitById.get(x.from_unit_id)?.symbol ?? x.from_unit_id} + = + {number(x.to_quantity)} {unitById.get(x.to_unit_id)?.symbol ?? x.to_unit_id} + {x.state || "Conversion"} · {x.isManual ? "manual" : x.source.title || "sourced"} +

+ {editing && x.isManual && ( +
+ +
+ + +
+
+ )} +
+ ))} +
+ {!densities.length && !conversions.length &&

No equivalencies have been defined.

} + {editing && ( +
+ + Add Equivalency +
+ + + + = + + + + + +
+
+ )} +
+ +
+

Nutrition

+

Nutrition values are sourced from the ingredient's mapped USDA FoodData Central record.

+ {editing && ( +
+ + + {usdaMapping?.sourceUrl && Open USDA ↗} +
+ )} + {usdaMapping ? ( +
+
+
+ {usdaMapping.source.title} + {usdaMapping.status} · USDA FoodData Central +
+ {usdaMapping.sourceUrl && View source ↗} +
+ {usdaMapping.nutrients.length ? ( +
+ {usdaMapping.nutrients.map((entry: [string, number]) => { + const [key, value] = entry; + const [label, unit] = nutrientLabels[key] ?? [key.replaceAll("_", " "), ""]; + return
{label}
{number(value)} {unit}
; + })} +
+ ) : ( +

No reviewed nutrient values stored.

+ )} +
+ {usdaMapping.source.external_id && Record {usdaMapping.source.external_id}} + {usdaMapping.source.retrieved_at && Retrieved {usdaMapping.source.retrieved_at}} +
+
+ ) : ( +

No USDA record mapped.{editing && " Enter an FDC ID above."}

+ )} +
+
-{editing&&} - + window.addEventListener('beforeunload',event=>{if(ingredientDirty&&!ingredientSubmitting)event.preventDefault()}); + +} +
diff --git a/src/application/pages/app/inventory/[id].astro b/src/application/pages/app/inventory/[id].astro new file mode 100644 index 0000000..7d810bc --- /dev/null +++ b/src/application/pages/app/inventory/[id].astro @@ -0,0 +1,657 @@ +--- +export const prerender = false; +import BaseLayout from "../../../../layouts/BaseLayout.astro"; +import DetailUtility from "../../../../components/DetailUtility.astro"; +import { openDatabase } from "../../../../lib/database"; +import { + getInventoryCountDetail, + saveInventoryCountItems, +} from "../../../../lib/repository/inventory-repository"; +import { readOnlyMode } from "../../../../lib/runtime"; + +const id = Astro.params.id!; +const database = openDatabase({ readOnly: false }); +if (!database) return Astro.redirect("/app/?error=database-missing", 303); + +let error = ""; +if (Astro.request.method === "POST") { + try { + const form = await Astro.request.formData(); + const intent = String(form.get("intent") ?? "save"); + + if (intent === "save" || intent === "complete" || intent === "reopen") { + const ingredientIds = form.getAll("ingredient_id").map(String); + const locationIds = form.getAll("location_id").map((v) => String(v) || null); + const quantities = form.getAll("quantity").map((v) => Number(v) || 0); + const unitIds = form.getAll("unit_id").map(String); + + const items = ingredientIds.map((ingId, idx) => ({ + ingredient_id: ingId, + location_id: locationIds[idx] ?? null, + quantity: quantities[idx] ?? 0, + unit_id: unitIds[idx] ?? "gram", + })); + + const newStatus = + intent === "complete" + ? "completed" + : intent === "reopen" + ? "open" + : undefined; + + saveInventoryCountItems(database, id, items, newStatus); + database.close(); + return Astro.redirect(`/app/inventory/${id}/`, 303); + } + } catch (cause) { + error = cause instanceof Error ? cause.message : "Unable to save count sheet."; + } +} + +const count = getInventoryCountDetail(database, id); +if (!count) { + database.close(); + return new Response("Inventory count session not found", { status: 404 }); +} + +// Fetch all available units and active ingredients for the quick-add selector +const allUnits = database.prepare("SELECT id, name, symbol FROM units ORDER BY name").all() as Array<{ id: string; name: string; symbol: string }>; +const activeIngredients = database.prepare("SELECT id, name FROM ingredients WHERE status='active' AND deleted_at IS NULL ORDER BY name").all() as Array<{ id: string; name: string }>; + +database.close(); + +const selectedLocation = Astro.url.searchParams.get("loc") ?? "all"; +const filteredItems = count.items.filter((item) => { + if (selectedLocation === "all") return true; + if (selectedLocation === "unassigned") return !item.location_id; + return item.location_id === selectedLocation; +}); + +const isCompleted = count.status === "completed"; +--- + + +
+ + +
+ {error &&
{error}
} + +
+
+ +
+

{count.title}

+ + {isCompleted ? "Completed" : "Open Draft"} + +
+
+ Count Date: {count.counted_at} + {count.notes && · {count.notes}} +
+
+ +
+
+ Total On-Hand Valuation + + ${count.total_value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + +
+ + {!readOnlyMode && ( +
+ {isCompleted ? ( +
+ + +
+ ) : ( + <> + + + + )} +
+ )} +
+
+ + + + + +
+ + +
+ + + + + + + + + + {!isCompleted && !readOnlyMode && } + + + + {filteredItems.map((item) => ( + + + + + + + + {!isCompleted && !readOnlyMode && ( + + )} + + ))} + +
IngredientLocationQuantityUnitUnit CostExtended Value
+ + {item.ingredient_name} + + {isCompleted || readOnlyMode ? ( + {item.location_name ?? "Unassigned"} + ) : ( + + )} + + {isCompleted || readOnlyMode ? ( + {item.quantity} + ) : ( + + )} + + {isCompleted || readOnlyMode ? ( + {item.unit_symbol} + ) : ( + + )} + + {item.unit_cost != null ? ( + `$${item.unit_cost.toFixed(4)}` + ) : ( + + )} + + ${(item.extended_cost ?? 0).toFixed(2)} + + +
+ + {filteredItems.length === 0 && ( +
+

No items assigned to this storage location in this count session.

+
+ )} +
+ + {!isCompleted && !readOnlyMode && ( +
+ + +
+ )} +
+
+
+
+ + + + diff --git a/src/application/pages/app/inventory/index.astro b/src/application/pages/app/inventory/index.astro new file mode 100644 index 0000000..5848532 --- /dev/null +++ b/src/application/pages/app/inventory/index.astro @@ -0,0 +1,519 @@ +--- +export const prerender = false; +import BaseLayout from "../../../../layouts/BaseLayout.astro"; +import DetailUtility from "../../../../components/DetailUtility.astro"; +import { openDatabase } from "../../../../lib/database"; +import { + createInventoryCount, + getInventoryCounts, + getInventoryLocations, +} from "../../../../lib/repository/inventory-repository"; +import { readOnlyMode } from "../../../../lib/runtime"; + +const database = openDatabase({ readOnly: false }); +if (!database) return Astro.redirect("/app/?error=database-missing", 303); + +let error = ""; +if (Astro.request.method === "POST") { + try { + const form = await Astro.request.formData(); + const intent = String(form.get("intent") ?? "create"); + + if (intent === "create") { + const title = String(form.get("title") ?? "").trim(); + const counted_at = String(form.get("counted_at") ?? "").trim(); + const notes = String(form.get("notes") ?? "").trim(); + const prepopulate = form.get("prepopulate") === "1"; + + if (!title) throw new Error("Title is required."); + if (!counted_at) throw new Error("Count date is required."); + + const createdId = createInventoryCount(database, { + title, + counted_at, + notes, + prepopulate, + }); + + database.close(); + return Astro.redirect(`/app/inventory/${createdId}/`, 303); + } + } catch (cause) { + error = cause instanceof Error ? cause.message : "Unable to create inventory count."; + } +} + +const counts = getInventoryCounts(database); +const locations = getInventoryLocations(database); +const totalInventoryValue = counts + .filter((c) => c.status === "completed") + .reduce((sum, c) => sum + c.total_value, 0); + +database.close(); +--- + + +
+ + +
+ {error &&
{error}
} + +
+
+ +

Inventory Count Sessions

+

+ Sheet-to-shelf on-hand stock counts, valuations, and storage locations. +

+
+ + {!readOnlyMode && ( +
+ +
+ )} +
+ +
+
+ Total Count Sessions + {counts.length} +
+
+ Storage Locations + {locations.length} +
+
+ Total Completed Valuation + + ${totalInventoryValue.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + +
+
+ + {counts.length > 0 ? ( + + ) : ( +
+
+ + + +
+

No inventory counts recorded yet

+

Create your first location-specific count session to begin tracking on-hand stock and valuations.

+ {!readOnlyMode && ( + + )} +
+ )} + + + +
+ +
+

New Inventory Count Session

+ +
+ +
+ + + + + + + +
+ + +
+
+
+
+
+ + diff --git a/src/application/pages/app/recipe-books/[id].astro b/src/application/pages/app/recipe-books/[id].astro index ab46e26..f5ba9fc 100644 --- a/src/application/pages/app/recipe-books/[id].astro +++ b/src/application/pages/app/recipe-books/[id].astro @@ -1,19 +1,236 @@ --- -export const prerender=false; +export const prerender = false; import BaseLayout from "../../../../layouts/BaseLayout.astro"; import DetailUtility from "../../../../components/DetailUtility.astro"; -import {readOnlyMode} from "../../../../lib/runtime"; -import {openDatabase,refreshSiteProjection} from "../../../../lib/database"; -const id=Astro.params.id!,editing=!readOnlyMode&&Astro.url.searchParams.get("edit")==="1"; -const database=openDatabase({readOnly:false});if(!database)return Astro.redirect("/app/?error=database-missing",303); -let error=""; -if(Astro.request.method==="POST")try{const form=await Astro.request.formData(),intent=String(form.get("intent")); - if(intent==="details"){const name=String(form.get("name")??"").trim();if(!name)throw new Error("Name is required.");database.prepare("UPDATE collections SET name=?,description=? WHERE id=?").run(name,String(form.get("description")??"").trim()||null,id);} - if(intent==="membership"){const selected=new Set(form.getAll("recipe_id").map(String));database.exec("BEGIN IMMEDIATE");try{database.prepare("DELETE FROM collection_recipes WHERE collection_id=?").run(id);const insert=database.prepare("INSERT INTO collection_recipes(collection_id,recipe_id,position) VALUES (?,?,?)");[...selected].forEach((recipeId,index)=>insert.run(id,recipeId,index+1));database.exec("COMMIT");}catch(cause){database.exec("ROLLBACK");throw cause;}} - refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipe-books/${id}/`,303); -}catch(cause){error=cause instanceof Error?cause.message:"Unable to save recipe book.";} -const book=database.prepare("SELECT * FROM collections WHERE id=? AND deleted_at IS NULL").get(id) as any;if(!book){database.close();return new Response("Recipe book not found",{status:404});} -const recipes=database.prepare("SELECT r.id,r.title,cr.position,cr.recipe_id IS NOT NULL included FROM recipes r LEFT JOIN collection_recipes cr ON cr.recipe_id=r.id AND cr.collection_id=? WHERE r.deleted_at IS NULL ORDER BY coalesce(cr.position,999999),r.title").all(id) as any[]; +import { readOnlyMode } from "../../../../lib/runtime"; +import { openDatabase, refreshSiteProjection } from "../../../../lib/database"; +import { TYPE_ICONS, TYPE_ICON_TRANSFORMS } from "../../../../lib/icons"; + +const id = Astro.params.id!; +const editing = !readOnlyMode && Astro.url.searchParams.get("edit") === "1"; +const database = openDatabase({ readOnly: false }); +if (!database) return Astro.redirect("/app/?error=database-missing", 303); + +let error = ""; +if (Astro.request.method === "POST") { + try { + const form = await Astro.request.formData(); + const intent = String(form.get("intent") ?? "save"); + + if (intent === "save" || intent === "details") { + const name = String(form.get("name") ?? "").trim(); + const description = String(form.get("description") ?? "").trim() || null; + if (!name) throw new Error("Name is required."); + database.prepare("UPDATE collections SET name=?, description=? WHERE id=?").run(name, description, id); + } + + if (intent === "save" || intent === "membership") { + const selected = new Set(form.getAll("recipe_id").map(String)); + database.exec("BEGIN IMMEDIATE"); + try { + database.prepare("DELETE FROM collection_recipes WHERE collection_id=?").run(id); + const insert = database.prepare("INSERT INTO collection_recipes(collection_id, recipe_id, position) VALUES (?, ?, ?)"); + [...selected].forEach((recipeId, index) => insert.run(id, recipeId, index + 1)); + database.exec("COMMIT"); + } catch (cause) { + database.exec("ROLLBACK"); + throw cause; + } + } + + refreshSiteProjection(database); + database.close(); + return Astro.redirect(`/app/recipe-books/${id}/`, 303); + } catch (cause) { + error = cause instanceof Error ? cause.message : "Unable to save recipe book."; + } +} + +const book = database.prepare("SELECT * FROM collections WHERE id=? AND deleted_at IS NULL").get(id) as any; +if (!book) { + database.close(); + return new Response("Recipe book not found", { status: 404 }); +} + +const recipes = database.prepare(` + SELECT r.id, r.title, cr.position, (cr.recipe_id IS NOT NULL) AS included + FROM recipes r + LEFT JOIN collection_recipes cr ON cr.recipe_id = r.id AND cr.collection_id = ? + WHERE r.deleted_at IS NULL + ORDER BY coalesce(cr.position, 999999), r.title +`).all(id) as any[]; + +const includedRecipes = recipes.filter((r) => r.included); database.close(); --- -

← Recipe books

{book.name}

{recipes.filter(x=>x.included).length} recipes

{!readOnlyMode&&{editing?"✓ Done":"✎ Edit"}}
{error&&

{error}

}
{editing&&
}

Recipes

{editing?"Choose the recipes included in this book.":book.description}

{editing&&}
{recipes.filter(recipe=>editing||recipe.included).map(recipe=>)}
+ + +
+ + +
+ {error &&
{error}
} + +
+
+ +

{book.name}

+
+ + {includedRecipes.length} {includedRecipes.length === 1 ? "recipe" : "recipes"} + + {book.description && {book.description}} +
+
+ + {!readOnlyMode && ( + + )} +
+ + {editing ? ( +
+ + +
+

Book Details

+
+ + +
+
+ +
+
+
+

Select Recipes

+

+ Choose the recipes included in this book ({includedRecipes.length} currently selected) +

+
+
+ +
+ {recipes.map((recipe) => ( + + ))} +
+
+ + +
+ ) : ( +
+ {includedRecipes.length > 0 ? ( +
+
+ Included Recipes + {includedRecipes.length} {includedRecipes.length === 1 ? "recipe" : "recipes"} +
+
+ {includedRecipes.map((recipe) => ( + + ))} +
+
+ ) : ( +
+ +

No recipes in this book yet

+

Organize your recipes by adding them to this book.

+ {!readOnlyMode && ( + + ✎ Add recipes + + )} +
+ )} +
+ )} +
+
+
diff --git a/src/application/pages/app/recipe-books/new.astro b/src/application/pages/app/recipe-books/new.astro index e9b3d84..f6d3ce9 100644 --- a/src/application/pages/app/recipe-books/new.astro +++ b/src/application/pages/app/recipe-books/new.astro @@ -1,8 +1,93 @@ --- -export const prerender=false; +export const prerender = false; import BaseLayout from "../../../../layouts/BaseLayout.astro"; -import { openDatabase,refreshSiteProjection } from "../../../../lib/database"; -let error:string|undefined; -if(Astro.request.method==="POST"){const database=openDatabase({readOnly:false});if(!database)return Astro.redirect("/app/?error=database-missing",303);try{const form=await Astro.request.formData();const name=String(form.get("name")??"").trim();const description=String(form.get("description")??"").trim()||null;if(!name)throw new Error("Name is required.");const base=name.toLocaleLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g,"_").replace(/^_|_$/g,"")||"recipe_book";let id=base,suffix=2;while(database.prepare("SELECT 1 FROM collections WHERE id=?").get(id))id=`${base}_${suffix++}`;database.prepare("INSERT INTO collections(id,name,description,source_json) VALUES (?,?,?,'{}')").run(id,name,description);refreshSiteProjection(database);database.close();return Astro.redirect("/app/?type=book",303);}catch(cause){error=cause instanceof Error?cause.message:"Unable to create recipe book.";database.close();}} +import DetailUtility from "../../../../components/DetailUtility.astro"; +import { openDatabase, refreshSiteProjection } from "../../../../lib/database"; + +let error: string | undefined; + +if (Astro.request.method === "POST") { + const database = openDatabase({ readOnly: false }); + if (!database) return Astro.redirect("/app/?error=database-missing", 303); + + try { + const form = await Astro.request.formData(); + const name = String(form.get("name") ?? "").trim(); + const description = String(form.get("description") ?? "").trim() || null; + if (!name) throw new Error("Name is required."); + + const base = name.toLocaleLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "") || "recipe_book"; + let id = base; + let suffix = 2; + while (database.prepare("SELECT 1 FROM collections WHERE id=?").get(id)) { + id = `${base}_${suffix++}`; + } + + database.prepare("INSERT INTO collections(id, name, description, source_json) VALUES (?, ?, ?, '{}')").run(id, name, description); + refreshSiteProjection(database); + database.close(); + return Astro.redirect(`/app/recipe-books/${id}/`, 303); + } catch (cause) { + error = cause instanceof Error ? cause.message : "Unable to create recipe book."; + database.close(); + } +} --- -

Recipe books

New recipe book

Create a collection for organizing recipes.

Recipe book details{error&&
{error}
}
Cancel
+ + +
+ + +
+ {error &&
{error}
} + +
+
+ +

New recipe book

+

Create a collection for organizing recipes.

+
+
+ +
+
+

Recipe Book Details

+
+ + +
+
+ +
+ + Cancel +
+
+
+
+
diff --git a/src/application/pages/app/recipes/[id].astro b/src/application/pages/app/recipes/[id].astro index 82fdab0..9d02034 100644 --- a/src/application/pages/app/recipes/[id].astro +++ b/src/application/pages/app/recipes/[id].astro @@ -1,16 +1,24 @@ --- export const prerender = false; import BaseLayout from "../../../../layouts/BaseLayout.astro"; -import { databaseProjection, duplicateRecipe, editableRecipe, openDatabase, refreshSiteProjection, saveRecipeMetadata } from "../../../../lib/database"; -import { recipeStructure } from "../../../../lib/database"; +import { + editableRecipe, + openDatabase, + refreshSiteProjection, + saveRecipeMetadata, + duplicateRecipe, + recipeStructure, +} from "../../../../lib/database"; +import { getRecipeCalculationContext } from "../../../../lib/repository"; import RecipeStructureEditor from "../../../../components/RecipeStructureEditor"; import RecipeCalculator, { LiveCostValues, LiveNutritionValues } from "../../../../components/RecipeCalculator"; import { calculateNutrition } from "../../../../lib/nutrition"; import { calculateCost } from "../../../../lib/costing"; -import type { Ingredient, PrepAction, PurchaseItem, Recipe, SourceMapping, Unit } from "../../../../lib/types"; +import type { Ingredient, Recipe } from "../../../../lib/types"; import DetailUtility from "../../../../components/DetailUtility.astro"; import { convertWithIngredientMeasures } from "../../../../lib/measurement"; import { readOnlyMode } from "../../../../lib/runtime"; +import { getTabIconHtml, type TabIconKey } from "../../../../lib/icons"; const id = Astro.params.id!; const editing = !readOnlyMode && Astro.url.searchParams.get("edit") === "1"; @@ -21,22 +29,18 @@ let error: string | undefined; if (Astro.request.method === "POST") { try { const form = await Astro.request.formData(); - if(form.get("intent")==="media"){ - const url=String(form.get("url")??"").trim(),mediaType=String(form.get("media_type")??"image"),stepId=String(form.get("step_id")??"").trim()||null;if(!url)throw new Error("Media URL is required.");if(!["image","video"].includes(mediaType))throw new Error("Invalid media type."); - const position=(database.prepare("SELECT coalesce(max(position),0)+1 position FROM recipe_media WHERE recipe_id=?").get(id) as any).position;database.prepare("INSERT INTO recipe_media(recipe_id,id,step_id,media_type,url,caption,position) VALUES (?,?,?,?,?,?,?)").run(id,`media_${Date.now()}`,stepId,mediaType,url,String(form.get("caption")??"").trim()||null,position);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1#additional`,303); - } if(form.get("intent")==="additional"){ const row=database.prepare("SELECT source_json FROM recipes WHERE id=?").get(id) as any,source=JSON.parse(row.source_json??"{}"); const shelfQuantity=Number(form.get("shelf_quantity")),shelfUnit=String(form.get("shelf_unit")??"").trim(); if(shelfQuantity>0&&shelfUnit)source.shelf_life={duration:{quantity:shelfQuantity,unit_id:shelfUnit},storage_condition:String(form.get("storage_condition")??"").trim()||undefined};else delete source.shelf_life; const notes=String(form.get("notes")??"").split("\n").map(value=>value.trim()).filter(Boolean); - database.prepare("UPDATE recipes SET station=?,cover_media_url=?,notes_json=?,source_json=? WHERE id=?").run(String(form.get("station")??"").trim()||null,String(form.get("cover_media_url")??"").trim()||null,JSON.stringify(notes),JSON.stringify(source),id); + database.prepare("UPDATE recipes SET station=?,notes_json=?,source_json=? WHERE id=?").run(String(form.get("station")??"").trim()||null,JSON.stringify(notes),JSON.stringify(source),id); refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1#additional`,303); } if (form.get("intent") === "duplicate") { - const redirectTo = `/app/recipes/${duplicateRecipe(database, id)}/?edit=1`; + const duplicatedId = duplicateRecipe(database, id); database.close(); - return Astro.redirect(redirectTo, 303); + return Astro.redirect(`/app/recipes/${duplicatedId}/?edit=1`, 303); } if(form.get("intent")==="auto_yield"){ const current=editableRecipe(database,id);if(!current)throw new Error("Recipe not found."); @@ -47,34 +51,53 @@ if (Astro.request.method === "POST") { if(original?.quantity>0&&original?.unit_id)saveRecipeMetadata(database,id,current.save_version,{title:current.title,summary:current.summary,categories_json:current.categories_json,tags_json:current.tags_json,yield_quantity:original.quantity,yield_unit_id:original.unit_id,yield_servings:current.yield_servings,yield_basis:original.basis??null}); delete source.auto_yield_original;database.prepare("UPDATE recipes SET auto_yield=0,source_json=? WHERE id=?").run(JSON.stringify(source),id);refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1&saved=1`,303); } - const projectionNow=databaseProjection(database) as {recipes:Recipe[];ingredients:Ingredient[];units:Unit[];sourceMappings:SourceMapping[]}; - const recipesNow=new Map(projectionNow.recipes.map(value=>[value.id,value])),ingredientsNow=new Map(projectionNow.ingredients.map(value=>[value.id,value])),unitsNow=new Map(projectionNow.units.map(value=>[value.id,value])); - const autoRecipe=recipesNow.get(id)!;let calculatedYieldWeightG=0;const conversionFailures:string[]=[]; - for(const item of autoRecipe.components.flatMap(component=>component.items).filter(item=>!item.optional)){ - try{if("ingredient_id" in item.reference){const ingredient=ingredientsNow.get(item.reference.ingredient_id);if(!ingredient)throw new Error("ingredient not found");calculatedYieldWeightG+=convertWithIngredientMeasures(item.amount,"gram",ingredient,unitsNow).quantity;}else{const child=recipesNow.get(item.reference.recipe_id);if(!child)throw new Error("sub-recipe not found");calculatedYieldWeightG+=convertWithIngredientMeasures(item.amount,"gram",{schema_version:2,id:child.id,name:child.title,status:"active",categories:[],measure_conversions:child.measure_conversions},unitsNow).quantity;}}catch{conversionFailures.push("ingredient_id" in item.reference?ingredientsNow.get(item.reference.ingredient_id)?.name??item.reference.ingredient_id:recipesNow.get(item.reference.recipe_id)?.title??item.reference.recipe_id);} + const calcContextNow = getRecipeCalculationContext(database, id); + if (!calcContextNow) throw new Error("Recipe not found."); + const { recipes: recipesNow, ingredients: ingredientsNow, units: unitsNow, domainRecipe: autoRecipe } = calcContextNow; + let calculatedYieldWeightG = 0; + const conversionFailures: string[] = []; + for (const item of autoRecipe.components.flatMap(component => component.items).filter(item => !item.optional)) { + try { + if ("ingredient_id" in item.reference) { + const ingredient = ingredientsNow.get(item.reference.ingredient_id); + if (!ingredient) throw new Error("ingredient not found"); + calculatedYieldWeightG += convertWithIngredientMeasures(item.amount, "gram", ingredient, unitsNow).quantity; + } else { + const child = recipesNow.get(item.reference.recipe_id); + if (!child) throw new Error("sub-recipe not found"); + calculatedYieldWeightG += convertWithIngredientMeasures(item.amount, "gram", { schema_version: 2, id: child.id, name: child.title, status: "active", categories: [], measure_conversions: child.measure_conversions }, unitsNow).quantity; + } + } catch { + conversionFailures.push("ingredient_id" in item.reference ? ingredientsNow.get(item.reference.ingredient_id)?.name ?? item.reference.ingredient_id : recipesNow.get(item.reference.recipe_id)?.title ?? item.reference.recipe_id); + } } - if(conversionFailures.length)throw new Error(`Auto calculate total yield needs a weight equivalency for: ${conversionFailures.join(", ")}.`); - if(!(calculatedYieldWeightG>0))throw new Error("No convertible ingredient weights are available for automatic yield."); - source.auto_yield_original={quantity:current.yield_quantity,unit_id:current.yield_unit_id,basis:current.yield_basis}; - saveRecipeMetadata(database,id,current.save_version,{title:current.title,summary:current.summary,categories_json:current.categories_json,tags_json:current.tags_json,yield_quantity:calculatedYieldWeightG,yield_unit_id:"gram",yield_servings:current.yield_servings,yield_basis:"theoretical"}); - database.prepare("UPDATE recipes SET auto_yield=1,source_json=? WHERE id=?").run(JSON.stringify(source),id);refreshSiteProjection(database); - database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1&saved=1#nutrition`,303); + if (conversionFailures.length) throw new Error(`Auto calculate total yield needs a weight equivalency for: ${conversionFailures.join(", ")}.`); + if (!(calculatedYieldWeightG > 0)) throw new Error("No convertible ingredient weights are available for automatic yield."); + source.auto_yield_original = { quantity: current.yield_quantity, unit_id: current.yield_unit_id, basis: current.yield_basis }; + saveRecipeMetadata(database, id, current.save_version, { title: current.title, summary: current.summary, categories_json: current.categories_json, tags_json: current.tags_json, yield_quantity: calculatedYieldWeightG, yield_unit_id: "gram", yield_servings: current.yield_servings, yield_basis: "theoretical" }); + database.prepare("UPDATE recipes SET auto_yield=1,source_json=? WHERE id=?").run(JSON.stringify(source), id); + refreshSiteProjection(database); + database.close(); + return Astro.redirect(`/app/recipes/${id}/?edit=1&saved=1#nutrition`, 303); } if (form.get("intent") === "nutrition_servings") { - const current=editableRecipe(database,id);if(!current)throw new Error("Recipe not found."); - const yieldServings=Number(form.get("yield_servings")); - if(!Number.isFinite(yieldServings)||yieldServings<=0)throw new Error("Servings must be greater than zero."); - const expectedVersion=Number(form.get("save_version")); - saveRecipeMetadata(database,id,expectedVersion,{title:current.title,summary:current.summary,categories_json:current.categories_json,tags_json:current.tags_json,yield_quantity:current.yield_quantity,yield_unit_id:current.yield_unit_id,yield_servings:yieldServings,yield_basis:current.yield_basis}); - database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1#nutrition`,303); + const current = editableRecipe(database, id); + if (!current) throw new Error("Recipe not found."); + const yieldServings = Number(form.get("yield_servings")); + if (!Number.isFinite(yieldServings) || yieldServings <= 0) throw new Error("Servings must be greater than zero."); + const expectedVersion = Number(form.get("save_version")); + saveRecipeMetadata(database, id, expectedVersion, { title: current.title, summary: current.summary, categories_json: current.categories_json, tags_json: current.tags_json, yield_quantity: current.yield_quantity, yield_unit_id: current.yield_unit_id, yield_servings: yieldServings, yield_basis: current.yield_basis }); + database.close(); + return Astro.redirect(`/app/recipes/${id}/?edit=1#nutrition`, 303); } if (form.get("intent") === "conversion") { - const fromQuantity=Number(form.get("from_quantity")),toQuantity=Number(form.get("to_quantity")); - const fromUnitId=String(form.get("from_unit_id")??""),toUnitId=String(form.get("to_unit_id")??""); - if(!(fromQuantity>0&&toQuantity>0)) throw new Error("Equivalency quantities must be positive."); - if(!database.prepare("SELECT 1 FROM units WHERE id IN (?,?) HAVING count(*)=2").get(fromUnitId,toUnitId)) throw new Error("Unknown equivalency unit."); - database.prepare("INSERT INTO recipe_measure_conversions VALUES (?,?,?,?,?,?,?,?)").run(id,`manual_${Date.now()}`,fromQuantity,fromUnitId,toQuantity,toUnitId,String(form.get("notes")??"").trim()||null,JSON.stringify({source_type:"manual",title:"Recipe application",reviewed:true})); - database.close(); return Astro.redirect(`/app/recipes/${id}/?edit=1#equivalencies`,303); + const fromQuantity = Number(form.get("from_quantity")), toQuantity = Number(form.get("to_quantity")); + const fromUnitId = String(form.get("from_unit_id") ?? ""), toUnitId = String(form.get("to_unit_id") ?? ""); + if (!(fromQuantity > 0 && toQuantity > 0)) throw new Error("Equivalency quantities must be positive."); + if (!database.prepare("SELECT 1 FROM units WHERE id IN (?,?) HAVING count(*)=2").get(fromUnitId, toUnitId)) throw new Error("Unknown equivalency unit."); + database.prepare("INSERT INTO recipe_measure_conversions VALUES (?,?,?,?,?,?,?,?)").run(id, `manual_${Date.now()}`, fromQuantity, fromUnitId, toQuantity, toUnitId, String(form.get("notes") ?? "").trim() || null, JSON.stringify({ source_type: "manual", title: "Recipe application", reviewed: true })); + database.close(); + return Astro.redirect(`/app/recipes/${id}/?edit=1#equivalencies`, 303); } const title = String(form.get("title") ?? "").trim(); const yieldQuantity = Number(form.get("yield_quantity")); @@ -107,26 +130,29 @@ const recipe = editableRecipe(database, id); if (!recipe) { database.close(); return new Response("Recipe not found", { status: 404 }); } const units = database.prepare("SELECT id, name, symbol, dimension FROM units ORDER BY dimension, name").all() as Array<{ id: string; name: string; symbol: string; dimension: string }>; const structure = recipeStructure(database, id)!; -const autoYield=Boolean((database.prepare("SELECT auto_yield FROM recipes WHERE id=?").get(id) as {auto_yield:number}).auto_yield); -const ingredientOptions = database.prepare("SELECT id, name FROM ingredients WHERE status = 'active' ORDER BY name").all() as Array<{ id: string; name: string }>; +const autoYield = Boolean((database.prepare("SELECT auto_yield FROM recipes WHERE id=?").get(id) as { auto_yield: number }).auto_yield); +const ingredientOptions = (database.prepare("SELECT id, name FROM ingredients WHERE status = 'active' ORDER BY name").all() as Array<{ id: string; name: string }>).map((ingredient) => ({ + ...ingredient, + aliases: (database.prepare("SELECT name FROM ingredient_aliases WHERE ingredient_id = ? ORDER BY name").all(ingredient.id) as Array<{ name: string }>).map((entry) => entry.name), +})); const recipeOptions = database.prepare("SELECT id, title AS name FROM recipes WHERE deleted_at IS NULL ORDER BY title").all() as Array<{ id: string; name: string }>; const prepActionOptions = database.prepare("SELECT id, name FROM prep_actions ORDER BY name").all() as Array<{ id: string; name: string }>; -const recipeConversions=database.prepare("SELECT * FROM recipe_measure_conversions WHERE recipe_id=? ORDER BY id").all(id) as any[]; -const additional=database.prepare("SELECT station,cover_media_url,notes_json,source_json FROM recipes WHERE id=?").get(id) as any; -const additionalSource=JSON.parse(additional.source_json??"{}"),shelfLife=additionalSource.shelf_life; -const media=database.prepare("SELECT * FROM recipe_media WHERE recipe_id=? ORDER BY position").all(id) as any[]; -const projection = databaseProjection(database) as { recipes:Recipe[]; ingredients:Ingredient[]; units:Unit[]; sourceMappings:SourceMapping[]; purchaseItems:PurchaseItem[]; prepActions:PrepAction[] }; -const domainRecipe = projection.recipes.find((entry) => entry.id === id)!; -const recipeMap = new Map(projection.recipes.map((entry) => [entry.id, entry])); -const ingredientMap = new Map(projection.ingredients.map((entry) => [entry.id, entry])); -const unitMap = new Map(projection.units.map((entry) => [entry.id, entry])); -const nutrition = calculateNutrition(domainRecipe, { recipes:recipeMap, ingredients:ingredientMap, units:unitMap, mappings:new Map(projection.sourceMappings.map((entry) => [entry.id, entry])) }); -const cost = calculateCost(domainRecipe, { recipes:recipeMap, ingredients:ingredientMap, units:unitMap, purchaseItems:new Map(projection.purchaseItems.map((entry) => [entry.id, entry])), prepActions:new Map(projection.prepActions.map((entry) => [entry.id, entry])) }); -const calculatorComponents = domainRecipe.components.map((component) => ({ ...component, items:component.items.map((item) => { const ingredient="ingredient_id" in item.reference ? ingredientMap.get(item.reference.ingredient_id) : undefined; const child="recipe_id" in item.reference ? recipeMap.get(item.reference.recipe_id) : undefined; return { ...item, basisMember:item.basis_member, label:ingredient?.name ?? child?.title ?? "Unknown", href:ingredient ? `/app/ingredients/${ingredient.id}/` : child ? `/app/recipes/${child.id}/` : undefined, measureConversions:ingredient?.measure_conversions ?? child?.measure_conversions ?? [] }; }) })); -const percentSubjectEntries=domainRecipe.components.flatMap(component=>component.items).map(item=>{const ingredient="ingredient_id" in item.reference?ingredientMap.get(item.reference.ingredient_id):undefined,child="recipe_id" in item.reference?recipeMap.get(item.reference.recipe_id):undefined;return ingredient?[`ingredient:${ingredient.id}`,{key:`ingredient:${ingredient.id}`,value:ingredient}] as [string,{key:string,value:Ingredient|Recipe}]:[`recipe:${child?.id}`,{key:`recipe:${child?.id}`,value:child!}] as [string,{key:string,value:Ingredient|Recipe}];}); -const percentSubjects=[...new Map(percentSubjectEntries).values()]; -const weightRates=Object.fromEntries(percentSubjects.flatMap(subject=>projection.units.map(unit=>{try{return [`${subject.key}:${unit.id}`,convertWithIngredientMeasures({quantity:1,unit_id:unit.id},"gram",subject.value as Ingredient,unitMap).quantity];}catch{return [`${subject.key}:${unit.id}`,null];}}))); -const nutritionMappingMap=new Map(projection.sourceMappings.map(mapping=>[mapping.id,mapping])); +const recipeConversions = database.prepare("SELECT * FROM recipe_measure_conversions WHERE recipe_id=? ORDER BY id").all(id) as any[]; +const additional = database.prepare("SELECT station,notes_json,source_json FROM recipes WHERE id=?").get(id) as any; +const additionalSource = JSON.parse(additional.source_json ?? "{}"), shelfLife = additionalSource.shelf_life; + +const calcContext = getRecipeCalculationContext(database, id); +if (!calcContext) { database.close(); return new Response("Recipe calculation context not found", { status: 404 }); } +const { domainRecipe, recipes: recipeMap, ingredients: ingredientMap, units: unitMap, purchaseItems: purchaseItemsMap, sourceMappings: sourceMappingsMap, prepActions: prepActionsMap } = calcContext; + +const nutrition = calculateNutrition(domainRecipe, { recipes: recipeMap, ingredients: ingredientMap, units: unitMap, mappings: sourceMappingsMap }); +const cost = calculateCost(domainRecipe, { recipes: recipeMap, ingredients: ingredientMap, units: unitMap, purchaseItems: purchaseItemsMap, prepActions: prepActionsMap }); +const reviewedNutritionMappingIds = new Set(Array.from(sourceMappingsMap.values()).filter((mapping) => mapping.mapping_type === "nutrition" && mapping.status === "reviewed" && Object.keys(mapping.nutrition_per_100g ?? {}).length > 0).map((mapping) => mapping.id)); +const calculatorComponents = domainRecipe.components.map((component) => ({ ...component, items: component.items.map((item) => { const ingredient = "ingredient_id" in item.reference ? ingredientMap.get(item.reference.ingredient_id) : undefined; const child = "recipe_id" in item.reference ? recipeMap.get(item.reference.recipe_id) : undefined; const mapped = ingredient ? (ingredient.nutrition_mapping_ids ?? []).some((mappingId) => reviewedNutritionMappingIds.has(mappingId)) : true; return { ...item, basisMember: item.basis_member, label: ingredient?.name ?? child?.title ?? "Unknown", href: ingredient ? `/app/ingredients/${ingredient.id}/` : child ? `/app/recipes/${child.id}/` : undefined, attention: Boolean(ingredient && !mapped), attentionMessage: ingredient && !mapped ? "Nutrition mapping needed" : undefined, measureConversions: ingredient?.measure_conversions ?? child?.measure_conversions ?? [] }; }) })); +const percentSubjectEntries = domainRecipe.components.flatMap(component => component.items).map(item => { const ingredient = "ingredient_id" in item.reference ? ingredientMap.get(item.reference.ingredient_id) : undefined, child = "recipe_id" in item.reference ? recipeMap.get(item.reference.recipe_id) : undefined; return ingredient ? [`ingredient:${ingredient.id}`, { key: `ingredient:${ingredient.id}`, value: ingredient }] as [string, { key: string, value: Ingredient | Recipe }] : [`recipe:${child?.id}`, { key: `recipe:${child?.id}`, value: child! }] as [string, { key: string, value: Ingredient | Recipe }]; }); +const percentSubjects = [...new Map(percentSubjectEntries).values()]; +const weightRates = Object.fromEntries(percentSubjects.flatMap(subject => Array.from(unitMap.values()).map(unit => { try { return [`${subject.key}:${unit.id}`, convertWithIngredientMeasures({ quantity: 1, unit_id: unit.id }, "gram", subject.value as Ingredient, unitMap).quantity]; } catch { return [`${subject.key}:${unit.id}`, null]; } }))); +const nutritionMappingMap = sourceMappingsMap; const nutritionIngredients=domainRecipe.components.flatMap(component=>component.items).filter(item=>!item.optional).map(item=>{ if("ingredient_id" in item.reference){ const ingredient=ingredientMap.get(item.reference.ingredient_id)!; @@ -143,42 +169,320 @@ database.close(); const categories = JSON.parse(recipe.categories_json).join(", "); const tags = JSON.parse(recipe.tags_json).join(", "); const saved = Astro.url.searchParams.get("saved") === "1"; +const recipeTabIcon = (name: string) => getTabIconHtml(name as TabIconKey); --- -
- -

← Recipes

{editing?:

{recipe.title}

}
{!readOnlyMode&&
{editing?:✎ Edit}
}
-
{editing?<>:<>}
- {editing?<>
-
- - {saved &&
Changes saved.
} - {error &&
{error}
} -
Finished Yield{autoYield&&Calculated from convertible ingredient quantities}
- -
-
Auto calculate total yield{autoYield&&Revert to original and disable auto calculate}
-
-
-
-

UoM Equivalency

Define how this finished recipe converts between weight, volume, and portions.

{recipeConversions.map(x=>
{x.from_quantity} {x.from_unit_id}={x.to_quantity} {x.to_unit_id}{x.notes}
)}
=
-
-

Additional Details

Shelf Life
- :<>

Prep Method {domainRecipe.steps.length}

    {domainRecipe.steps.map(step=>
  1. {step.order}.{step.instruction}{media.filter(entry=>entry.step_id===step.id).map(entry=>
    {entry.media_type==="image"?{entry.caption??""}/:
    )}
  2. )}

UoM Equivalency

{recipeConversions.length?recipeConversions.map(x=>
{x.from_quantity} {x.from_unit_id}={x.to_quantity} {x.to_unit_id}{x.notes}
):

No recipe-level equivalencies have been defined.

}
{additional.cover_media_url&&
}

Additional details

{additional.station&&

Station{additional.station}

}{shelfLife&&

Shelf life{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}

}{JSON.parse(additional.notes_json??"[]").length>0&&
    {JSON.parse(additional.notes_json).map((note:string)=>
  • {note}
  • )}
}
} +
+ +
+ {editing ? ( + + ) : !readOnlyMode && ( + Edit + )} +
+ +
+
+
+
+ +
+
+
+ {editing ? ( + + ) : ( +

{recipe.title}

+ )} +
+
+ + {editing && ( +
+
+ + {saved &&
Changes saved.
} + {error &&
{error}
} +
+ Total Yield +
+ + +
+ + +
+ + + +
+
+ + + Auto calculate total yield +
+
+ )} + + {editing ? ( +
+ +
+ ) : ( +
+
+ +
+
+ )} +
+ +
+
+ + + + + +
+ +
+
+ {editing ? ( +
+
+
+

Additional details

+
+
+ Shelf Life + + + +
+ + +
+
+
+ ) : ( +
+

Prep Method {domainRecipe.steps.filter(s => !s.instruction.trim().endsWith(":") && !/^\(.+\)$/.test(s.instruction.trim())).length || domainRecipe.steps.length}

+
    + {(() => { + let stepCount = 0; + return domainRecipe.steps.map((step) => { + const text = step.instruction.trim(); + const isHeading = text.endsWith(":"); + const isNote = /^\(.+\)$/.test(text); + if (!isHeading && !isNote) { + stepCount += 1; + } + return ( +
  1. + {isHeading ? ( +

    {text}

    + ) : isNote ? ( +

    {text}

    + ) : ( + <> + {stepCount}. + {step.instruction} + + )} +
  2. + ); + }); + })()} +
+
+ )} + +
+

U of M Equivalency

+

XX Weight = XX Volume = XX Each

+

+ If you would like to use this recipe by weight, volume, and even by the portion - you can customize that here. + + + + For example, if your recipe yields 1 quart, weighs 850 grams, and each portion is 85 grams, toggle off the standard conversion and enter '850 grams = 1 quart = 10 portions'. + + +

+ +
+ +
+ Standard Weight - Volume Conversion + When toggled on, conversions are locked to 8oz = 1 cup. Toggle off to customize. +
+
+ +
+ +
+ Weight +
+ + +
+
+ + +
=
+ + +
+ Volume +
+ + +
+
+ + +
=
+ + +
+ Each +
+ + +
+
+
+ + {recipeConversions.length > 0 && ( +
+

Custom Equivalencies

+ {recipeConversions.map(x => ( +
+ {x.from_quantity} {unitMap.get(x.from_unit_id)?.symbol ?? x.from_unit_id} + = + {x.to_quantity} {unitMap.get(x.to_unit_id)?.symbol ?? x.to_unit_id} + {x.notes && {x.notes}} +
+ ))} +
+ )} +
+ +
+ +
+ +
+ +
+
+
+ + {!editing && ( +
+
+

Additional details

+ {additional.station&&

Station{additional.station}

} + {shelfLife&&

Shelf life{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}

} + {JSON.parse(additional.notes_json??"[]").length>0&&
    {JSON.parse(additional.notes_json).map((note:string)=>
  • {note}
  • )}
} +
+
+ )} +
- {!editing&&} + {editing&&} diff --git a/src/application/pages/app/settings/index.astro b/src/application/pages/app/settings/index.astro new file mode 100644 index 0000000..bee9759 --- /dev/null +++ b/src/application/pages/app/settings/index.astro @@ -0,0 +1,696 @@ +--- +export const prerender = false; +import BaseLayout from "../../../../layouts/BaseLayout.astro"; +import DetailUtility from "../../../../components/DetailUtility.astro"; +import { openDatabase } from "../../../../lib/database"; +import { exportDatabase } from "../../../../lib/backup"; +import { readOnlyMode } from "../../../../lib/runtime"; + +const database = openDatabase({ readOnly: true }); +if (!database) return Astro.redirect("/app/", 303); + +let summary = { + recipes_count: 0, + ingredients_count: 0, + purchase_items_count: 0, + collections_count: 0, + inventory_counts_count: 0, + inventory_locations_count: 0, + total_records_count: 0, +}; + +try { + const bundle = exportDatabase(database); + summary = bundle.summary; +} catch (err) { + console.error("Failed to compute database summary:", err); +} finally { + database.close(); +} +--- + + +
+ + +
+
+
+

Data Management

+

+ Export complete database archives or restore backups across all 25 tables with full referential integrity. +

+
+
+ +
+ +
+
+
+ + + +
+
+

Active Database Status

+ SQLite local storage ({summary.total_records_count} total entities) +
+
+ +
+
+ Recipes + {summary.recipes_count} +
+
+ Ingredients + {summary.ingredients_count} +
+
+ Purchase Items + {summary.purchase_items_count} +
+
+ Recipe Books + {summary.collections_count} +
+
+ Inventory Counts + {summary.inventory_counts_count} +
+
+ Storage Locations + {summary.inventory_locations_count} +
+
+
+ + +
+
+
+ + + +
+
+

Export Database Backup

+ Download full-fidelity JSON archive +
+
+ +

+ Generates a portable backup bundle containing 100% of your formulations, ingredients, purchasing catalogs, cost histories, and inventory count sessions. +

+ + +
+ + +
+
+
+ + + +
+
+

Import & Restore Backup

+ Restore database from backup JSON +
+
+ + {readOnlyMode ? ( +
+ Database restoration is disabled in read-only demonstration mode. +
+ ) : ( +
+
+ + + + +

Select or drag a Formulation backup (.json) file

+ +
+ + + + + + +
+ )} +
+
+
+
+
+ + + + diff --git a/src/application/pages/tools/purchasing-review.astro b/src/application/pages/tools/purchasing-review.astro index dbc70c3..0b196fd 100644 --- a/src/application/pages/tools/purchasing-review.astro +++ b/src/application/pages/tools/purchasing-review.astro @@ -1,9 +1,57 @@ --- -export const prerender = true; -import fs from "node:fs"; import path from "node:path"; import YAML from "yaml"; -import BaseLayout from "../../../layouts/BaseLayout.astro"; import PurchasingReview from "../../../components/PurchasingReview"; -import { ingredients } from "../../../lib/data"; -const file=path.resolve(process.cwd(),"generated/receipt-product-candidates.yaml"); const data=fs.existsSync(file)?YAML.parse(fs.readFileSync(file,"utf8")):null; -const ingredientOptions=[...ingredients.values()].map(({id,name})=>({id,name})).sort((a,b)=>a.name.localeCompare(b.name)); +export const prerender = false; +import fs from "node:fs"; +import path from "node:path"; +import YAML from "yaml"; +import BaseLayout from "../../../layouts/BaseLayout.astro"; +import DetailUtility from "../../../components/DetailUtility.astro"; +import PurchasingReview from "../../../components/PurchasingReview"; +import { openDatabase } from "../../../lib/database"; +import { titleCase } from "../../../lib/format"; + +const file = path.resolve(process.cwd(), "generated/receipt-product-candidates.yaml"); +const data = fs.existsSync(file) ? YAML.parse(fs.readFileSync(file, "utf8")) : null; + +const database = openDatabase(); +const ingredientOptions = database + ? (database.prepare("SELECT id, name FROM ingredients WHERE status = 'active' ORDER BY name").all() as Array<{ id: string; name: string }>).map((i) => ({ id: i.id, name: titleCase(i.name) })) + : []; +if (database) database.close(); --- -

Local data tool

Receipt product review

Link actual Walmart and Sam's Club products to canonical ingredients.

{data?:
Run scripts/receipt-products propose, then rebuild.
}
+ + +
+ + +
+
+ +

Receipt product review

+

Link Walmart and Sam's Club purchase products to canonical formulation ingredients.

+
+ + {data ? ( + + ) : ( +
+ + + + + +
+ No candidates generated yet +

Run scripts/receipt-products propose to parse receipt files and extract product candidates.

+
+
+ )} +
+
+
diff --git a/src/components/DetailUtility.astro b/src/components/DetailUtility.astro index 3b3cbce..897ee1e 100644 --- a/src/components/DetailUtility.astro +++ b/src/components/DetailUtility.astro @@ -2,27 +2,95 @@ interface Props { section?: string; sectionHref?: string } import { readOnlyMode } from "../lib/runtime"; const { section, sectionHref } = Astro.props; -const inferred = Astro.url.pathname.includes("/ingredients/") - ? { label:"Ingredients", href:"/app/?type=ingredient" } - : Astro.url.pathname.includes("/recipe-books/") - ? { label:"Recipe Books", href:"/app/?type=book" } - : Astro.url.pathname.includes("/recipes/") - ? { label:"Recipes", href:"/app/?type=recipe" } - : { label:undefined, href:"/app/" }; -const resolvedSection=section??inferred.label; -const resolvedHref=sectionHref??inferred.href; +const pathname = Astro.url.pathname.replace(/\/+$/, "") + "/"; + +let inferredLabel: string | undefined; +let inferredHref = "/app/"; + +if (pathname.startsWith("/app/inventory/")) { + if (pathname === "/app/inventory/") { + inferredLabel = "Inventory"; + inferredHref = "/app/"; + } else { + inferredLabel = "Inventory"; + inferredHref = "/app/inventory/"; + } +} else if (pathname.startsWith("/app/ingredients/")) { + inferredLabel = "Ingredients"; + inferredHref = "/app/?type=ingredient"; +} else if (pathname.startsWith("/app/recipe-books/")) { + inferredLabel = "Recipe Books"; + inferredHref = "/app/?type=book"; +} else if (pathname.startsWith("/app/recipes/")) { + inferredLabel = "Recipes"; + inferredHref = "/app/?type=recipe"; +} else if (pathname.startsWith("/app/archive")) { + inferredLabel = "Archive"; + inferredHref = "/app/"; +} else if (pathname.startsWith("/app/settings")) { + inferredLabel = "Data Management"; + inferredHref = "/app/settings/"; +} else if (pathname.startsWith("/tools/purchasing-review")) { + inferredLabel = "Purchasing Review"; + inferredHref = "/app/"; +} + +const resolvedSection = section ?? inferredLabel; +const resolvedHref = sectionHref ?? inferredHref; --- + diff --git a/src/components/EntityDirectory.tsx b/src/components/EntityDirectory.tsx index 1edb21f..c4f3112 100644 --- a/src/components/EntityDirectory.tsx +++ b/src/components/EntityDirectory.tsx @@ -1,9 +1,10 @@ import { useRef,useState } from "preact/hooks"; +import { TYPE_ICONS, TYPE_ICON_TRANSFORMS } from "../lib/icons"; export type DirectoryRow = { - id:string; name:string; href?:string; kind:"recipe"|"ingredient"|"book"|"purchase"; icon:string; + id:string; name:string; href?:string; kind:"recipe"|"ingredient"|"book"|"purchase"; detail?:string; }; -type Props={ rows:DirectoryRow[]; entityType:DirectoryRow["kind"]; emptyMessage:string; readOnly?:boolean }; +type Props={ rows:DirectoryRow[]; entityType?:DirectoryRow["kind"]; emptyMessage:string; readOnly?:boolean }; export default function EntityDirectory({rows,entityType,emptyMessage,readOnly=false}:Props) { const [selected,setSelected]=useState([]),[deleting,setDeleting]=useState(false),[error,setError]=useState(""); @@ -12,33 +13,59 @@ export default function EntityDirectory({rows,entityType,emptyMessage,readOnly=f const allSelected=rows.length>0&&selected.length===rows.length; const toggle=(id:string)=>setSelected(current=>current.includes(id)?current.filter(value=>value!==id):[...current,id]); const requestDelete=(ids:string[])=>{if(!ids.length)return;setPendingDelete(ids);dialog.current?.showModal();}; + const kindById=new Map(rows.map(row=>[row.id,row.kind])); const remove=async()=>{ const ids=pendingDelete; if(!ids.length)return; setDeleting(true);setError(""); - const response=await fetch("/api/app/entities/delete",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({type:entityType,ids})}); - const result=await response.json(); - if(!response.ok){setError(result.error??"Unable to delete selection.");setDeleting(false);dialog.current?.close();return;} + const groups=new Map(); + for(const id of ids){const kind=kindById.get(id)??entityType??"recipe";if(!groups.has(kind))groups.set(kind,[]);groups.get(kind)!.push(id);} + try{ + for(const [kind,groupIds] of groups){ + const response=await fetch("/api/app/entities/delete",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({type:kind,ids:groupIds})}); + const result=await response.json(); + if(!response.ok){setError(result.error??"Unable to delete selection.");dialog.current?.close();return;} + } + } finally { setDeleting(false); } location.reload(); }; return
- {!readOnly&&
- {if(input)input.indeterminate=selected.length>0&&!allSelected;}} onChange={()=>setSelected(allSelected?[]:rows.map(row=>row.id))}/> - {selected.length?`${selected.length} selected`:""} - {selected.length>0&&<>} + {!readOnly&&
0?" has-selection":""}`}> + {if(input)input.indeterminate=selected.length>0&&!allSelected;}} onChange={()=>setSelected(allSelected?[]:rows.map(row=>row.id))}/> + {selected.length>0 ? ( +
+ {selected.length} Selected +
+ + +
+
+ ) : ( + <> + Type + Name +
+ + )}
} {error&&

{error}

}
{rows.map(row=>
{!readOnly&&toggle(row.id)}/>} - {row.icon} - {row.href?{row.name}:{row.name}} - {!readOnly&&
} + + {row.href?{row.name}{row.detail&&{row.detail}}:<>{row.name}{row.detail&&{row.detail}}} + {!readOnly&&
}
)}
{rows.length===0&&
{emptyMessage}
} {!readOnly&&{if(!deleting)setPendingDelete([]);}}>

Delete {pendingDelete.length===1?"item":`${pendingDelete.length} items`}?

-

This permanently removes the selected {pendingDelete.length===1?entityType:`${entityType} items`}. This action cannot be undone.

+

This permanently removes the selected {pendingDelete.length===1?(entityType??"item"):`${pendingDelete.length} ${entityType??"items"}`}. This action cannot be undone.

}
; diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx new file mode 100644 index 0000000..207d28b --- /dev/null +++ b/src/components/Icon.tsx @@ -0,0 +1,69 @@ +import { + TAB_ICONS, + TAB_VIEWBOXES, + TYPE_ICONS, + TYPE_ICON_TRANSFORMS, + UI_ICONS, + type TabIconKey, + type EntityIconKey, + type UiIconKey +} from "../lib/icons"; + +interface TabIconProps { + name: TabIconKey; + className?: string; + size?: number; +} + +export function TabIcon({ name, className = "recipe-tab-icon", size }: TabIconProps) { + const path = TAB_ICONS[name]; + const viewBox = TAB_VIEWBOXES[name] || "0 0 24 24"; + if (!path) return null; + + return ( + + ); +} + +interface TypeIconProps { + kind: EntityIconKey; + className?: string; + size?: number; +} + +export function TypeIcon({ kind, className, size = 20 }: TypeIconProps) { + const path = TYPE_ICONS[kind]; + const transform = TYPE_ICON_TRANSFORMS[kind]; + if (!path) return null; + + const wrapperClass = className ?? `workspace-pill-icon ${kind}`; + + return ( + + ); +} + +interface UiIconProps { + name: UiIconKey; + size?: number; + className?: string; +} + +export function UiIcon({ name, size = 16, className }: UiIconProps) { + const path = UI_ICONS[name]; + if (!path) return null; + + return ( + + ); +} diff --git a/src/components/PurchasingReview.tsx b/src/components/PurchasingReview.tsx index f656b52..63ecb5e 100644 --- a/src/components/PurchasingReview.tsx +++ b/src/components/PurchasingReview.tsx @@ -1,15 +1,292 @@ import { useEffect, useMemo, useState } from "preact/hooks"; -type Candidate={ingredient_id:string;name:string;score:number}; -type Product={supplier_id:string;supplier_sku:string;name:string;url?:string;package?:{quantity:number;unit_id:string};prices:Array<{amount:number;effective_at:string}>;ingredient_candidates:Candidate[]}; -type Props={products:Product[];ingredients:Array<{id:string;name:string}>}; type Decisions=Record; -const STORAGE_KEY="recipe-book-purchasing-decisions-v1"; -export default function PurchasingReview({products,ingredients}:Props){ - const [decisions,setDecisions]=useState({}); const [query,setQuery]=useState(""); const [unresolved,setUnresolved]=useState(true); - useEffect(()=>{try{setDecisions(JSON.parse(localStorage.getItem(STORAGE_KEY)??"{}"))}catch{}},[]); - const choose=(key:string,value:string|null)=>{const next={...decisions,[key]:value};setDecisions(next);localStorage.setItem(STORAGE_KEY,JSON.stringify(next))}; - const visible=useMemo(()=>products.filter(p=>{const key=`${p.supplier_id}:${p.supplier_sku}`;return p.name.toLowerCase().includes(query.toLowerCase())&&(!unresolved||!(key in decisions))}),[products,query,unresolved,decisions]); - const download=()=>{const url=URL.createObjectURL(new Blob([JSON.stringify({schema_version:1,generated_at:new Date().toISOString(),decisions},null,2)],{type:"application/json"}));const anchor=document.createElement("a");anchor.href=url;anchor.download="purchasing-decisions.json";anchor.click();URL.revokeObjectURL(url)}; - return
{Object.keys(decisions).length} / {products.length} reviewedProducts without explicit package sizes cannot be imported yet
setQuery(e.currentTarget.value)}/>
- {visible.map(product=>{const key=`${product.supplier_id}:${product.supplier_sku}`;return
{product.name}

{product.supplier_id.replace("_"," ")} · SKU {product.supplier_sku} · {product.package?`${product.package.quantity} ${product.package.unit_id}`:"package unknown"} · latest ${product.prices.at(-1)?.amount.toFixed(2)}

{product.ingredient_candidates.map(candidate=>)}{product.url&&

Inspect product ↗

}
})} -
; + +type Candidate = { + ingredient_id: string; + name: string; + score: number; +}; + +type Product = { + supplier_id: string; + supplier_sku: string; + name: string; + url?: string; + package?: { + quantity: number; + unit_id: string; + }; + prices: Array<{ + amount: number; + effective_at: string; + }>; + ingredient_candidates: Candidate[]; +}; + +type Props = { + products: Product[]; + ingredients: Array<{ id: string; name: string }>; +}; + +type Decisions = Record; + +const STORAGE_KEY = "recipe-book-purchasing-decisions-v1"; + +export default function PurchasingReview({ products, ingredients }: Props) { + const [decisions, setDecisions] = useState({}); + const [query, setQuery] = useState(""); + const [unresolved, setUnresolved] = useState(true); + + useEffect(() => { + try { + setDecisions(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}")); + } catch {} + }, []); + + const choose = (key: string, value: string | null) => { + const next = { ...decisions, [key]: value }; + setDecisions(next); + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + }; + + const visible = useMemo(() => { + return products.filter((p) => { + const key = `${p.supplier_id}:${p.supplier_sku}`; + const matchesQuery = p.name.toLowerCase().includes(query.toLowerCase()) || + p.supplier_sku.toLowerCase().includes(query.toLowerCase()); + const matchesResolution = !unresolved || !(key in decisions); + return matchesQuery && matchesResolution; + }); + }, [products, query, unresolved, decisions]); + + const reviewedCount = Object.keys(decisions).length; + const progressPercent = products.length > 0 ? Math.round((reviewedCount / products.length) * 100) : 0; + + const download = () => { + const url = URL.createObjectURL( + new Blob( + [ + JSON.stringify( + { + schema_version: 1, + generated_at: new Date().toISOString(), + decisions, + }, + null, + 2 + ), + ], + { type: "application/json" } + ) + ); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = "purchasing-decisions.json"; + anchor.click(); + URL.revokeObjectURL(url); + }; + + const supplierLabel = (id: string) => { + if (id === "walmart") return "Walmart"; + if (id === "sams_club") return "Sam's Club"; + return id.replace("_", " "); + }; + + return ( +
+
+
+
+ {reviewedCount} / {products.length} + reviewed ({progressPercent}%) +
+ Products without explicit package sizes cannot be imported automatically. +
+ +
+ + + + + +
+
+ + {visible.length > 0 ? ( +
+ {visible.map((product) => { + const key = `${product.supplier_id}:${product.supplier_sku}`; + const currentDecision = decisions[key]; + const isResolved = key in decisions; + const latestPrice = product.prices.at(-1)?.amount; + + return ( +
+
+
+
+ + {supplierLabel(product.supplier_id)} + + SKU #{product.supplier_sku} + {product.package && ( + + {product.package.quantity} {product.package.unit_id.replace("_", " ")} + + )} + {latestPrice != null && ( + ${latestPrice.toFixed(2)} + )} +
+

{product.name}

+
+ + {product.url && ( + + Inspect product + + + + + + + )} +
+ +
+ Select ingredient mapping: + +
+ {product.ingredient_candidates.map((candidate) => { + const isSelected = currentDecision === candidate.ingredient_id; + const matchPercent = Math.round(candidate.score * 100); + + return ( + + ); + })} + +
+ +
+ + +
+
+
+ ); + })} +
+ ) : ( +
+
+ + + + +
+

No products to review

+

+ {unresolved + ? "All candidate products have been reviewed! Uncheck 'Unresolved only' to inspect past decisions." + : "No products matched your search query."} +

+ {query && ( + + )} +
+ )} +
+ ); } diff --git a/src/components/RecipeCalculator.tsx b/src/components/RecipeCalculator.tsx index c5e521b..c1c98e7 100644 --- a/src/components/RecipeCalculator.tsx +++ b/src/components/RecipeCalculator.tsx @@ -2,9 +2,10 @@ import { useEffect, useMemo, useState } from "preact/hooks"; import type { CalculatorComponent, CalculatorItem, Unit } from "../lib/types"; import { convert } from "../lib/measurement"; import type { NutritionResult } from "../lib/nutrition"; -import type { CostResult } from "../lib/costing"; +import type { CostLine, CostResult } from "../lib/costing"; import NutritionPanel from "./NutritionPanel"; import { number, roundForDisplay } from "../lib/format"; +import { TYPE_ICONS, TYPE_ICON_TRANSFORMS } from "../lib/icons"; type Props = { components: CalculatorComponent[]; @@ -17,7 +18,9 @@ type Props = { cost: CostResult; servings?: number; showDerived?: boolean; + showPercentControls?: boolean; yieldConversions?: CalculatorItem["measureConversions"]; + shelfLife?: string | { duration?: { quantity?: number; unit_id?: string }; storage_condition?: string }; }; function itemUnits(item: CalculatorItem, units: Record) { @@ -46,13 +49,94 @@ function convertItem(quantity: number, fromUnitId: string, toUnitId: string, ite throw new Error(`No reviewed equivalency from ${fromUnitId} to ${toUnitId}`); } -export default function RecipeCalculator({ components, units, basisUnitId, yieldQuantity, yieldUnitId, nutrition, cost, servings, showDerived = true, yieldConversions = [] }: Props) { +const SCALE_OPTIONS = [ + { value: 0.5, label: "1/2x" }, + { value: 1, label: "1x" }, + { value: 2, label: "2x" }, + { value: 3, label: "3x" }, + { value: 4, label: "4x" }, + { value: 5, label: "5x" }, +]; + +export default function RecipeCalculator({ components, units, basisUnitId, yieldQuantity, yieldUnitId, nutrition, cost, servings, showDerived = true, showPercentControls = true, yieldConversions = [], shelfLife }: Props) { const [factor, setFactor] = useState(1); - const [calculatePercent,setCalculatePercent]=useState(true); + const [isCustomScale, setIsCustomScale] = useState(false); + const [calculatePercent,setCalculatePercent]=useState(showPercentControls); const [percentMode,setPercentMode]=useState<"standard"|"bakers">("standard"); const [yieldDisplayUnitId, setYieldDisplayUnitId] = useState(yieldUnitId); const [lineUnits, setLineUnits] = useState>({}); + const [showScaleTooltip, setShowScaleTooltip] = useState(false); + const [scaleTooltipTimer, setScaleTooltipTimer] = useState(null); + const [activeAttentionId, setActiveAttentionId] = useState(null); const validFactor = Number.isFinite(factor) && factor >= 0 ? factor : 0; + + const matchedScaleOption = SCALE_OPTIONS.find((opt) => Math.abs(opt.value - factor) < 0.001); + const showCustomInput = isCustomScale || !matchedScaleOption; + + const handleScaleSelectChange = (e: Event) => { + const val = (e.currentTarget as HTMLSelectElement).value; + if (val === "custom") { + setIsCustomScale(true); + } else { + setIsCustomScale(false); + setFactor(Number(val)); + } + }; + + const handleScaleTooltipEnter = () => { + if (scaleTooltipTimer) clearTimeout(scaleTooltipTimer); + setShowScaleTooltip(true); + }; + + const handleScaleTooltipLeave = () => { + const timer = setTimeout(() => { + setShowScaleTooltip(false); + }, 150); + setScaleTooltipTimer(timer); + }; + + const handleScaleTooltipToggle = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setShowScaleTooltip((prev) => !prev); + }; + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement | null; + if (!target?.closest(".helper-tooltip-wrap")) { + setShowScaleTooltip(false); + } + if (!target?.closest(".ingredient-attention-wrapper")) { + setActiveAttentionId(null); + } + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setShowScaleTooltip(false); + setActiveAttentionId(null); + } + }; + document.addEventListener("click", handleClickOutside); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("click", handleClickOutside); + document.removeEventListener("keydown", handleKeyDown); + }; + }, []); + + const shelfLifeText = useMemo(() => { + if (!shelfLife) return null; + if (typeof shelfLife === "string") return shelfLife.trim() || null; + const q = shelfLife.duration?.quantity; + const u = shelfLife.duration?.unit_id; + if (q != null && u) { + const unitLabel = q === 1 ? u : `${u}s`; + return `${q} ${unitLabel}`; + } + return null; + }, [shelfLife]); + useEffect(() => { window.dispatchEvent(new CustomEvent("recipe-scale-change", { detail:validFactor })); }, [validFactor]); const itemWeight=(item:CalculatorItem)=>{try{return convertItem(item.amount.quantity,item.amount.unit_id,"gram",item,units);}catch{return undefined;}}; const total = useMemo( @@ -63,51 +147,168 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield [components, validFactor, basisUnitId, units], ); const unconvertedCount=useMemo(()=>components.flatMap((component)=>component.items).filter((item)=>{try{convertItem(item.amount.quantity,item.amount.unit_id,basisUnitId,item,units);return false;}catch{return true;}}).length,[components,basisUnitId,units]); - const percentageBase=components.flatMap(component=>component.items).filter(item=>percentMode==="standard"||item.basisMember).reduce((sum,item)=>sum+(itemWeight(item)??0),0); + const percentageBase=useMemo(()=>{if(!calculatePercent)return 0;if(percentMode==="standard")return total;return components.flatMap(c=>c.items).filter(item=>item.basisMember).reduce((sum,item)=>{const w=itemWeight(item);return w!=null?sum+w*validFactor:sum;},0);},[calculatePercent,percentMode,total,components,units,validFactor]); const baseItems=components.flatMap(component=>component.items).filter(item=>item.basisMember); const basisUnit = units[basisUnitId]; const yieldItem = { id:"yield", label:"Yield", amount:{quantity:yieldQuantity,unit_id:yieldUnitId}, measureConversions:yieldConversions } as CalculatorItem; - const yieldUnits = itemUnits(yieldItem,units); - const scaledYield = convertItem(yieldQuantity * validFactor,yieldUnitId,yieldDisplayUnitId,yieldItem,units); - const changeYield = (value: number) => { - const canonicalQuantity = convertItem(value,yieldDisplayUnitId,yieldUnitId,yieldItem,units); - setFactor(yieldQuantity > 0 ? canonicalQuantity / yieldQuantity : 1); + const yieldUnits = useMemo(() => itemUnits(yieldItem,units), [yieldItem, units]); + const scaledYield = useMemo(() => { + try { return convertItem(yieldQuantity * validFactor, yieldUnitId, yieldDisplayUnitId, yieldItem, units); } + catch { return yieldQuantity * validFactor; } + }, [yieldQuantity, validFactor, yieldUnitId, yieldDisplayUnitId, yieldItem, units]); + + const changeYield = (nextYield: number) => { + if (!Number.isFinite(nextYield) || nextYield <= 0 || yieldQuantity <= 0) return; + try { + const canonicalNextYield = convertItem(nextYield, yieldDisplayUnitId, yieldUnitId, yieldItem, units); + setFactor(canonicalNextYield / yieldQuantity); + } catch { + setFactor(nextYield / yieldQuantity); + } }; - return ( -
-
-
-

Scalable formula

-

Ingredients

-
- - -
{calculatePercent&&}
-
-

1× produces {number(yieldQuantity)} {units[yieldUnitId]?.symbol??yieldUnitId} finished yield. Changing the multiplier, finished yield, or any ingredient amount scales the entire recipe.

+ const currentScaleLabel = !showCustomInput && matchedScaleOption ? matchedScaleOption.label : "Custom"; + const yieldUnitSymbol = yieldUnits.find((u) => u.id === yieldDisplayUnitId)?.symbol ?? yieldDisplayUnitId; + const yieldQtyString = String(roundForDisplay(scaledYield)); + const customFactorString = String(roundForDisplay(factor)); - {calculatePercent&&percentMode==="bakers"&&baseItems.length>0&&
Base
{baseItems.map(item=>{const displayUnitId=lineUnits[item.id]??item.amount.unit_id;const displayQuantity=convertItem(item.amount.quantity*validFactor,item.amount.unit_id,displayUnitId,item,units);return

{const canonicalQuantity=convertItem(Number(event.currentTarget.value),displayUnitId,item.amount.unit_id,item,units);setFactor(item.amount.quantity>0?canonicalQuantity/item.amount.quantity:1)}}/>{item.label}

})}
} + return ( +
+
+
+ Batch Size: + + + {showCustomInput && ( + + (event.currentTarget as HTMLInputElement).select()} + onClick={(event) => (event.currentTarget as HTMLInputElement).select()} + onInput={(event) => { + const val = Number((event.currentTarget as HTMLInputElement).value); + if (!isNaN(val) && val > 0) setFactor(val); + }} + autoFocus + /> + x + + )} + +
+
+ Yield: + + (event.currentTarget as HTMLInputElement).select()} + onClick={(event) => (event.currentTarget as HTMLInputElement).select()} + onInput={(event) => changeYield(Number((event.currentTarget as HTMLInputElement).value))} + /> + + + + + {showScaleTooltip && ( + + )} + +
+ {shelfLifeText && ( +
+ Shelf Life: + {shelfLifeText} +
+ )} + {showPercentControls && ( +
+ {calculatePercent && ( + + + + + )} + +
+ )} +
+ + {calculatePercent&&percentMode==="bakers"&&baseItems.length>0&&
Base
{baseItems.map(item=>{const displayUnitId=lineUnits[item.id]??item.amount.unit_id;const displayQuantity=convertItem(item.amount.quantity*validFactor,item.amount.unit_id,displayUnitId,item,units);return

(event.currentTarget as HTMLInputElement).select()} onClick={(event) => (event.currentTarget as HTMLInputElement).select()} onInput={(event)=>{const canonicalQuantity=convertItem(Number(event.currentTarget.value),displayUnitId,item.amount.unit_id,item,units);setFactor(item.amount.quantity>0?canonicalQuantity/item.amount.quantity:1)}}/>{item.label}

})}
} {components.map((component) => (
- {components.length > 1 &&

{component.name}

} + {Boolean(component.name?.trim()) &&

{component.name}

} + {(component.notes ?? []).filter(Boolean).map((note, noteIdx) => ( +

{note}

+ ))}
- {calculatePercent&&} + {calculatePercent&&} {component.items.filter(item=>percentMode!=="bakers"||!calculatePercent||!item.basisMember).map((item) => { const displayUnitId = lineUnits[item.id] ?? item.amount.unit_id; @@ -119,13 +320,42 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield }; return ( + - {calculatePercent&&} - + {calculatePercent&&} ); })} @@ -146,7 +376,34 @@ export function DerivedValues({ nutrition, cost, servings, factor }: { nutrition return
-

Derived estimate

Recipe cost

{Math.round(cost.completeness * 100)}% priced
+
+
+

Derived estimate

+

Recipe cost

+
+
+ {Math.round(cost.completeness * 100)}% priced + {cost.completeness < 1 && ( + + + + There is not enough information to calculate cost. Please verify the below: +
    +
  • This recipe and all its sub-recipes must include Total Yield
  • +
  • All ingredients in this recipe and its sub-recipes must be defined
  • +
  • All ingredients in this recipe and its sub-recipes must have a purchase cost
  • +
  • All ingredients in this recipe and its sub-recipes must have the needed unit conversion to convert from the unit in the recipe to the purchase unit
  • +
  • If this recipe exists in multiple concepts or locations, there is a cost at each concept/location
  • +
+
+
+ )} +
+
{cost.batch != null ?
Scaled batch
{money.format(cost.batch * factor)}
{cost.perServing != null &&
Per serving
{money.format(cost.perServing)}
} @@ -169,10 +426,157 @@ function useLiveFactor() { return factor; } -export function LiveCostValues({ cost }: { cost: CostResult }) { +function CostLedgerLine({ line, factor, currency, editable, expanded }: { line:CostLine; factor:number; currency:string; editable:boolean; expanded:boolean }) { + const money=new Intl.NumberFormat("en-US",{style:"currency",currency,minimumFractionDigits:2,maximumFractionDigits:4}); + const editHref = line.kind === "ingredient" ? `/app/ingredients/${line.subjectId}/?edit=1#costs` : `/app/recipes/${line.subjectId}/?edit=1#costing`; + const viewHref = line.kind === "ingredient" ? `/app/ingredients/${line.subjectId}/#costs` : `/app/recipes/${line.subjectId}/#costing`; + const hasChildren = Boolean(line.purchase || line.children?.length); + const costValue = line.cost != null ? money.format(line.cost * factor) : "—"; + + const handleSummaryClick = (e: MouseEvent) => { + const target = e.target as HTMLElement | null; + if (target?.closest("a") || target?.closest("button")) { + return; + } + if (!hasChildren) { + e.preventDefault(); + } + }; + + return ( +
+ + + + e.stopPropagation()}>{line.name} + + {line.completeness < 1 && ( + + + + {line.purchase ? "Cost information is incomplete" : "This ingredient is not yet set with a purchase cost and unit"} + + + )} + + + {editable && line.kind === "ingredient" ? ( + e.stopPropagation()}> + {line.cost != null ? costValue : "Add cost"} + + + ) : ( + {costValue} + )} + + + {hasChildren && ( +
+ {line.purchase ? ( + <> +
Purchase item name{line.purchase.name}
+
Purchase cost{money.format(line.purchase.price)}
+
Purchase unit{number(line.purchase.packageQuantity)} {line.purchase.packageUnitId}
+
Date added{line.purchase.effectiveAt}
+
Item ID #{line.purchase.sku??"—"}
+
Vendor{line.purchase.supplier??"—"}
+ + ) : ( +

No usable purchase cost is available.

+ )} + {line.children?.length ? ( +
+ {line.children.map((child) => ( + + ))} +
+ ) : null} +
+ )} +
+ ); +} + +export function LiveCostValues({ cost, yieldQuantity, yieldUnit="g", editable=false }: { cost: CostResult; yieldQuantity?:number; yieldUnit?:string; editable?:boolean }) { const factor=useLiveFactor(); const money=new Intl.NumberFormat("en-US",{style:"currency",currency:cost.currency,minimumFractionDigits:2,maximumFractionDigits:4}); - return

Recipe Cost

{Math.round(cost.completeness*100)}% priced
{cost.batch!=null?
Scaled batch
{money.format(cost.batch*factor)}
{cost.perServing!=null&&
Per serving
{money.format(cost.perServing*factor)}
}{cost.per100g!=null&&
Per 100 g
{money.format(cost.per100g)}
}
:

No usable purchase prices are available yet.

}{cost.completeness<1&&

Partial estimate; unpriced ingredients are excluded.

}{cost.warnings.length>0&&
{cost.warnings.length} costing {cost.warnings.length===1?"issue":"issues"}
    {cost.warnings.map(warning=>
  • {warning}
  • )}
}
; + const [expansion,setExpansion]=useState({open:false,revision:0}); + const setAll=(open:boolean)=>setExpansion((current)=>({open,revision:current.revision+1})); + + return ( +
+
+
+

Recipe Cost

+ {cost.completeness < 1 && ( + + + + There is not enough information to calculate cost. Please verify the below: +
    +
  • This recipe and all its sub-recipes must include Total Yield
  • +
  • All ingredients in this recipe and its sub-recipes must be defined
  • +
  • All ingredients in this recipe and its sub-recipes must have a purchase cost
  • +
  • All ingredients in this recipe and its sub-recipes must have the needed unit conversion to convert from the unit in the recipe to the purchase unit
  • +
  • If this recipe exists in multiple concepts or locations, there is a cost at each concept/location
  • +
+
+
+ )} +
+

{editable?"Update an ingredient’s shared purchase cost here. The change is reflected in every recipe that uses it.":"Ingredient and sub-recipe costs used to calculate this recipe."}

+
+
+ + Ingredient / Sub-Recipe ( | ) + + Cost +
+
+ {cost.lines.map((line)=>( + + ))} +
+
+
Total Yield{yieldQuantity!=null?number(yieldQuantity*factor):"—"} {yieldUnit}
+
Total Cost{cost.batch!=null?money.format(cost.batch*factor):"—"}
+
Cost Per {yieldUnit.toUpperCase()}:{cost.batch!=null&&yieldQuantity?money.format(cost.batch/yieldQuantity):"—"}
+ {cost.perServing!=null&&
Cost Per Serving{money.format(cost.perServing)}
} +
+ {cost.completeness<1&&

Partial estimate; unpriced ingredients are excluded. {Math.round(cost.completeness*100)}% of ingredient weight is priced.

} + {cost.warnings.length>0&&
{cost.warnings.length} costing {cost.warnings.length===1?"issue":"issues"}
    {cost.warnings.map(warning=>
  • {warning}
  • )}
} +
+ ); } export function LiveNutritionValues({ nutrition,servings,ingredients=[],editable=false,saveVersion }: { nutrition:NutritionResult; servings?:number; ingredients?:import("./NutritionPanel").NutritionIngredientStatus[]; editable?:boolean; saveVersion?:number }) { diff --git a/src/components/RecipeStructureEditor.tsx b/src/components/RecipeStructureEditor.tsx index 49d9aa7..1ba6ede 100644 --- a/src/components/RecipeStructureEditor.tsx +++ b/src/components/RecipeStructureEditor.tsx @@ -1,219 +1,70 @@ -import { useEffect, useState } from "preact/hooks"; -import type { RecipeStructure } from "../lib/database"; -import { percentage, targetWeight } from "../lib/percentages"; +import { Fragment } from "preact"; +import { + BulkIngredientImportModal, + BulkPrepStepsModal, + RecipeItemRow, + RecipeMethodEditor, + useRecipeStructure, + uid, + normal, + type RecipeEditorProps, +} from "./recipe-editor"; -type Option = { id: string; name: string }; -type Props = { - recipeId: string; - initial: RecipeStructure; - ingredients: Option[]; - recipes: Option[]; - units: Array
+ {message && (

{message}

)} + {autoYield && unconvertedYieldItems() > 0 && ( -

Auto yield excludes {unconvertedYieldItems()} ingredient {unconvertedYieldItems() === 1 ? "amount" : "amounts"} without a weight equivalency.

+

+ Auto yield excludes {unconvertedYieldItems()} ingredient{" "} + {unconvertedYieldItems() === 1 ? "amount" : "amounts"} without a weight + equivalency. +

)} - {data.components.map((component, componentIndex) => ( -
event.preventDefault()} onDrop={() => { - if (dragging?.kind === "component") setData((current) => ({ ...current, components: move(current.components, dragging.index, componentIndex) })); - setDragging(undefined); - }}> - Ingredient header {componentIndex + 1} -
- - updateComponent(componentIndex, (value) => ({ - ...value, - name: event.currentTarget.value, - })) - } - /> - - setDragging({ kind: "component", index: componentIndex })}>⠿ -
- {(component.notes ?? []).map((note, noteIndex) => ( -
- updateComponent(componentIndex, (value) => ({ - ...value, - notes: (value.notes ?? []).map((entry, index) => index === noteIndex ? event.currentTarget.value : entry), - }))} - /> - -
- ))} -
-
Ingredient{percentMode==="standard"?"Standard %":"Baker's %"}Weight
AmountIngredient{percentMode==="standard"?"Standard %":"Baker's %"}
(event.currentTarget as HTMLInputElement).select()} onClick={(event) => (event.currentTarget as HTMLInputElement).select()} onInput={(event) => changeLineQuantity(Number(event.currentTarget.value))}/> - {item.href ? {item.label} : item.label} + + {item.href ? {item.label} : item.label} + {item.attention && ( + setActiveAttentionId(item.id)} + onMouseLeave={() => setActiveAttentionId((prev) => (prev === item.id ? null : prev))} + > + + {activeAttentionId === item.id && ( + + )} + + )} + {item.optional && optional} {item.notes && {item.notes}} {itemWeight(item)==null||percentageBase<=0?"—":`${number(itemWeight(item)!/percentageBase*100)}%`} changeLineQuantity(Number(event.currentTarget.value))}/>{itemWeight(item)==null||percentageBase<=0?"—":`${number(itemWeight(item)!/percentageBase*100)}%`}
- - - {calculatePercent && percentMode === "bakers" && ( - - )} - {calculatePercent && } - - - - - - - - - {component.items.map((item, itemIndex) => { - const label = item.ingredient_id - ? ingredients.find( - (entry) => entry.id === item.ingredient_id, - )?.name - : recipes.find((entry) => entry.id === item.subrecipe_id) - ?.name; - const href = item.ingredient_id - ? `/app/ingredients/${item.ingredient_id}/` - : `/app/recipes/${item.subrecipe_id}/`; - return ( - event.preventDefault()} onDrop={(event) => { - event.stopPropagation(); - if (dragging?.kind === "item" && dragging.component === componentIndex) updateComponent(componentIndex, (value) => ({ ...value, items: move(value.items, dragging.index, itemIndex) })); + +
+
Base%QtyUnitIngredient / RecipeNotes
+ + + + + + + {calculatePercent && percentMode === "bakers" && ( + + )} + {calculatePercent && } + + + + + {data.components.map((component, componentIndex) => ( + + {(data.components.length > 1 || + Boolean(component.name?.trim())) && ( + event.preventDefault()} + onDrop={() => { + if (dragging?.kind === "component") + setData((current) => ({ + ...current, + components: move( + current.components, + dragging.index, + componentIndex + ), + })); setDragging(undefined); - }}> - {calculatePercent && percentMode === "bakers" && ( - - )} - {calculatePercent && ( - - )} - - - - - - - ); - })} - -
QtyUnitIngredient / RecipeNotesBase%
- - updateComponent(componentIndex, (value) => ({ - ...value, - items: value.items.map((line, index) => - index === itemIndex - ? { - ...line, - basis_member: - event.currentTarget.checked, - } - : line, - ), - })) - } - /> - - {percent(item) == null ? ( - - — - - ) : ( - - setPercent( - componentIndex, - itemIndex, - Number(event.currentTarget.value), - ) - } - /> - )} - + }} + > + +
updateComponent(componentIndex, (value) => ({ ...value, - items: value.items.map((line, index) => - index === itemIndex - ? { - ...line, - quantity: Number( - event.currentTarget.value, - ), - } - : line, - ), + name: event.currentTarget.value, })) } /> -
- - - - - {label ?? item.ingredient_id ?? item.subrecipe_id} - - - - {item.subrecipe_id ? "Sub-recipe" : "Ingredient"} - - - - updateComponent(componentIndex, (value) => ({ - ...value, - items: value.items.map((line, index) => - index === itemIndex - ? { ...line, notes: event.currentTarget.value } - : line, - ), - })) - } - /> - - +
- { event.stopPropagation(); setDragging({ kind: "item", component: componentIndex, index: itemIndex }); }}>⠿ - -
-
-
- - -
- - ))} -
- - -
- {showMethod && ( -
-
-

Prep Method {data.steps.length}

-
    - {data.steps.map((step, index) => ( -
  1. event.preventDefault()} onDrop={() => { - if (dragging?.kind === "step") setData((current) => ({ ...current, steps: move(current.steps, dragging.index, index) })); - setDragging(undefined); - }}> -