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 0000000..e4e9187 Binary files /dev/null and b/public/fonts/circular/CircularCustCapNum-Black.woff2 differ diff --git a/public/fonts/circular/CircularCustCapNum-Bold.woff2 b/public/fonts/circular/CircularCustCapNum-Bold.woff2 new file mode 100644 index 0000000..459da90 Binary files /dev/null and b/public/fonts/circular/CircularCustCapNum-Bold.woff2 differ diff --git a/public/fonts/circular/CircularCustCapNum-Book.woff2 b/public/fonts/circular/CircularCustCapNum-Book.woff2 new file mode 100644 index 0000000..b2bbec1 Binary files /dev/null and b/public/fonts/circular/CircularCustCapNum-Book.woff2 differ diff --git a/public/fonts/circular/CircularCustCapNum-Light.woff2 b/public/fonts/circular/CircularCustCapNum-Light.woff2 new file mode 100644 index 0000000..caae556 Binary files /dev/null and b/public/fonts/circular/CircularCustCapNum-Light.woff2 differ diff --git a/public/fonts/circular/CircularCustCapNum-Medium.woff2 b/public/fonts/circular/CircularCustCapNum-Medium.woff2 new file mode 100644 index 0000000..3d2eff4 Binary files /dev/null and b/public/fonts/circular/CircularCustCapNum-Medium.woff2 differ 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); - }}> -