Compare commits
2
Commits
685cf86819
...
e3f45d1256
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3f45d1256 | ||
|
|
f3d81d598c |
@@ -38,11 +38,10 @@ 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.
|
||||
|
||||
All normal recipe, ingredient, nutrition-mapping, and purchasing changes must
|
||||
be written to SQLite through the application or its validated database
|
||||
@@ -51,26 +50,34 @@ 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.
|
||||
|
||||
Create a new local database from the portable culinary dataset:
|
||||
### 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]
|
||||
```
|
||||
|
||||
**Warning:** this command deletes and replaces the existing local database with
|
||||
the contents of `culinary/`. Any newer SQLite-only edits will be lost. There is
|
||||
intentionally no legacy upgrade chain. YAML under `culinary/` is retained as
|
||||
portable seed and interchange data; it is not a second writable source of
|
||||
truth.
|
||||
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, including the distinction between a
|
||||
seed rebuild and transferring current SQLite data, see
|
||||
[Agent handoff](docs/agent-handoff.md).
|
||||
For moving development to another machine, see [Agent handoff](docs/agent-handoff.md).
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
+15
-12
@@ -10,28 +10,31 @@ 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.
|
||||
|
||||
## Start from the committed seed data
|
||||
## 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 db:reset
|
||||
npm run dev:app
|
||||
```
|
||||
|
||||
`db:reset` deletes the local database before importing `culinary/`. Do not run it
|
||||
when a newer SQLite database has been transferred from another installation.
|
||||
### Backups and Transfers
|
||||
|
||||
## Transfer the latest application data
|
||||
The runtime database lives under `var/`, which is intentionally ignored by Git.
|
||||
|
||||
The runtime database and its backups live under `var/`, which is intentionally
|
||||
ignored by Git. A clone therefore contains the application and portable seed,
|
||||
but not necessarily the latest recipe edits.
|
||||
|
||||
To hand off the current live state, create a consistent SQLite backup separately
|
||||
from Git:
|
||||
To backup or hand off the current live state:
|
||||
|
||||
```sh
|
||||
npm run db:backup -- /safe/transfer/recipe-book.sqlite
|
||||
# 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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -1,12 +1,12 @@
|
||||
# Count Sheets & Storage Locations
|
||||
|
||||
Inventory in Formulation is built for high-speed, sheet-to-shelf counting across physical storage locations.
|
||||
Inventory in Formulation is designed for fast, sheet-to-shelf counting across physical kitchen storage locations.
|
||||
|
||||
---
|
||||
|
||||
## 1. Storage Locations
|
||||
|
||||
Organize your kitchen into logical physical zones:
|
||||
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.
|
||||
@@ -17,8 +17,10 @@ Organize your kitchen into logical physical zones:
|
||||
|
||||
## 2. Conducting an Inventory Count
|
||||
|
||||
1. **Start a Count Session**: Open `/app/inventory/` and click **+ New Count Session**.
|
||||
2. **Sheet-to-Shelf Counting**: Open the count sheet and filter by physical location. Items appear in the exact physical order of your storage shelves.
|
||||
3. **Enter On-Hand Quantities**: Type counted units (e.g. `4.5` bags, `12` each, `25` lbs).
|
||||
4. **Live Valuation**: Formulation instantly computes the **Extended Value ($)** for each item based on current vendor purchase costs.
|
||||
5. **Complete & Finalize**: Submit the count to freeze period-end inventory valuation.
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Baker's & Standard Percentages
|
||||
|
||||
In baking and commercial food manufacturing, formulas are often structured using **percentages** to ensure recipe scalability and hydration control.
|
||||
In baking and commercial food manufacturing, formulas use **percentages** to ensure recipe scalability and hydration control.
|
||||
|
||||
---
|
||||
|
||||
@@ -9,18 +9,20 @@ In baking and commercial food manufacturing, formulas are often structured using
|
||||
### 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%**.
|
||||
- Useful for confectionery, dressings, beverages, and general culinary batching.
|
||||
- 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(s) or designated base ingredients are flagged as **Base Members** (`basis_member = true`) and sum to **100%**.
|
||||
- All other ingredients (water/hydration, salt, yeast, sugar, butter) are expressed as a percentage relative to the total flour weight (e.g. 75% hydration water, 2% salt, 1.5% yeast).
|
||||
- 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. Dynamic Interactive Percentage Editing
|
||||
## 2. Using Interactive Percentage Editing
|
||||
|
||||
When editing in the Recipe Structure Editor:
|
||||
1. Enable **Calculate %** and select **Baker's %** or **Standard %**.
|
||||
2. For Baker's %, toggle the **Base** checkbox on the flour/grain ingredients.
|
||||
3. Editing an ingredient's percentage dynamically recalibrates its required physical weight and quantity in grams automatically!
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Scaling, Batching & Yield Calculations
|
||||
|
||||
Formulation is a weight-first formulation engine designed to scale recipes accurately across commercial batch sizes without calculation rounding drift.
|
||||
Formulation is a weight-first formulation engine designed to scale recipes across commercial batch sizes without calculation rounding drift.
|
||||
|
||||
---
|
||||
|
||||
@@ -8,27 +8,34 @@ Formulation is a weight-first formulation engine designed to scale recipes accur
|
||||
|
||||
Recipes can be scaled in two primary modes:
|
||||
|
||||
### A. Batch Multiplier (`x` factor)
|
||||
- Entering a batch multiplier (e.g. `0.5x`, `2x`, `5x`, `10x`) multiplies every ingredient quantity by the exact factor.
|
||||
- Pre-configured quick buttons allow instant one-tap scaling during active kitchen prep.
|
||||
### 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
|
||||
- Specify an exact required total batch yield (e.g., scale a sauce recipe to yield exactly `1,500 grams` or `4.5 quarts`).
|
||||
- Formulation calculates the precise scale factor required to produce that yield based on total recipe weight.
|
||||
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
|
||||
|
||||
When **Auto-Calculate Total Yield** is toggled ON:
|
||||
1. Formulation calculates the weight in grams for every ingredient in the recipe using canonical unit conversion factors or ingredient-specific density measurements.
|
||||
2. The total recipe yield quantity is automatically updated as the exact sum of all ingredient weights.
|
||||
3. If an ingredient cannot be converted to weight (due to a missing UoM volume-to-weight equivalency), an informative notice displays: *"Auto yield excludes N ingredient amounts without a weight equivalency."*
|
||||
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 (e.g. `g`, `kg`, `oz`, `lb`) convert losslessly across all mass units.
|
||||
- Ingredients measured in volume (e.g. `cup`, `tbsp`, `tsp`, `liter`, `ml`) require an ingredient density measurement (e.g. `1 cup = 120g`) to convert to mass.
|
||||
- Cross-dimensional universal conversions without density data are intentionally rejected to maintain strict culinary accuracy.
|
||||
- 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.
|
||||
|
||||
@@ -1,23 +1,45 @@
|
||||
# Sub-recipes & Prep Methods
|
||||
|
||||
Recipes in Formulation can seamlessly nest other recipes as **sub-recipes**, enabling modular batch preparation and accurate cost/nutrition rollup.
|
||||
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
|
||||
|
||||
- **Nesting**: When adding an ingredient to a recipe row, you can select an existing Recipe (tagged with the blue Recipe badge) rather than a raw ingredient.
|
||||
- **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 (e.g. *House Mayonnaise*) automatically propagate up to all dishes that include it (e.g. *Aioli*, *Tartar Sauce*, *Sandwich Spread*).
|
||||
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 allows organizing kitchen instructions into numbered, ordered steps:
|
||||
The **Prep Method** editor organizes kitchen instructions into ordered, sequential steps.
|
||||
|
||||
- **Numbered Instructions**: Step-by-step prep directions with drag-and-drop reordering handles.
|
||||
- **Section Headers**: Add intermediate headings (e.g. `To Sear:`, `Dry Mix:`, `To Garnish:`) by ending the line with a colon `:`.
|
||||
- **Prep Notes**: Add inline notes (e.g. `(Let rest for 15 minutes before carving)`) by wrapping text in parentheses `(...)`.
|
||||
- **Bulk Prep Import**: Paste entire text documents or recipes into the bulk import modal to automatically parse headings, numbered steps, and notes into structured cards.
|
||||
### Step-by-Step Instructions
|
||||
1. In the recipe editor, go to the **Prep Method** section.
|
||||
2. Select **Add Step** (or press <kbd>Enter</kbd>) 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.
|
||||
|
||||
+16
-12
@@ -25,11 +25,9 @@ agent request
|
||||
-> optional explicit export for backup or review
|
||||
```
|
||||
|
||||
Create the initial database from the current portable dataset with Node 22 or
|
||||
newer, then run either application mode:
|
||||
Run either application mode with Node 22 or newer:
|
||||
|
||||
```sh
|
||||
npm run db:reset
|
||||
npm run dev:readonly
|
||||
npm run dev:app
|
||||
```
|
||||
@@ -39,16 +37,22 @@ 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 delete and replace the
|
||||
database from portable YAML. They are intended only for initial setup or an
|
||||
explicit restore. Running either command after application edits can discard
|
||||
newer SQLite-only data.
|
||||
### 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
Generated
+902
-387
File diff suppressed because it is too large
Load Diff
+7
-4
@@ -19,16 +19,19 @@
|
||||
"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:backup": "node scripts/db-backup.mjs",
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/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 <input-path.json> [--replace | --merge]
|
||||
node scripts/backup.mjs validate <input-path.json>
|
||||
|
||||
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 {
|
||||
console.log("Exporting full Formulation database...");
|
||||
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.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);
|
||||
});
|
||||
+15
-4
@@ -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 -- <backup.json>\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]);
|
||||
|
||||
@@ -48,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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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");
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -109,7 +109,10 @@ const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/in
|
||||
{tabs.map((tab)=><a class:list={{active:type===tab.type}} href={tab.href ?? `/app/?type=${tab.type}`}><span class={`workspace-pill-icon ${tab.kind}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d={TYPE_ICONS[tab.kind]} style={TYPE_ICON_TRANSFORMS[tab.kind]?{transform:TYPE_ICON_TRANSFORMS[tab.kind]}:undefined}/></svg></span><span>{tab.label}</span><small>{tab.count}</small></a>)}
|
||||
{type==="ingredient"&&<details class="filter-menu" open={filtering}><summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Filter{filtering?` · ${filteredIngredients.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="ingredient"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="missing_cost" value="1" checked={missingCost}/> Missing cost</label><label><input type="checkbox" name="no_usda" value="1" checked={noUsda}/> No USDA map</label><label><input type="checkbox" name="unused" value="1" checked={unused}/> Unused ingredient</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=ingredient">Clear</a>}</div></form></details>}
|
||||
{type==="recipe"&&<details class="filter-menu" open={filtering}><summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Filter{filtering?` · ${filteredRecipes.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="recipe"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="empty_recipe" value="1" checked={emptyRecipe}/> Empty recipe</label><label><input type="checkbox" name="placeholder_steps" value="1" checked={placeholderSteps}/> Placeholder instructions</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=recipe">Clear</a>}</div></form></details>}
|
||||
{!readOnlyMode&&<a class="archive-link" href="/app/archive/">Archive</a>}
|
||||
{!readOnlyMode&&<>
|
||||
<a class="archive-link" href="/app/archive/">Archive</a>
|
||||
<a class="archive-link" href="/app/settings/">Data Management</a>
|
||||
</>}
|
||||
</nav>
|
||||
{query?<section class="workspace-search-results" aria-live="polite"><p><strong>{searchResults.length}</strong> {searchResults.length===1?"result":"results"} for “{query}”{filteringSearchTypes&&` · ${selectedSearchTypes.length} item ${selectedSearchTypes.length===1?"type":"types"}`}</p>{searchRows.length?<EntityDirectory client:load rows={searchRows} emptyMessage="No items of the selected types match this search." readOnly={readOnlyMode}/>:<div class="empty-state">No items of the selected types match this search.</div>}</section>:<>
|
||||
{type==="ingredient"&&<EntityDirectory client:load rows={ingredientRows} entityType="ingredient" emptyMessage="No ingredients match these filters." readOnly={readOnlyMode}/>}
|
||||
|
||||
@@ -540,7 +540,7 @@ const isCompleted = count.status === "completed";
|
||||
text-align: left;
|
||||
}
|
||||
.count-items-table th {
|
||||
background: #fbfbfb;
|
||||
background: #ffffff;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
|
||||
@@ -317,7 +317,7 @@ database.close();
|
||||
gap: 16px;
|
||||
}
|
||||
.inventory-table-head {
|
||||
background: #fbfbfb;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
@@ -465,7 +465,7 @@ database.close();
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
background: #fbfbfb;
|
||||
background: #ffffff;
|
||||
}
|
||||
.cancel-btn {
|
||||
height: 36px;
|
||||
|
||||
@@ -278,9 +278,33 @@ const recipeTabIcon=(name:string)=>`<span class="recipe-tab-icon"><svg viewBox="
|
||||
</section>
|
||||
) : (
|
||||
<section class="recipe-view-method recipe-tab-panel active" data-recipe-panel="method">
|
||||
<h2>Prep Method <small>{domainRecipe.steps.length}</small></h2>
|
||||
<ol>
|
||||
{domainRecipe.steps.map(step=><li class:list={{placeholder:step.instruction.startsWith("TODO:")}}><strong>{step.order}.</strong><span>{step.instruction}</span></li>)}
|
||||
<h2>Prep Method <small>{domainRecipe.steps.filter(s => !s.instruction.trim().endsWith(":") && !/^\(.+\)$/.test(s.instruction.trim())).length || domainRecipe.steps.length}</small></h2>
|
||||
<ol class="method-steps-list">
|
||||
{(() => {
|
||||
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 (
|
||||
<li class:list={["method-step-item", { "prep-heading": isHeading, "prep-note": isNote, placeholder: text.startsWith("TODO:") }]}>
|
||||
{isHeading ? (
|
||||
<h3 class="method-heading-text">{text}</h3>
|
||||
) : isNote ? (
|
||||
<p class="method-note-text">{text}</p>
|
||||
) : (
|
||||
<>
|
||||
<strong>{stepCount}.</strong>
|
||||
<span>{step.instruction}</span>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
---
|
||||
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();
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout title="Data Management & Backups" immersive>
|
||||
<div class="settings-page">
|
||||
<DetailUtility section="Data Management" sectionHref="/app/settings/" />
|
||||
|
||||
<main class="settings-workspace">
|
||||
<header class="settings-header">
|
||||
<div class="settings-header-left">
|
||||
<h1>Data Management</h1>
|
||||
<p class="settings-subtitle">
|
||||
Export complete database archives or restore backups across all 25 tables with full referential integrity.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="settings-grid">
|
||||
<!-- Database Overview Card -->
|
||||
<section class="settings-card db-status-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon status-icon">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 4.02 2 6.5v11C2 19.98 6.48 22 12 22s10-2.02 10-4.5v-11C22 4.02 17.52 2 12 2zm0 2c4.42 0 8 1.45 8 2.5S16.42 9 12 9 4 7.55 4 6.5 7.58 4 12 4zm8 13.5c0 1.05-3.58 2.5-8 2.5s-8-1.45-8-2.5V14.2c1.94 1.14 4.8 1.8 8 1.8s6.06-.66 8-1.8v3.3zm0-5.5c0 1.05-3.58 2.5-8 2.5s-8-1.45-8-2.5V8.7c1.94 1.14 4.8 1.8 8 1.8s6.06-.66 8-1.8V12z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Active Database Status</h2>
|
||||
<span class="card-caption">SQLite local storage ({summary.total_records_count} total entities)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-metrics-grid">
|
||||
<div class="stat-pill">
|
||||
<span class="stat-label">Recipes</span>
|
||||
<span class="stat-value">{summary.recipes_count}</span>
|
||||
</div>
|
||||
<div class="stat-pill">
|
||||
<span class="stat-label">Ingredients</span>
|
||||
<span class="stat-value">{summary.ingredients_count}</span>
|
||||
</div>
|
||||
<div class="stat-pill">
|
||||
<span class="stat-label">Purchase Items</span>
|
||||
<span class="stat-value">{summary.purchase_items_count}</span>
|
||||
</div>
|
||||
<div class="stat-pill">
|
||||
<span class="stat-label">Recipe Books</span>
|
||||
<span class="stat-value">{summary.collections_count}</span>
|
||||
</div>
|
||||
<div class="stat-pill">
|
||||
<span class="stat-label">Inventory Counts</span>
|
||||
<span class="stat-value">{summary.inventory_counts_count}</span>
|
||||
</div>
|
||||
<div class="stat-pill">
|
||||
<span class="stat-label">Storage Locations</span>
|
||||
<span class="stat-value">{summary.inventory_locations_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Export Backup Card -->
|
||||
<section class="settings-card export-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon export-icon">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
|
||||
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM17 13l-5 5-5-5h3V9h4v4h3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Export Database Backup</h2>
|
||||
<span class="card-caption">Download full-fidelity JSON archive</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="card-description">
|
||||
Generates a portable backup bundle containing 100% of your formulations, ingredients, purchasing catalogs, cost histories, and inventory count sessions.
|
||||
</p>
|
||||
|
||||
<div class="card-actions">
|
||||
<a href="/api/app/backup/export" download class="btn-primary export-btn" id="exportBackupBtn">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||||
</svg>
|
||||
<span>Export Full Backup (.json)</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Import & Restore Card -->
|
||||
<section class="settings-card import-card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon import-icon">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
|
||||
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Import & Restore Backup</h2>
|
||||
<span class="card-caption">Restore database from backup JSON</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{readOnlyMode ? (
|
||||
<div class="notice readonly-notice">
|
||||
Database restoration is disabled in read-only demonstration mode.
|
||||
</div>
|
||||
) : (
|
||||
<div class="import-interactive-zone">
|
||||
<div class="drop-zone" id="backupDropZone">
|
||||
<input type="file" id="backupFileInput" accept=".json" class="file-input-hidden" />
|
||||
<svg viewBox="0 0 24 24" width="36" height="36" fill="currentColor" class="drop-icon">
|
||||
<path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z"/>
|
||||
</svg>
|
||||
<p class="drop-title">Select or drag a Formulation backup (.json) file</p>
|
||||
<button type="button" class="btn-secondary" id="browseFileBtn">Browse File</button>
|
||||
</div>
|
||||
|
||||
<!-- Preview Dialog (Hidden until file selected) -->
|
||||
<div class="import-preview-box" id="importPreviewBox" style="display: none;">
|
||||
<div class="preview-header">
|
||||
<h3>Backup File Validation</h3>
|
||||
<span class="badge-valid" id="validationBadge">Valid</span>
|
||||
</div>
|
||||
|
||||
<div class="preview-metrics" id="previewMetrics">
|
||||
<!-- Injected via JavaScript -->
|
||||
</div>
|
||||
|
||||
<div class="mode-selection-group">
|
||||
<label class="mode-radio">
|
||||
<input type="radio" name="importMode" value="replace" checked />
|
||||
<div class="mode-text">
|
||||
<strong>Clean Restore (Replace)</strong>
|
||||
<span>Safely replaces all database records with this backup snapshot inside an atomic transaction.</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="mode-radio">
|
||||
<input type="radio" name="importMode" value="merge" />
|
||||
<div class="mode-text">
|
||||
<strong>Smart Merge (Upsert)</strong>
|
||||
<span>Updates existing items and adds new items without deleting existing data.</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="preview-actions">
|
||||
<button type="button" class="btn-primary" id="confirmImportBtn">
|
||||
Restore Database Now
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" id="cancelImportBtn">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Banner -->
|
||||
<div class="import-status-banner" id="importStatusBanner" style="display: none;"></div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
const fileInput = document.getElementById("backupFileInput") as HTMLInputElement | null;
|
||||
const browseBtn = document.getElementById("browseFileBtn") as HTMLButtonElement | null;
|
||||
const dropZone = document.getElementById("backupDropZone") as HTMLDivElement | null;
|
||||
const previewBox = document.getElementById("importPreviewBox") as HTMLDivElement | null;
|
||||
const previewMetrics = document.getElementById("previewMetrics") as HTMLDivElement | null;
|
||||
const validationBadge = document.getElementById("validationBadge") as HTMLSpanElement | null;
|
||||
const confirmBtn = document.getElementById("confirmImportBtn") as HTMLButtonElement | null;
|
||||
const cancelBtn = document.getElementById("cancelImportBtn") as HTMLButtonElement | null;
|
||||
const statusBanner = document.getElementById("importStatusBanner") as HTMLDivElement | null;
|
||||
|
||||
let loadedBundle: any = null;
|
||||
|
||||
if (browseBtn && fileInput) {
|
||||
browseBtn.addEventListener("click", () => fileInput.click());
|
||||
}
|
||||
|
||||
if (dropZone && fileInput) {
|
||||
dropZone.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add("dragover");
|
||||
});
|
||||
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("dragover"));
|
||||
dropZone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("dragover");
|
||||
if (e.dataTransfer?.files.length) {
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
});
|
||||
fileInput.addEventListener("change", () => {
|
||||
if (fileInput.files?.length) {
|
||||
handleFile(fileInput.files[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener("click", resetImport);
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
if (!file.name.endsWith(".json")) {
|
||||
showStatus("Please select a valid .json file.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showStatus("Validating backup file...", "info");
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
const res = await fetch("/api/app/backup/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(parsed),
|
||||
});
|
||||
|
||||
const validation = await res.json();
|
||||
|
||||
if (!validation.valid) {
|
||||
showStatus(`Invalid backup file: ${validation.errors.join("; ")}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
loadedBundle = parsed;
|
||||
hideStatus();
|
||||
|
||||
if (previewBox && previewMetrics && validationBadge) {
|
||||
dropZone!.style.display = "none";
|
||||
previewBox.style.display = "block";
|
||||
|
||||
const s = validation.summary;
|
||||
previewMetrics.innerHTML = `
|
||||
<div class="metric-row"><strong>Exported At:</strong> <span>${new Date(parsed.exported_at).toLocaleString()}</span></div>
|
||||
<div class="metric-row"><strong>Format Version:</strong> <span>${parsed.format_version}</span></div>
|
||||
<div class="metric-row"><strong>Recipes:</strong> <span>${s.recipes_count}</span></div>
|
||||
<div class="metric-row"><strong>Ingredients:</strong> <span>${s.ingredients_count}</span></div>
|
||||
<div class="metric-row"><strong>Purchase Items:</strong> <span>${s.purchase_items_count}</span></div>
|
||||
<div class="metric-row"><strong>Inventory Counts:</strong> <span>${s.inventory_counts_count}</span></div>
|
||||
<div class="metric-row"><strong>Total Records:</strong> <span>${s.total_records_count}</span></div>
|
||||
`;
|
||||
}
|
||||
} catch (err) {
|
||||
showStatus(`Failed to read file: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmBtn) {
|
||||
confirmBtn.addEventListener("click", async () => {
|
||||
if (!loadedBundle) return;
|
||||
|
||||
const modeRadio = document.querySelector('input[name="importMode"]:checked') as HTMLInputElement | null;
|
||||
const mode = modeRadio?.value ?? "replace";
|
||||
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = "Restoring Database...";
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/app/backup/import?mode=${mode}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(loadedBundle),
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (!res.ok || !result.success) {
|
||||
showStatus(`Import failed: ${result.error || "Unknown error"}`, "error");
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = "Restore Database Now";
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus(result.message + " Reloading workspace...", "success");
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
showStatus(`Import error: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = "Restore Database Now";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resetImport() {
|
||||
loadedBundle = null;
|
||||
if (fileInput) fileInput.value = "";
|
||||
if (previewBox) previewBox.style.display = "none";
|
||||
if (dropZone) dropZone.style.display = "flex";
|
||||
hideStatus();
|
||||
}
|
||||
|
||||
function showStatus(message: string, type: "info" | "error" | "success") {
|
||||
if (!statusBanner) return;
|
||||
statusBanner.className = `import-status-banner status-${type}`;
|
||||
statusBanner.textContent = message;
|
||||
statusBanner.style.display = "block";
|
||||
}
|
||||
|
||||
function hideStatus() {
|
||||
if (statusBanner) statusBanner.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.settings-page {
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-workspace {
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 64px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.settings-header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #050841;
|
||||
margin: 0 0 6px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.settings-subtitle {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.export-icon {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.import-icon {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #050841;
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.card-caption {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.stats-metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.stat-pill {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #050841;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 10px 18px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 9px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #cbd5e1;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 36px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: #fafafa;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.drop-zone.dragover {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.drop-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.file-input-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.import-preview-box {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.preview-header h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #050841;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.badge-valid {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.preview-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
:global(.metric-row) {
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
:global(.metric-row strong) {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.mode-selection-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mode-radio {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mode-radio input {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.mode-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mode-text strong {
|
||||
font-size: 14px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.mode-text span {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.import-status-banner {
|
||||
margin-top: 14px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.readonly-notice {
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
border: 1px solid #fef3c7;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -27,6 +27,9 @@ if (pathname.startsWith("/app/inventory/")) {
|
||||
} 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/";
|
||||
|
||||
@@ -127,7 +127,10 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
|
||||
{calculatePercent&&percentMode==="bakers"&&baseItems.length>0&&<section class="bakers-base-summary"><strong>Base</strong><div>{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 <p><span><input aria-label={`${item.label} base quantity`} type="number" min="0" step="any" value={roundForDisplay(displayQuantity)} 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)}}/><select aria-label={`${item.label} base unit`} value={displayUnitId} onChange={(event)=>setLineUnits((current)=>({...current,[item.id]:event.currentTarget.value}))}>{itemUnits(item,units).map(unit=><option value={unit.id}>{unit.symbol}</option>)}</select></span><a href={item.href}>{item.label}</a></p>})}</div></section>}
|
||||
{components.map((component) => (
|
||||
<div class="formula-component" key={component.id}>
|
||||
{components.length > 1 && <h3>{component.name}</h3>}
|
||||
{Boolean(component.name?.trim()) && <h3 class="formula-component-heading">{component.name}</h3>}
|
||||
{(component.notes ?? []).filter(Boolean).map((note, noteIdx) => (
|
||||
<p key={noteIdx} class="formula-component-note">{note}</p>
|
||||
))}
|
||||
<div class="table-wrap">
|
||||
<table class="recipe-ingredients">
|
||||
<thead><tr><th>Amount</th><th>Ingredient</th>{calculatePercent&&<th>{percentMode==="standard"?"Standard %":"Baker's %"}</th>}</tr></thead>
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { exportDatabase } from "./export-database";
|
||||
import { importDatabase } from "./import-database";
|
||||
import { validateBackupBundle } from "./validate-backup";
|
||||
import type { FormulationBackupBundle } from "./types";
|
||||
|
||||
function createTestDatabase(): DatabaseSync {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
const root = path.resolve(__dirname, "../../..");
|
||||
const migrationsDir = path.join(root, "migrations");
|
||||
const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort();
|
||||
for (const file of files) {
|
||||
const sql = fs.readFileSync(path.join(migrationsDir, file), "utf8");
|
||||
db.exec(sql);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("Database Backup & Restore System", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
|
||||
// Populate baseline sample data
|
||||
db.exec(`
|
||||
INSERT INTO units (id, name, symbol, dimension, system) VALUES
|
||||
('gram', 'Gram', 'g', 'mass', 'metric'),
|
||||
('kilogram', 'Kilogram', 'kg', 'mass', 'metric'),
|
||||
('each', 'Each', 'ea', 'count', 'customary');
|
||||
|
||||
INSERT INTO equipment (id, name, category, notes) VALUES
|
||||
('whisk', 'Whisk', 'hand_tool', 'Stainless steel');
|
||||
|
||||
INSERT INTO prep_actions (id, name, action_type, default_yield_factor) VALUES
|
||||
('dice', 'Dice', 'cut', 0.95);
|
||||
|
||||
INSERT INTO ingredients (id, schema_version, name, status, categories_json, tags_json, source_json) VALUES
|
||||
('flour', 2, 'Flour', 'active', '["dry"]', '["baking"]', '{}'),
|
||||
('sugar', 2, 'Sugar', 'active', '["dry"]', '["sweet"]', '{}');
|
||||
|
||||
INSERT INTO ingredient_aliases (ingredient_id, name, kind) VALUES
|
||||
('flour', 'All Purpose Flour', 'common');
|
||||
|
||||
INSERT INTO ingredient_prep_actions (ingredient_id, action_id, yield_factor) VALUES
|
||||
('flour', 'dice', 0.95);
|
||||
|
||||
INSERT INTO ingredient_measure_conversions (ingredient_id, id, from_quantity, from_unit_id, to_quantity, to_unit_id, source_json) VALUES
|
||||
('flour', 'conv_1', 1, 'each', 120, 'gram', '{}');
|
||||
|
||||
INSERT INTO recipes (id, schema_version, save_version, title, yield_quantity, yield_unit_id, source_json) VALUES
|
||||
('cake', 2, 1, 'Simple Cake', 500, 'gram', '{}');
|
||||
|
||||
INSERT INTO recipe_components (recipe_id, id, position, name) VALUES
|
||||
('cake', 'main', 1, 'Main');
|
||||
|
||||
INSERT INTO recipe_items (recipe_id, component_id, id, position, ingredient_id, quantity, unit_id) VALUES
|
||||
('cake', 'main', 'item_1', 1, 'flour', 300, 'gram'),
|
||||
('cake', 'main', 'item_2', 2, 'sugar', 200, 'gram');
|
||||
|
||||
INSERT INTO item_prep_actions (recipe_id, item_id, position, action_id, yield_factor) VALUES
|
||||
('cake', 'item_1', 1, 'dice', 1.0);
|
||||
|
||||
INSERT INTO recipe_steps (recipe_id, id, position, instruction) VALUES
|
||||
('cake', 'step_1', 1, 'Mix dry ingredients together.');
|
||||
|
||||
INSERT INTO purchase_items (id, ingredient_id, name, status, package_quantity, package_unit_id) VALUES
|
||||
('pi_flour_bag', 'flour', '50lb Flour Bag', 'active', 50, 'gram');
|
||||
|
||||
INSERT INTO price_observations (purchase_item_id, effective_at, currency, amount, source_json) VALUES
|
||||
('pi_flour_bag', '2026-08-01', 'USD', 24.50, '{}');
|
||||
|
||||
INSERT INTO collections (id, name, source_json) VALUES
|
||||
('bakery_menu', 'Bakery Menu', '{}');
|
||||
|
||||
INSERT INTO collection_recipes (collection_id, recipe_id, position) VALUES
|
||||
('bakery_menu', 'cake', 1);
|
||||
|
||||
INSERT INTO inventory_locations (id, name, position) VALUES
|
||||
('loc_dry', 'Dry Storage', 1);
|
||||
|
||||
INSERT INTO inventory_counts (id, title, counted_at, created_at) VALUES
|
||||
('count_aug', 'August Inventory', '2026-08-01', '2026-08-01');
|
||||
|
||||
INSERT INTO inventory_count_items (count_id, location_id, ingredient_id, quantity, unit_id, unit_cost, extended_cost) VALUES
|
||||
('count_aug', 'loc_dry', 'flour', 10, 'gram', 0.5, 5.0);
|
||||
`);
|
||||
});
|
||||
|
||||
it("exports all tables into a valid FormulationBackupBundle", () => {
|
||||
const bundle = exportDatabase(db);
|
||||
|
||||
expect(bundle.format_version).toBe("1.0.0");
|
||||
expect(bundle.app_version).toBe("2.0.0");
|
||||
expect(bundle.summary.units_count).toBe(3);
|
||||
expect(bundle.summary.ingredients_count).toBe(2);
|
||||
expect(bundle.summary.recipes_count).toBe(1);
|
||||
expect(bundle.summary.purchase_items_count).toBe(1);
|
||||
expect(bundle.summary.collections_count).toBe(1);
|
||||
expect(bundle.summary.inventory_locations_count).toBe(6); // 5 from baseline seed + 1 custom
|
||||
expect(bundle.summary.inventory_counts_count).toBe(1);
|
||||
|
||||
expect(bundle.data.recipes[0].items.length).toBe(2);
|
||||
expect(bundle.data.recipes[0].items[0].prep_actions.length).toBe(1);
|
||||
expect(bundle.data.ingredients[0].aliases.length).toBe(1);
|
||||
expect(bundle.data.purchase_items[0].prices.length).toBe(1);
|
||||
expect(bundle.data.inventory_counts[0].items.length).toBe(1);
|
||||
});
|
||||
|
||||
it("validates backup bundle structure correctly", () => {
|
||||
const bundle = exportDatabase(db);
|
||||
const result = validateBackupBundle(bundle);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.length).toBe(0);
|
||||
|
||||
const invalid = validateBackupBundle({ format_version: "2.0.0", data: {} });
|
||||
expect(invalid.valid).toBe(false);
|
||||
expect(invalid.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("performs full roundtrip export -> wipe -> import without data loss", () => {
|
||||
const originalBundle = exportDatabase(db);
|
||||
|
||||
const targetDb = createTestDatabase();
|
||||
// Wipe target db completely
|
||||
targetDb.exec("DELETE FROM units;");
|
||||
|
||||
const result = importDatabase(targetDb, originalBundle, { mode: "replace", rebuildProjections: false });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.summary.recipes_count).toBe(1);
|
||||
|
||||
const restoredBundle = exportDatabase(targetDb);
|
||||
expect(restoredBundle.summary.units_count).toBe(originalBundle.summary.units_count);
|
||||
expect(restoredBundle.summary.ingredients_count).toBe(originalBundle.summary.ingredients_count);
|
||||
expect(restoredBundle.summary.recipes_count).toBe(originalBundle.summary.recipes_count);
|
||||
expect(restoredBundle.data.recipes[0].title).toBe("Simple Cake");
|
||||
expect(restoredBundle.data.ingredients[0].aliases[0].name).toBe("All Purpose Flour");
|
||||
expect(restoredBundle.data.inventory_counts[0].items[0].quantity).toBe(10);
|
||||
});
|
||||
|
||||
it("supports merge mode without wiping existing unmentioned records", () => {
|
||||
const targetDb = createTestDatabase();
|
||||
targetDb.exec("INSERT INTO units (id, name, symbol, dimension, system) VALUES ('meter', 'Meter', 'm', 'length', 'metric');");
|
||||
|
||||
const bundle = exportDatabase(db);
|
||||
const result = importDatabase(targetDb, bundle, { mode: "merge", rebuildProjections: false });
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const units = targetDb.prepare("SELECT id FROM units ORDER BY id").all() as Array<{ id: string }>;
|
||||
expect(units.some((u) => u.id === "meter")).toBe(true);
|
||||
expect(units.some((u) => u.id === "gram")).toBe(true);
|
||||
});
|
||||
|
||||
it("rolls back transaction atomically if an error occurs during import", () => {
|
||||
const targetDb = createTestDatabase();
|
||||
targetDb.exec("INSERT INTO units (id, name, symbol, dimension, system) VALUES ('gram', 'Gram', 'g', 'mass', 'metric');");
|
||||
|
||||
const badBundle: FormulationBackupBundle = {
|
||||
$schema: "https://formulation.app/schemas/backup-v1.json",
|
||||
format_version: "1.0.0",
|
||||
app_version: "2.0.0",
|
||||
exported_at: new Date().toISOString(),
|
||||
summary: {
|
||||
units_count: 1, equipment_count: 0, prep_actions_count: 0,
|
||||
ingredients_count: 1, recipes_count: 0, purchase_items_count: 0,
|
||||
price_observations_count: 0, source_mappings_count: 0, collections_count: 0,
|
||||
inventory_locations_count: 0, inventory_counts_count: 0, total_records_count: 2
|
||||
},
|
||||
data: {
|
||||
units: [{ id: "gram", name: "Gram", symbol: "g", dimension: "mass", system: "metric", base_unit_id: null, factor: null, offset: null }],
|
||||
equipment: [],
|
||||
prep_actions: [],
|
||||
ingredients: [{
|
||||
id: "flour", schema_version: 2, name: "Flour", status: "active",
|
||||
categories_json: "INVALID JSON SYNTAX", tags_json: "[]", description: null, source_json: "{}", deleted_at: null,
|
||||
aliases: [], prep_actions: [], measure_conversions: [], density_measurements: []
|
||||
}],
|
||||
recipes: [{
|
||||
id: "bad_recipe", schema_version: 2, save_version: 1, title: "Bad", summary: null,
|
||||
categories_json: "[]", tags_json: "[]", yield_quantity: null as any, yield_unit_id: "gram",
|
||||
yield_servings: null, yield_basis: null, scaling_mode: null, scaling_basis_id: null,
|
||||
scaling_basis_quantity: null, scaling_basis_unit_id: null, notes_json: "[]",
|
||||
source_json: "{}", auto_yield: 0, station: null, deleted_at: null, cover_media_url: null,
|
||||
equipment_ids: [], components: [], items: [], steps: [], measure_conversions: [], media: []
|
||||
}],
|
||||
purchase_items: [],
|
||||
source_mappings: [],
|
||||
collections: [],
|
||||
inventory_locations: [],
|
||||
inventory_counts: [],
|
||||
}
|
||||
};
|
||||
|
||||
// Attempt importing badBundle which should fail validation/execution
|
||||
expect(() => {
|
||||
importDatabase(targetDb, badBundle, { mode: "replace", rebuildProjections: false });
|
||||
}).toThrow();
|
||||
|
||||
// Verify existing units record remains intact
|
||||
const units = targetDb.prepare("SELECT id FROM units").all() as Array<{ id: string }>;
|
||||
expect(units.length).toBe(1);
|
||||
expect(units[0].id).toBe("gram");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type {
|
||||
FormulationBackupBundle,
|
||||
UnitBackupRecord,
|
||||
EquipmentBackupRecord,
|
||||
PrepActionBackupRecord,
|
||||
IngredientBackupRecord,
|
||||
RecipeBackupRecord,
|
||||
PurchaseItemBackupRecord,
|
||||
SourceMappingBackupRecord,
|
||||
CollectionBackupRecord,
|
||||
InventoryLocationBackupRecord,
|
||||
InventoryCountBackupRecord,
|
||||
BackupSummary,
|
||||
} from "./types";
|
||||
|
||||
export function exportDatabase(database: DatabaseSync): FormulationBackupBundle {
|
||||
// 1. Units
|
||||
const units = database
|
||||
.prepare("SELECT * FROM units ORDER BY dimension, id")
|
||||
.all() as unknown as UnitBackupRecord[];
|
||||
|
||||
// 2. Equipment
|
||||
const equipment = database
|
||||
.prepare("SELECT * FROM equipment ORDER BY id")
|
||||
.all() as unknown as EquipmentBackupRecord[];
|
||||
|
||||
// 3. Prep Actions
|
||||
const prepActions = database
|
||||
.prepare("SELECT * FROM prep_actions ORDER BY id")
|
||||
.all() as unknown as PrepActionBackupRecord[];
|
||||
|
||||
// 4. Ingredients & sub-tables
|
||||
const aliasStmt = database.prepare(
|
||||
"SELECT name, kind FROM ingredient_aliases WHERE ingredient_id = ? ORDER BY name"
|
||||
);
|
||||
const ingredientPrepStmt = database.prepare(
|
||||
"SELECT action_id, yield_factor, notes FROM ingredient_prep_actions WHERE ingredient_id = ? ORDER BY action_id"
|
||||
);
|
||||
const ingredientConvStmt = database.prepare(
|
||||
"SELECT id, from_quantity, from_unit_id, to_quantity, to_unit_id, state, source_json FROM ingredient_measure_conversions WHERE ingredient_id = ? ORDER BY id"
|
||||
);
|
||||
const ingredientDensityStmt = database.prepare(
|
||||
"SELECT id, mass_quantity, mass_unit_id, volume_quantity, volume_unit_id, temperature_c, state, source_json FROM ingredient_density_measurements WHERE ingredient_id = ? ORDER BY id"
|
||||
);
|
||||
|
||||
const rawIngredients = database
|
||||
.prepare("SELECT * FROM ingredients ORDER BY id")
|
||||
.all() as unknown as Array<{
|
||||
id: string;
|
||||
schema_version: number;
|
||||
name: string;
|
||||
status: string;
|
||||
categories_json: string;
|
||||
tags_json: string;
|
||||
description: string | null;
|
||||
source_json: string;
|
||||
deleted_at: string | null;
|
||||
}>;
|
||||
|
||||
const ingredients: IngredientBackupRecord[] = rawIngredients.map((ing) => ({
|
||||
...ing,
|
||||
aliases: aliasStmt.all(ing.id) as any[],
|
||||
prep_actions: ingredientPrepStmt.all(ing.id) as any[],
|
||||
measure_conversions: ingredientConvStmt.all(ing.id) as any[],
|
||||
density_measurements: ingredientDensityStmt.all(ing.id) as any[],
|
||||
}));
|
||||
|
||||
// 5. Recipes & sub-tables
|
||||
const componentStmt = database.prepare(
|
||||
"SELECT id, position, name, notes_json FROM recipe_components WHERE recipe_id = ? ORDER BY position"
|
||||
);
|
||||
const itemStmt = database.prepare(
|
||||
"SELECT component_id, id, position, ingredient_id, subrecipe_id, quantity, unit_id, percentage, basis_member, optional, notes, nutrition_retention_factor FROM recipe_items WHERE recipe_id = ? ORDER BY component_id, position"
|
||||
);
|
||||
const itemPrepStmt = database.prepare(
|
||||
"SELECT position, action_id, yield_factor, notes FROM item_prep_actions WHERE recipe_id = ? AND item_id = ? ORDER BY position"
|
||||
);
|
||||
const stepStmt = database.prepare(
|
||||
"SELECT id, position, instruction, critical_control_point FROM recipe_steps WHERE recipe_id = ? ORDER BY position"
|
||||
);
|
||||
const stepEquipmentStmt = database.prepare(
|
||||
"SELECT equipment_id FROM step_equipment WHERE recipe_id = ? AND step_id = ? ORDER BY equipment_id"
|
||||
);
|
||||
const recipeEquipmentStmt = database.prepare(
|
||||
"SELECT equipment_id FROM recipe_equipment WHERE recipe_id = ? ORDER BY equipment_id"
|
||||
);
|
||||
const recipeConvStmt = database.prepare(
|
||||
"SELECT id, from_quantity, from_unit_id, to_quantity, to_unit_id, notes, source_json FROM recipe_measure_conversions WHERE recipe_id = ? ORDER BY id"
|
||||
);
|
||||
const recipeMediaStmt = database.prepare(
|
||||
"SELECT id, step_id, media_type, url, caption, position FROM recipe_media WHERE recipe_id = ? ORDER BY position"
|
||||
);
|
||||
|
||||
const rawRecipes = database
|
||||
.prepare("SELECT * FROM recipes ORDER BY id")
|
||||
.all() as unknown as Array<{
|
||||
id: string;
|
||||
schema_version: number;
|
||||
save_version: number;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
categories_json: string;
|
||||
tags_json: string;
|
||||
yield_quantity: number;
|
||||
yield_unit_id: string;
|
||||
yield_servings: number | null;
|
||||
yield_basis: string | null;
|
||||
scaling_mode: string | null;
|
||||
scaling_basis_id: string | null;
|
||||
scaling_basis_quantity: number | null;
|
||||
scaling_basis_unit_id: string | null;
|
||||
notes_json: string;
|
||||
source_json: string;
|
||||
auto_yield: number;
|
||||
station: string | null;
|
||||
deleted_at: string | null;
|
||||
cover_media_url: string | null;
|
||||
}>;
|
||||
|
||||
const recipes: RecipeBackupRecord[] = rawRecipes.map((rec) => {
|
||||
const rawItems = itemStmt.all(rec.id) as any[];
|
||||
const items = rawItems.map((item) => ({
|
||||
...item,
|
||||
prep_actions: itemPrepStmt.all(rec.id, item.id) as any[],
|
||||
}));
|
||||
|
||||
const rawSteps = stepStmt.all(rec.id) as any[];
|
||||
const steps = rawSteps.map((step) => ({
|
||||
...step,
|
||||
equipment_ids: (stepEquipmentStmt.all(rec.id, step.id) as any[]).map(
|
||||
(r) => r.equipment_id
|
||||
),
|
||||
}));
|
||||
|
||||
const equipment_ids = (recipeEquipmentStmt.all(rec.id) as any[]).map(
|
||||
(r) => r.equipment_id
|
||||
);
|
||||
|
||||
return {
|
||||
...rec,
|
||||
equipment_ids,
|
||||
components: componentStmt.all(rec.id) as any[],
|
||||
items,
|
||||
steps,
|
||||
measure_conversions: recipeConvStmt.all(rec.id) as any[],
|
||||
media: recipeMediaStmt.all(rec.id) as any[],
|
||||
};
|
||||
});
|
||||
|
||||
// 6. Purchase items & price observations
|
||||
const priceStmt = database.prepare(
|
||||
"SELECT effective_at, currency, amount, source_json FROM price_observations WHERE purchase_item_id = ? ORDER BY effective_at DESC"
|
||||
);
|
||||
const rawPurchaseItems = database
|
||||
.prepare("SELECT * FROM purchase_items ORDER BY id")
|
||||
.all() as unknown as Array<{
|
||||
id: string;
|
||||
ingredient_id: string;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
supplier_id: string | null;
|
||||
supplier_sku: string | null;
|
||||
status: string;
|
||||
package_quantity: number;
|
||||
package_unit_id: string;
|
||||
units_per_case: number;
|
||||
usable_yield_factor: number;
|
||||
deleted_at: string | null;
|
||||
}>;
|
||||
|
||||
let totalPricesCount = 0;
|
||||
const purchase_items: PurchaseItemBackupRecord[] = rawPurchaseItems.map(
|
||||
(pi) => {
|
||||
const prices = priceStmt.all(pi.id) as any[];
|
||||
totalPricesCount += prices.length;
|
||||
return {
|
||||
...pi,
|
||||
prices,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// 7. Source mappings
|
||||
const source_mappings = database
|
||||
.prepare("SELECT * FROM source_mappings ORDER BY id")
|
||||
.all() as unknown as SourceMappingBackupRecord[];
|
||||
|
||||
// 8. Collections & collection recipes
|
||||
const collRecipeStmt = database.prepare(
|
||||
"SELECT recipe_id, position FROM collection_recipes WHERE collection_id = ? ORDER BY position"
|
||||
);
|
||||
const rawCollections = database
|
||||
.prepare("SELECT * FROM collections ORDER BY id")
|
||||
.all() as unknown as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
source_json: string;
|
||||
deleted_at: string | null;
|
||||
}>;
|
||||
|
||||
const collections: CollectionBackupRecord[] = rawCollections.map((coll) => ({
|
||||
...coll,
|
||||
recipes: collRecipeStmt.all(coll.id) as any[],
|
||||
}));
|
||||
|
||||
// 9. Inventory locations
|
||||
let inventory_locations: InventoryLocationBackupRecord[] = [];
|
||||
try {
|
||||
inventory_locations = database
|
||||
.prepare("SELECT id, name, position, deleted_at FROM inventory_locations ORDER BY position, id")
|
||||
.all() as unknown as InventoryLocationBackupRecord[];
|
||||
} catch {}
|
||||
|
||||
// 10. Inventory counts & count items
|
||||
let inventory_counts: InventoryCountBackupRecord[] = [];
|
||||
try {
|
||||
const countItemStmt = database.prepare(
|
||||
"SELECT location_id, ingredient_id, quantity, unit_id, unit_cost, extended_cost FROM inventory_count_items WHERE count_id = ? ORDER BY location_id, ingredient_id"
|
||||
);
|
||||
const rawCounts = database
|
||||
.prepare("SELECT id, title, counted_at, status, notes, created_at, deleted_at FROM inventory_counts ORDER BY counted_at DESC, id")
|
||||
.all() as unknown as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
counted_at: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
}>;
|
||||
|
||||
inventory_counts = rawCounts.map((count) => ({
|
||||
...count,
|
||||
items: countItemStmt.all(count.id) as any[],
|
||||
}));
|
||||
} catch {}
|
||||
|
||||
const summary: BackupSummary = {
|
||||
units_count: units.length,
|
||||
equipment_count: equipment.length,
|
||||
prep_actions_count: prepActions.length,
|
||||
ingredients_count: ingredients.length,
|
||||
recipes_count: recipes.length,
|
||||
purchase_items_count: purchase_items.length,
|
||||
price_observations_count: totalPricesCount,
|
||||
source_mappings_count: source_mappings.length,
|
||||
collections_count: collections.length,
|
||||
inventory_locations_count: inventory_locations.length,
|
||||
inventory_counts_count: inventory_counts.length,
|
||||
total_records_count:
|
||||
units.length +
|
||||
equipment.length +
|
||||
prepActions.length +
|
||||
ingredients.length +
|
||||
recipes.length +
|
||||
purchase_items.length +
|
||||
source_mappings.length +
|
||||
collections.length +
|
||||
inventory_locations.length +
|
||||
inventory_counts.length,
|
||||
};
|
||||
|
||||
return {
|
||||
$schema: "https://formulation.app/schemas/backup-v1.json",
|
||||
format_version: "1.0.0",
|
||||
app_version: "2.0.0",
|
||||
exported_at: new Date().toISOString(),
|
||||
summary,
|
||||
data: {
|
||||
units,
|
||||
equipment,
|
||||
prep_actions: prepActions,
|
||||
ingredients,
|
||||
recipes,
|
||||
purchase_items,
|
||||
source_mappings,
|
||||
collections,
|
||||
inventory_locations,
|
||||
inventory_counts,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { validateBackupBundle } from "./validate-backup.ts";
|
||||
import { writeSiteProjection } from "../../../scripts/lib/site-projection.mjs";
|
||||
import type {
|
||||
FormulationBackupBundle,
|
||||
ImportOptions,
|
||||
ImportResult,
|
||||
} from "./types.ts";
|
||||
|
||||
export function importDatabase(
|
||||
database: DatabaseSync,
|
||||
bundle: FormulationBackupBundle,
|
||||
options: ImportOptions = {}
|
||||
): ImportResult {
|
||||
const mode = options.mode ?? "replace";
|
||||
const validation = validateBackupBundle(bundle);
|
||||
|
||||
if (!validation.valid) {
|
||||
throw new Error(
|
||||
`Backup validation failed: ${validation.errors.join("; ")}`
|
||||
);
|
||||
}
|
||||
|
||||
const { data, summary } = bundle;
|
||||
|
||||
database.exec("BEGIN IMMEDIATE;");
|
||||
try {
|
||||
database.exec("PRAGMA defer_foreign_keys = ON;");
|
||||
|
||||
if (mode === "replace") {
|
||||
// Clear tables in reverse dependency order
|
||||
const tables = [
|
||||
"inventory_count_items",
|
||||
"inventory_counts",
|
||||
"inventory_locations",
|
||||
"collection_recipes",
|
||||
"collections",
|
||||
"source_mappings",
|
||||
"price_observations",
|
||||
"purchase_items",
|
||||
"recipe_media",
|
||||
"recipe_measure_conversions",
|
||||
"step_equipment",
|
||||
"recipe_equipment",
|
||||
"recipe_steps",
|
||||
"item_prep_actions",
|
||||
"recipe_items",
|
||||
"recipe_components",
|
||||
"recipes",
|
||||
"ingredient_density_measurements",
|
||||
"ingredient_measure_conversions",
|
||||
"ingredient_prep_actions",
|
||||
"ingredient_aliases",
|
||||
"ingredients",
|
||||
"prep_actions",
|
||||
"equipment",
|
||||
"units",
|
||||
];
|
||||
|
||||
for (const table of tables) {
|
||||
try {
|
||||
database.exec(`DELETE FROM ${table};`);
|
||||
} catch {
|
||||
// Table might not exist in old migration environments
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Units
|
||||
const unitStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO units(id, name, symbol, dimension, system, base_unit_id, factor, offset) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
for (const u of data.units ?? []) {
|
||||
unitStmt.run(
|
||||
u.id,
|
||||
u.name,
|
||||
u.symbol,
|
||||
u.dimension,
|
||||
u.system,
|
||||
u.base_unit_id ?? null,
|
||||
u.factor ?? null,
|
||||
u.offset ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Equipment
|
||||
const eqStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO equipment(id, name, category, notes) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
for (const eq of data.equipment ?? []) {
|
||||
eqStmt.run(eq.id, eq.name, eq.category ?? null, eq.notes ?? null);
|
||||
}
|
||||
|
||||
// 3. Prep Actions
|
||||
const prepStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO prep_actions(id, name, action_type, default_yield_factor, notes) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
for (const pa of data.prep_actions ?? []) {
|
||||
prepStmt.run(
|
||||
pa.id,
|
||||
pa.name,
|
||||
pa.action_type,
|
||||
pa.default_yield_factor ?? null,
|
||||
pa.notes ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Ingredients & sub-tables
|
||||
const ingStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO ingredients(id, schema_version, name, status, categories_json, tags_json, description, source_json, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
const aliasStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO ingredient_aliases(ingredient_id, name, kind) VALUES (?, ?, ?)"
|
||||
);
|
||||
const ingPrepStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO ingredient_prep_actions(ingredient_id, action_id, yield_factor, notes) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
const ingConvStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO ingredient_measure_conversions(ingredient_id, id, from_quantity, from_unit_id, to_quantity, to_unit_id, state, source_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
const ingDensityStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO ingredient_density_measurements(ingredient_id, id, mass_quantity, mass_unit_id, volume_quantity, volume_unit_id, temperature_c, state, source_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
for (const ing of data.ingredients ?? []) {
|
||||
ingStmt.run(
|
||||
ing.id,
|
||||
ing.schema_version ?? 2,
|
||||
ing.name,
|
||||
ing.status ?? "active",
|
||||
ing.categories_json ?? "[]",
|
||||
ing.tags_json ?? "[]",
|
||||
ing.description ?? null,
|
||||
ing.source_json ?? "{}",
|
||||
ing.deleted_at ?? null
|
||||
);
|
||||
|
||||
if (mode === "merge") {
|
||||
database.prepare("DELETE FROM ingredient_aliases WHERE ingredient_id = ?").run(ing.id);
|
||||
database.prepare("DELETE FROM ingredient_prep_actions WHERE ingredient_id = ?").run(ing.id);
|
||||
database.prepare("DELETE FROM ingredient_measure_conversions WHERE ingredient_id = ?").run(ing.id);
|
||||
database.prepare("DELETE FROM ingredient_density_measurements WHERE ingredient_id = ?").run(ing.id);
|
||||
}
|
||||
|
||||
for (const alias of ing.aliases ?? []) {
|
||||
aliasStmt.run(ing.id, alias.name, alias.kind ?? null);
|
||||
}
|
||||
for (const pa of ing.prep_actions ?? []) {
|
||||
ingPrepStmt.run(ing.id, pa.action_id, pa.yield_factor, pa.notes ?? null);
|
||||
}
|
||||
for (const conv of ing.measure_conversions ?? []) {
|
||||
ingConvStmt.run(
|
||||
ing.id,
|
||||
conv.id,
|
||||
conv.from_quantity,
|
||||
conv.from_unit_id,
|
||||
conv.to_quantity,
|
||||
conv.to_unit_id,
|
||||
conv.state ?? null,
|
||||
conv.source_json ?? "{}"
|
||||
);
|
||||
}
|
||||
for (const density of ing.density_measurements ?? []) {
|
||||
ingDensityStmt.run(
|
||||
ing.id,
|
||||
density.id,
|
||||
density.mass_quantity,
|
||||
density.mass_unit_id,
|
||||
density.volume_quantity,
|
||||
density.volume_unit_id,
|
||||
density.temperature_c ?? null,
|
||||
density.state ?? null,
|
||||
density.source_json ?? "{}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Recipes & sub-tables
|
||||
const recStmt = database.prepare(
|
||||
`INSERT OR REPLACE INTO recipes(
|
||||
id, schema_version, save_version, title, summary,
|
||||
categories_json, tags_json, yield_quantity, yield_unit_id,
|
||||
yield_servings, yield_basis, scaling_mode, scaling_basis_id,
|
||||
scaling_basis_quantity, scaling_basis_unit_id, notes_json,
|
||||
source_json, auto_yield, station, deleted_at, cover_media_url
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const recEqStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO recipe_equipment(recipe_id, equipment_id) VALUES (?, ?)"
|
||||
);
|
||||
const compStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO recipe_components(recipe_id, id, position, name, notes_json) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
const itemStmt = database.prepare(
|
||||
`INSERT OR REPLACE INTO recipe_items(
|
||||
recipe_id, component_id, id, position, ingredient_id,
|
||||
subrecipe_id, quantity, unit_id, percentage, basis_member,
|
||||
optional, notes, nutrition_retention_factor
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const itemPrepStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO item_prep_actions(recipe_id, item_id, position, action_id, yield_factor, notes) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
const stepStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO recipe_steps(recipe_id, id, position, instruction, critical_control_point) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
const stepEqStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO step_equipment(recipe_id, step_id, equipment_id) VALUES (?, ?, ?)"
|
||||
);
|
||||
const recConvStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO recipe_measure_conversions(recipe_id, id, from_quantity, from_unit_id, to_quantity, to_unit_id, notes, source_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
const mediaStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO recipe_media(recipe_id, id, step_id, media_type, url, caption, position) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
for (const rec of data.recipes ?? []) {
|
||||
recStmt.run(
|
||||
rec.id,
|
||||
rec.schema_version ?? 2,
|
||||
rec.save_version ?? 1,
|
||||
rec.title,
|
||||
rec.summary ?? null,
|
||||
rec.categories_json ?? "[]",
|
||||
rec.tags_json ?? "[]",
|
||||
rec.yield_quantity,
|
||||
rec.yield_unit_id,
|
||||
rec.yield_servings ?? null,
|
||||
rec.yield_basis ?? null,
|
||||
rec.scaling_mode ?? null,
|
||||
rec.scaling_basis_id ?? null,
|
||||
rec.scaling_basis_quantity ?? null,
|
||||
rec.scaling_basis_unit_id ?? null,
|
||||
rec.notes_json ?? "[]",
|
||||
rec.source_json ?? "{}",
|
||||
rec.auto_yield ?? 0,
|
||||
rec.station ?? null,
|
||||
rec.deleted_at ?? null,
|
||||
rec.cover_media_url ?? null
|
||||
);
|
||||
|
||||
if (mode === "merge") {
|
||||
database.prepare("DELETE FROM recipe_equipment WHERE recipe_id = ?").run(rec.id);
|
||||
database.prepare("DELETE FROM recipe_components WHERE recipe_id = ?").run(rec.id);
|
||||
database.prepare("DELETE FROM recipe_items WHERE recipe_id = ?").run(rec.id);
|
||||
database.prepare("DELETE FROM recipe_steps WHERE recipe_id = ?").run(rec.id);
|
||||
database.prepare("DELETE FROM recipe_measure_conversions WHERE recipe_id = ?").run(rec.id);
|
||||
database.prepare("DELETE FROM recipe_media WHERE recipe_id = ?").run(rec.id);
|
||||
}
|
||||
|
||||
for (const eqId of rec.equipment_ids ?? []) {
|
||||
recEqStmt.run(rec.id, eqId);
|
||||
}
|
||||
|
||||
for (const comp of rec.components ?? []) {
|
||||
compStmt.run(rec.id, comp.id, comp.position, comp.name, comp.notes_json ?? "[]");
|
||||
}
|
||||
|
||||
for (const item of rec.items ?? []) {
|
||||
itemStmt.run(
|
||||
rec.id,
|
||||
item.component_id,
|
||||
item.id,
|
||||
item.position,
|
||||
item.ingredient_id ?? null,
|
||||
item.subrecipe_id ?? null,
|
||||
item.quantity,
|
||||
item.unit_id,
|
||||
item.percentage ?? null,
|
||||
item.basis_member ?? 0,
|
||||
item.optional ?? 0,
|
||||
item.notes ?? null,
|
||||
item.nutrition_retention_factor ?? 1
|
||||
);
|
||||
|
||||
for (const ipa of item.prep_actions ?? []) {
|
||||
itemPrepStmt.run(
|
||||
rec.id,
|
||||
item.id,
|
||||
ipa.position,
|
||||
ipa.action_id,
|
||||
ipa.yield_factor ?? null,
|
||||
ipa.notes ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const step of rec.steps ?? []) {
|
||||
stepStmt.run(
|
||||
rec.id,
|
||||
step.id,
|
||||
step.position,
|
||||
step.instruction,
|
||||
step.critical_control_point ?? 0
|
||||
);
|
||||
|
||||
for (const eqId of step.equipment_ids ?? []) {
|
||||
stepEqStmt.run(rec.id, step.id, eqId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const conv of rec.measure_conversions ?? []) {
|
||||
recConvStmt.run(
|
||||
rec.id,
|
||||
conv.id,
|
||||
conv.from_quantity,
|
||||
conv.from_unit_id,
|
||||
conv.to_quantity,
|
||||
conv.to_unit_id,
|
||||
conv.notes ?? null,
|
||||
conv.source_json ?? "{}"
|
||||
);
|
||||
}
|
||||
|
||||
for (const m of rec.media ?? []) {
|
||||
mediaStmt.run(
|
||||
rec.id,
|
||||
m.id,
|
||||
m.step_id ?? null,
|
||||
m.media_type,
|
||||
m.url,
|
||||
m.caption ?? null,
|
||||
m.position
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Purchase items & prices
|
||||
const piStmt = database.prepare(
|
||||
`INSERT OR REPLACE 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const priceStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO price_observations(purchase_item_id, effective_at, currency, amount, source_json) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
for (const pi of data.purchase_items ?? []) {
|
||||
piStmt.run(
|
||||
pi.id,
|
||||
pi.ingredient_id,
|
||||
pi.name,
|
||||
pi.brand ?? null,
|
||||
pi.supplier_id ?? null,
|
||||
pi.supplier_sku ?? null,
|
||||
pi.status ?? "active",
|
||||
pi.package_quantity,
|
||||
pi.package_unit_id,
|
||||
pi.units_per_case ?? 1,
|
||||
pi.usable_yield_factor ?? 1,
|
||||
pi.deleted_at ?? null
|
||||
);
|
||||
|
||||
if (mode === "merge") {
|
||||
database.prepare("DELETE FROM price_observations WHERE purchase_item_id = ?").run(pi.id);
|
||||
}
|
||||
|
||||
for (const price of pi.prices ?? []) {
|
||||
priceStmt.run(
|
||||
pi.id,
|
||||
price.effective_at,
|
||||
price.currency,
|
||||
price.amount,
|
||||
price.source_json ?? "{}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Source mappings
|
||||
const smStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO source_mappings(id, subject_type, subject_id, mapping_type, status, source_json, nutrition_json) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
for (const sm of data.source_mappings ?? []) {
|
||||
smStmt.run(
|
||||
sm.id,
|
||||
sm.subject_type,
|
||||
sm.subject_id,
|
||||
sm.mapping_type,
|
||||
sm.status,
|
||||
sm.source_json ?? "{}",
|
||||
sm.nutrition_json ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// 8. Collections & collection recipes
|
||||
const collStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO collections(id, name, description, source_json, deleted_at) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
const collRecStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO collection_recipes(collection_id, recipe_id, position) VALUES (?, ?, ?)"
|
||||
);
|
||||
|
||||
for (const coll of data.collections ?? []) {
|
||||
collStmt.run(
|
||||
coll.id,
|
||||
coll.name,
|
||||
coll.description ?? null,
|
||||
coll.source_json ?? "{}",
|
||||
coll.deleted_at ?? null
|
||||
);
|
||||
|
||||
if (mode === "merge") {
|
||||
database.prepare("DELETE FROM collection_recipes WHERE collection_id = ?").run(coll.id);
|
||||
}
|
||||
|
||||
for (const cr of coll.recipes ?? []) {
|
||||
collRecStmt.run(coll.id, cr.recipe_id, cr.position);
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Inventory locations
|
||||
if (Array.isArray(data.inventory_locations) && data.inventory_locations.length > 0) {
|
||||
const locStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO inventory_locations(id, name, position, deleted_at) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
for (const loc of data.inventory_locations) {
|
||||
locStmt.run(loc.id, loc.name, loc.position, loc.deleted_at ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Inventory counts & count items
|
||||
if (Array.isArray(data.inventory_counts) && data.inventory_counts.length > 0) {
|
||||
const countStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO inventory_counts(id, title, counted_at, status, notes, created_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
const countItemStmt = database.prepare(
|
||||
"INSERT OR REPLACE INTO inventory_count_items(count_id, location_id, ingredient_id, quantity, unit_id, unit_cost, extended_cost) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
for (const count of data.inventory_counts) {
|
||||
countStmt.run(
|
||||
count.id,
|
||||
count.title,
|
||||
count.counted_at,
|
||||
count.status ?? "open",
|
||||
count.notes ?? null,
|
||||
count.created_at,
|
||||
count.deleted_at ?? null
|
||||
);
|
||||
|
||||
if (mode === "merge") {
|
||||
database.prepare("DELETE FROM inventory_count_items WHERE count_id = ?").run(count.id);
|
||||
}
|
||||
|
||||
for (const item of count.items ?? []) {
|
||||
countItemStmt.run(
|
||||
count.id,
|
||||
item.location_id ?? null,
|
||||
item.ingredient_id,
|
||||
item.quantity,
|
||||
item.unit_id,
|
||||
item.unit_cost ?? null,
|
||||
item.extended_cost ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
database.exec("COMMIT;");
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK;");
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (options.rebuildProjections !== false) {
|
||||
try {
|
||||
writeSiteProjection(database);
|
||||
} catch (err) {
|
||||
console.warn("Failed to rebuild site projection after import:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
mode,
|
||||
imported_at: new Date().toISOString(),
|
||||
summary,
|
||||
message: `Successfully imported ${summary.total_records_count} records (${summary.recipes_count} recipes, ${summary.ingredients_count} ingredients) in '${mode}' mode.`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./types.ts";
|
||||
export * from "./export-database.ts";
|
||||
export * from "./import-database.ts";
|
||||
export * from "./validate-backup.ts";
|
||||
@@ -0,0 +1,297 @@
|
||||
export interface BackupMetadata {
|
||||
$schema: "https://formulation.app/schemas/backup-v1.json";
|
||||
format_version: "1.0.0";
|
||||
app_version: string;
|
||||
exported_at: string;
|
||||
database_version?: number;
|
||||
}
|
||||
|
||||
export interface BackupSummary {
|
||||
units_count: number;
|
||||
equipment_count: number;
|
||||
prep_actions_count: number;
|
||||
ingredients_count: number;
|
||||
recipes_count: number;
|
||||
purchase_items_count: number;
|
||||
price_observations_count: number;
|
||||
source_mappings_count: number;
|
||||
collections_count: number;
|
||||
inventory_locations_count: number;
|
||||
inventory_counts_count: number;
|
||||
total_records_count: number;
|
||||
}
|
||||
|
||||
export interface UnitBackupRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
dimension: string;
|
||||
system: string;
|
||||
base_unit_id: string | null;
|
||||
factor: number | null;
|
||||
offset: number | null;
|
||||
}
|
||||
|
||||
export interface EquipmentBackupRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface PrepActionBackupRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
action_type: string;
|
||||
default_yield_factor: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface IngredientAliasBackupRecord {
|
||||
name: string;
|
||||
kind: string | null;
|
||||
}
|
||||
|
||||
export interface IngredientPrepActionBackupRecord {
|
||||
action_id: string;
|
||||
yield_factor: number;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface IngredientMeasureConversionBackupRecord {
|
||||
id: string;
|
||||
from_quantity: number;
|
||||
from_unit_id: string;
|
||||
to_quantity: number;
|
||||
to_unit_id: string;
|
||||
state: string | null;
|
||||
source_json: string;
|
||||
}
|
||||
|
||||
export interface IngredientDensityMeasurementBackupRecord {
|
||||
id: string;
|
||||
mass_quantity: number;
|
||||
mass_unit_id: string;
|
||||
volume_quantity: number;
|
||||
volume_unit_id: string;
|
||||
temperature_c: number | null;
|
||||
state: string | null;
|
||||
source_json: string;
|
||||
}
|
||||
|
||||
export interface IngredientBackupRecord {
|
||||
id: string;
|
||||
schema_version: number;
|
||||
name: string;
|
||||
status: string;
|
||||
categories_json: string;
|
||||
tags_json: string;
|
||||
description: string | null;
|
||||
source_json: string;
|
||||
deleted_at: string | null;
|
||||
aliases: IngredientAliasBackupRecord[];
|
||||
prep_actions: IngredientPrepActionBackupRecord[];
|
||||
measure_conversions: IngredientMeasureConversionBackupRecord[];
|
||||
density_measurements: IngredientDensityMeasurementBackupRecord[];
|
||||
}
|
||||
|
||||
export interface ItemPrepActionBackupRecord {
|
||||
position: number;
|
||||
action_id: string;
|
||||
yield_factor: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface RecipeItemBackupRecord {
|
||||
component_id: string;
|
||||
id: string;
|
||||
position: number;
|
||||
ingredient_id: string | null;
|
||||
subrecipe_id: string | null;
|
||||
quantity: number;
|
||||
unit_id: string;
|
||||
percentage: number | null;
|
||||
basis_member: number;
|
||||
optional: number;
|
||||
notes: string | null;
|
||||
nutrition_retention_factor: number;
|
||||
prep_actions: ItemPrepActionBackupRecord[];
|
||||
}
|
||||
|
||||
export interface RecipeComponentBackupRecord {
|
||||
id: string;
|
||||
position: number;
|
||||
name: string;
|
||||
notes_json: string;
|
||||
}
|
||||
|
||||
export interface RecipeStepBackupRecord {
|
||||
id: string;
|
||||
position: number;
|
||||
instruction: string;
|
||||
critical_control_point: number;
|
||||
equipment_ids: string[];
|
||||
}
|
||||
|
||||
export interface RecipeMeasureConversionBackupRecord {
|
||||
id: string;
|
||||
from_quantity: number;
|
||||
from_unit_id: string;
|
||||
to_quantity: number;
|
||||
to_unit_id: string;
|
||||
notes: string | null;
|
||||
source_json: string;
|
||||
}
|
||||
|
||||
export interface RecipeMediaBackupRecord {
|
||||
id: string;
|
||||
step_id: string | null;
|
||||
media_type: string;
|
||||
url: string;
|
||||
caption: string | null;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface RecipeBackupRecord {
|
||||
id: string;
|
||||
schema_version: number;
|
||||
save_version: number;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
categories_json: string;
|
||||
tags_json: string;
|
||||
yield_quantity: number;
|
||||
yield_unit_id: string;
|
||||
yield_servings: number | null;
|
||||
yield_basis: string | null;
|
||||
scaling_mode: string | null;
|
||||
scaling_basis_id: string | null;
|
||||
scaling_basis_quantity: number | null;
|
||||
scaling_basis_unit_id: string | null;
|
||||
notes_json: string;
|
||||
source_json: string;
|
||||
auto_yield: number;
|
||||
station: string | null;
|
||||
deleted_at: string | null;
|
||||
cover_media_url: string | null;
|
||||
equipment_ids: string[];
|
||||
components: RecipeComponentBackupRecord[];
|
||||
items: RecipeItemBackupRecord[];
|
||||
steps: RecipeStepBackupRecord[];
|
||||
measure_conversions: RecipeMeasureConversionBackupRecord[];
|
||||
media: RecipeMediaBackupRecord[];
|
||||
}
|
||||
|
||||
export interface PriceObservationBackupRecord {
|
||||
effective_at: string;
|
||||
currency: string;
|
||||
amount: number;
|
||||
source_json: string;
|
||||
}
|
||||
|
||||
export interface PurchaseItemBackupRecord {
|
||||
id: string;
|
||||
ingredient_id: string;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
supplier_id: string | null;
|
||||
supplier_sku: string | null;
|
||||
status: string;
|
||||
package_quantity: number;
|
||||
package_unit_id: string;
|
||||
units_per_case: number;
|
||||
usable_yield_factor: number;
|
||||
deleted_at: string | null;
|
||||
prices: PriceObservationBackupRecord[];
|
||||
}
|
||||
|
||||
export interface SourceMappingBackupRecord {
|
||||
id: string;
|
||||
subject_type: string;
|
||||
subject_id: string;
|
||||
mapping_type: string;
|
||||
status: string;
|
||||
source_json: string;
|
||||
nutrition_json: string | null;
|
||||
}
|
||||
|
||||
export interface CollectionRecipeBackupRecord {
|
||||
recipe_id: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface CollectionBackupRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
source_json: string;
|
||||
deleted_at: string | null;
|
||||
recipes: CollectionRecipeBackupRecord[];
|
||||
}
|
||||
|
||||
export interface InventoryLocationBackupRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
export interface InventoryCountItemBackupRecord {
|
||||
location_id: string | null;
|
||||
ingredient_id: string;
|
||||
quantity: number;
|
||||
unit_id: string;
|
||||
unit_cost: number | null;
|
||||
extended_cost: number | null;
|
||||
}
|
||||
|
||||
export interface InventoryCountBackupRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
counted_at: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
items: InventoryCountItemBackupRecord[];
|
||||
}
|
||||
|
||||
export interface FormulationBackupData {
|
||||
units: UnitBackupRecord[];
|
||||
equipment: EquipmentBackupRecord[];
|
||||
prep_actions: PrepActionBackupRecord[];
|
||||
ingredients: IngredientBackupRecord[];
|
||||
recipes: RecipeBackupRecord[];
|
||||
purchase_items: PurchaseItemBackupRecord[];
|
||||
source_mappings: SourceMappingBackupRecord[];
|
||||
collections: CollectionBackupRecord[];
|
||||
inventory_locations: InventoryLocationBackupRecord[];
|
||||
inventory_counts: InventoryCountBackupRecord[];
|
||||
}
|
||||
|
||||
export interface FormulationBackupBundle extends BackupMetadata {
|
||||
summary: BackupSummary;
|
||||
data: FormulationBackupData;
|
||||
}
|
||||
|
||||
export type ImportMode = "replace" | "merge";
|
||||
|
||||
export interface ImportOptions {
|
||||
mode?: ImportMode;
|
||||
rebuildProjections?: boolean;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
success: boolean;
|
||||
mode: ImportMode;
|
||||
imported_at: string;
|
||||
summary: BackupSummary;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
summary?: BackupSummary;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { FormulationBackupBundle, ValidationResult, BackupSummary } from "./types";
|
||||
|
||||
export function validateBackupBundle(input: unknown): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!input || typeof input !== "object") {
|
||||
return {
|
||||
valid: false,
|
||||
errors: ["Invalid backup payload: expected a JSON object."],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const bundle = input as Partial<FormulationBackupBundle>;
|
||||
|
||||
if (!bundle.format_version) {
|
||||
errors.push("Missing 'format_version' in backup metadata.");
|
||||
} else if (!bundle.format_version.startsWith("1.")) {
|
||||
errors.push(`Unsupported backup format_version: '${bundle.format_version}'. Expected 1.x.`);
|
||||
}
|
||||
|
||||
if (!bundle.data || typeof bundle.data !== "object") {
|
||||
errors.push("Missing 'data' container in backup payload.");
|
||||
return { valid: false, errors, warnings };
|
||||
}
|
||||
|
||||
const { data } = bundle;
|
||||
|
||||
if (!Array.isArray(data.units)) {
|
||||
errors.push("Missing 'data.units' array in backup payload.");
|
||||
}
|
||||
if (!Array.isArray(data.ingredients)) {
|
||||
errors.push("Missing 'data.ingredients' array in backup payload.");
|
||||
}
|
||||
if (!Array.isArray(data.recipes)) {
|
||||
errors.push("Missing 'data.recipes' array in backup payload.");
|
||||
}
|
||||
|
||||
// Check unit integrity
|
||||
const unitIds = new Set<string>();
|
||||
if (Array.isArray(data.units)) {
|
||||
for (const unit of data.units) {
|
||||
if (!unit.id || !unit.name || !unit.dimension || !unit.system) {
|
||||
errors.push(`Unit entry is missing required fields: ${JSON.stringify(unit)}`);
|
||||
break;
|
||||
}
|
||||
unitIds.add(unit.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Check ingredient integrity
|
||||
const ingredientIds = new Set<string>();
|
||||
if (Array.isArray(data.ingredients)) {
|
||||
for (const ingredient of data.ingredients) {
|
||||
if (!ingredient.id || !ingredient.name) {
|
||||
errors.push(`Ingredient entry is missing required fields (id, name): ${JSON.stringify(ingredient)}`);
|
||||
break;
|
||||
}
|
||||
ingredientIds.add(ingredient.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Check recipe integrity
|
||||
if (Array.isArray(data.recipes)) {
|
||||
for (const recipe of data.recipes) {
|
||||
if (!recipe.id || !recipe.title || !recipe.yield_unit_id) {
|
||||
errors.push(`Recipe entry is missing required fields (id, title, yield_unit_id): ${JSON.stringify(recipe)}`);
|
||||
break;
|
||||
}
|
||||
if (unitIds.size > 0 && !unitIds.has(recipe.yield_unit_id)) {
|
||||
warnings.push(`Recipe '${recipe.title}' (${recipe.id}) references unknown yield unit '${recipe.yield_unit_id}'.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summary: BackupSummary = bundle.summary ?? {
|
||||
units_count: Array.isArray(data.units) ? data.units.length : 0,
|
||||
equipment_count: Array.isArray(data.equipment) ? data.equipment.length : 0,
|
||||
prep_actions_count: Array.isArray(data.prep_actions) ? data.prep_actions.length : 0,
|
||||
ingredients_count: Array.isArray(data.ingredients) ? data.ingredients.length : 0,
|
||||
recipes_count: Array.isArray(data.recipes) ? data.recipes.length : 0,
|
||||
purchase_items_count: Array.isArray(data.purchase_items) ? data.purchase_items.length : 0,
|
||||
price_observations_count: Array.isArray(data.purchase_items)
|
||||
? data.purchase_items.reduce((acc, pi) => acc + (pi.prices?.length ?? 0), 0)
|
||||
: 0,
|
||||
source_mappings_count: Array.isArray(data.source_mappings) ? data.source_mappings.length : 0,
|
||||
collections_count: Array.isArray(data.collections) ? data.collections.length : 0,
|
||||
inventory_locations_count: Array.isArray(data.inventory_locations) ? data.inventory_locations.length : 0,
|
||||
inventory_counts_count: Array.isArray(data.inventory_counts) ? data.inventory_counts.length : 0,
|
||||
total_records_count:
|
||||
(data.units?.length ?? 0) +
|
||||
(data.equipment?.length ?? 0) +
|
||||
(data.prep_actions?.length ?? 0) +
|
||||
(data.ingredients?.length ?? 0) +
|
||||
(data.recipes?.length ?? 0) +
|
||||
(data.purchase_items?.length ?? 0) +
|
||||
(data.source_mappings?.length ?? 0) +
|
||||
(data.collections?.length ?? 0) +
|
||||
(data.inventory_locations?.length ?? 0) +
|
||||
(data.inventory_counts?.length ?? 0),
|
||||
};
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import type { Ingredient, PrepAction, PurchaseItem, Recipe, RecipeItem, Unit } from "./types";
|
||||
import { convertWithIngredientMeasures } from "./measurement";
|
||||
import type { Ingredient, PrepAction, PurchaseItem, Recipe, RecipeItem, Unit } from "./types.ts";
|
||||
import { convertWithIngredientMeasures } from "./measurement.ts";
|
||||
|
||||
export type CostResult = {
|
||||
batch?: number;
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import { databaseProjection, openDatabase } from "./database";
|
||||
|
||||
export function loadCatalogs() {
|
||||
const database = openDatabase();
|
||||
if (!database) throw new Error("Database unavailable. Run npm run db:reset first.");
|
||||
if (!database) throw new Error("Database unavailable. Restore from backup with: npm run db:restore -- path/to/backup.json");
|
||||
try {
|
||||
const projection = databaseProjection(database) as {
|
||||
ingredients: Ingredient[];
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { Amount, Unit } from "./types";
|
||||
import type { Amount, Unit } from "./types.ts";
|
||||
|
||||
export function displayDigits(value: number) {
|
||||
return value !== 0 && Math.abs(value) <= 0.1 ? 3 : 2;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Amount, DensityMeasurement, Ingredient, Unit } from "./types";
|
||||
import type { Amount, DensityMeasurement, Ingredient, Unit } from "./types.ts";
|
||||
|
||||
export type UnitCatalog = Map<string, Unit> | Record<string, Unit>;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Ingredient, Recipe, SourceMapping, Unit } from "./types";
|
||||
import { convertWithIngredientMeasures } from "./measurement";
|
||||
import type { Ingredient, Recipe, SourceMapping, Unit } from "./types.ts";
|
||||
import { convertWithIngredientMeasures } from "./measurement.ts";
|
||||
|
||||
export type NutritionFacts = Record<string, number>;
|
||||
export type NutritionResult = {
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { openDatabase } from "../lib/database";
|
||||
import { createMcpTools } from "./tools";
|
||||
|
||||
describe("MCP Tools & Domain Integration", () => {
|
||||
it("searches recipes with keywords and filters", () => {
|
||||
const db = openDatabase({ readOnly: true });
|
||||
expect(db).toBeDefined();
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const all = tools.searchRecipes({ limit: 10 });
|
||||
expect(all.length).toBeGreaterThan(0);
|
||||
expect(all[0]).toHaveProperty("id");
|
||||
expect(all[0]).toHaveProperty("title");
|
||||
expect(all[0]).toHaveProperty("yield");
|
||||
|
||||
const filtered = tools.searchRecipes({ query: "biscotti" });
|
||||
expect(filtered.length).toBeGreaterThan(0);
|
||||
expect(filtered.some((r) => r.id === "chocolate_biscotti")).toBe(true);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("retrieves full recipe with scaling calculation", () => {
|
||||
const db = openDatabase({ readOnly: true });
|
||||
expect(db).toBeDefined();
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const base = tools.getRecipe({ id: "chocolate_biscotti" });
|
||||
expect(base.id).toBe("chocolate_biscotti");
|
||||
expect(base.components.length).toBeGreaterThan(0);
|
||||
expect(base.steps.length).toBeGreaterThan(0);
|
||||
expect(base.scale_factor).toBe(1);
|
||||
|
||||
// Scale by 2x
|
||||
const doubled = tools.getRecipe({ id: "chocolate_biscotti", scale_factor: 2 });
|
||||
expect(doubled.scale_factor).toBe(2);
|
||||
expect(doubled.yield.quantity).toBe(base.yield.quantity * 2);
|
||||
expect(doubled.components[0].items[0].quantity).toBe(
|
||||
base.components[0].items[0].base_quantity * 2
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("runs recipe quality audit", () => {
|
||||
const db = openDatabase({ readOnly: true });
|
||||
expect(db).toBeDefined();
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const audit = tools.auditRecipeQuality();
|
||||
expect(audit.summary.total).toBeGreaterThan(0);
|
||||
expect(audit.recipes.length).toBe(audit.summary.total);
|
||||
expect(audit.recipes[0]).toHaveProperty("placeholder_steps");
|
||||
expect(audit.recipes[0]).toHaveProperty("unpriced_items");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("searches ingredients and retrieves ingredient detail", () => {
|
||||
const db = openDatabase({ readOnly: true });
|
||||
expect(db).toBeDefined();
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const ings = tools.searchIngredients({ query: "flour", limit: 5 });
|
||||
expect(ings.length).toBeGreaterThan(0);
|
||||
|
||||
const flour = tools.getIngredient({ id: "flour_all_purpose" });
|
||||
expect(flour.id).toBe("flour_all_purpose");
|
||||
expect(flour.name.toLowerCase()).toContain("flour");
|
||||
expect(Array.isArray(flour.density_measurements)).toBe(true);
|
||||
expect(Array.isArray(flour.measure_conversions)).toBe(true);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates, updates, and deletes an ingredient", () => {
|
||||
const db = openDatabase({ readOnly: false });
|
||||
expect(db).toBeDefined();
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
|
||||
const createRes = tools.saveIngredient({
|
||||
name: "Test Matcha Powder",
|
||||
description: "Ceremonial grade green tea powder",
|
||||
categories: ["tea", "flavoring"],
|
||||
tags: ["beverage", "japanese"],
|
||||
aliases: [{ name: "Matcha", kind: "search" }],
|
||||
density: {
|
||||
mass_quantity: 60,
|
||||
mass_unit_id: "gram",
|
||||
volume_quantity: 0.25,
|
||||
volume_unit_id: "cup_us",
|
||||
},
|
||||
});
|
||||
expect(createRes.success).toBe(true);
|
||||
expect(createRes.created).toBe(true);
|
||||
const ingId = createRes.ingredient_id;
|
||||
|
||||
const fetched = tools.getIngredient({ id: ingId });
|
||||
expect(fetched.name).toBe("Test Matcha Powder");
|
||||
expect(fetched.categories).toContain("tea");
|
||||
expect(fetched.density_measurements.length).toBe(1);
|
||||
|
||||
const updateRes = tools.saveIngredient({
|
||||
id: ingId,
|
||||
name: "Test Matcha Powder Organic",
|
||||
categories: ["tea", "flavoring", "organic"],
|
||||
});
|
||||
expect(updateRes.success).toBe(true);
|
||||
|
||||
const deleteRes = tools.deleteIngredient({ id: ingId });
|
||||
expect(deleteRes.success).toBe(true);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("prevents deleting an ingredient that is in active use", () => {
|
||||
const db = openDatabase({ readOnly: false });
|
||||
expect(db).toBeDefined();
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
expect(() => tools.deleteIngredient({ id: "sugar" })).toThrow(/used in active recipe/);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("manages purchase items and records price observations", () => {
|
||||
const db = openDatabase({ readOnly: false });
|
||||
expect(db).toBeDefined();
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
|
||||
// Create purchase item with initial price
|
||||
const createRes = tools.savePurchaseItem({
|
||||
ingredient_id: "flour_all_purpose",
|
||||
name: "King Arthur AP Flour 25 lb",
|
||||
brand: "King Arthur",
|
||||
supplier_id: "sysco",
|
||||
package_quantity: 25,
|
||||
package_unit_id: "pound",
|
||||
initial_price: {
|
||||
amount: 18.50,
|
||||
currency: "USD",
|
||||
},
|
||||
});
|
||||
expect(createRes.success).toBe(true);
|
||||
const piId = createRes.purchase_item_id;
|
||||
|
||||
// Get detail
|
||||
const detail = tools.getPurchaseItem({ id: piId });
|
||||
expect(detail.name).toBe("King Arthur AP Flour 25 lb");
|
||||
expect(detail.price_history.length).toBe(1);
|
||||
expect(detail.price_history[0].amount).toBe(18.50);
|
||||
|
||||
// Record new price observation
|
||||
const priceRes = tools.recordPriceObservation({
|
||||
purchase_item_id: piId,
|
||||
amount: 19.25,
|
||||
currency: "USD",
|
||||
effective_at: "2026-08-17",
|
||||
});
|
||||
expect(priceRes.success).toBe(true);
|
||||
|
||||
// Clean up
|
||||
db.prepare("DELETE FROM price_observations WHERE purchase_item_id = ?").run(piId);
|
||||
db.prepare("DELETE FROM purchase_items WHERE id = ?").run(piId);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("lists reference catalogs: units, equipment, and prep actions", () => {
|
||||
const db = openDatabase({ readOnly: true });
|
||||
expect(db).toBeDefined();
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
|
||||
const units = tools.listUnits({ dimension: "mass" });
|
||||
expect(units.length).toBeGreaterThan(0);
|
||||
expect(units.every((u) => u.dimension === "mass")).toBe(true);
|
||||
|
||||
const equip = tools.listEquipment();
|
||||
expect(equip.length).toBeGreaterThan(0);
|
||||
expect(equip[0]).toHaveProperty("id");
|
||||
expect(equip[0]).toHaveProperty("name");
|
||||
|
||||
const prep = tools.listPrepActions();
|
||||
expect(prep.length).toBeGreaterThan(0);
|
||||
expect(prep[0]).toHaveProperty("action_type");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates, retrieves, and updates recipe books", () => {
|
||||
const db = openDatabase({ readOnly: false });
|
||||
expect(db).toBeDefined();
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
|
||||
const createRes = tools.saveRecipeBook({
|
||||
name: "Test Holiday Pastries",
|
||||
description: "Holiday baked goods collection",
|
||||
recipe_ids: ["chocolate_biscotti", "cinnamon_sugar"],
|
||||
});
|
||||
expect(createRes.success).toBe(true);
|
||||
const bookId = createRes.book_id;
|
||||
|
||||
const book = tools.getRecipeBook({ id: bookId });
|
||||
expect(book.name).toBe("Test Holiday Pastries");
|
||||
expect(book.recipe_count).toBe(2);
|
||||
expect(book.recipes.length).toBe(2);
|
||||
|
||||
const list = tools.listRecipeBooks();
|
||||
expect(list.some((b) => b.id === bookId)).toBe(true);
|
||||
|
||||
db.prepare("DELETE FROM collection_recipes WHERE collection_id = ?").run(bookId);
|
||||
db.prepare("DELETE FROM collections WHERE id = ?").run(bookId);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates, retrieves, and updates inventory count sessions", () => {
|
||||
const db = openDatabase({ readOnly: false });
|
||||
expect(db).toBeDefined();
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
|
||||
const createRes = tools.createInventoryCountSession({
|
||||
title: "Test Week Count",
|
||||
counted_at: "2026-08-17T12:00:00Z",
|
||||
notes: "Automated test session",
|
||||
prepopulate: true,
|
||||
});
|
||||
expect(createRes.success).toBe(true);
|
||||
const countId = createRes.count_id;
|
||||
|
||||
const detail = tools.getInventoryCount({ id: countId });
|
||||
expect(detail.id).toBe(countId);
|
||||
expect(detail.status).toBe("open");
|
||||
expect(detail.items.length).toBeGreaterThan(0);
|
||||
|
||||
const updateRes = tools.updateInventoryCount({
|
||||
count_id: countId,
|
||||
items: [{ ingredient_id: "sugar", quantity: 50, unit_id: "pound" }],
|
||||
});
|
||||
expect(updateRes.success).toBe(true);
|
||||
|
||||
db.prepare("DELETE FROM inventory_count_items WHERE count_id = ?").run(countId);
|
||||
db.prepare("DELETE FROM inventory_counts WHERE id = ?").run(countId);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("converts units within dimension and cross-dimension with density", () => {
|
||||
const db = openDatabase({ readOnly: true });
|
||||
expect(db).toBeDefined();
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
|
||||
const kgToG = tools.convertUnits({
|
||||
quantity: 1,
|
||||
from_unit: "kilogram",
|
||||
to_unit: "gram",
|
||||
});
|
||||
expect(kgToG.to.quantity).toBe(1000);
|
||||
|
||||
const cupToG = tools.convertUnits({
|
||||
ingredient_id: "salt",
|
||||
quantity: 1,
|
||||
from_unit: "cup_us",
|
||||
to_unit: "gram",
|
||||
});
|
||||
expect(cupToG.to.quantity).toBeCloseTo(292, 0);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("calculates recipe cost and nutrition", () => {
|
||||
const db = openDatabase({ readOnly: true });
|
||||
expect(db).toBeDefined();
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const cost = tools.calculateRecipeCost({ recipe_id: "chocolate_biscotti", currency: "USD" });
|
||||
expect(cost).toHaveProperty("currency", "USD");
|
||||
expect(cost).toHaveProperty("lines");
|
||||
|
||||
const nutrition = tools.calculateRecipeNutrition({ recipe_id: "chocolate_biscotti" });
|
||||
expect(nutrition).toHaveProperty("batch");
|
||||
expect(nutrition).toHaveProperty("inputWeightG");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("lists archived items and exports database backup", () => {
|
||||
const db = openDatabase({ readOnly: true });
|
||||
expect(db).toBeDefined();
|
||||
if (!db) return;
|
||||
|
||||
try {
|
||||
const tools = createMcpTools(() => db);
|
||||
const stats = tools.getDatabaseStats();
|
||||
expect(stats.recipes).toBeGreaterThan(100);
|
||||
expect(stats.ingredients).toBeGreaterThan(100);
|
||||
|
||||
const archived = tools.listArchivedItems();
|
||||
expect(archived).toHaveProperty("total");
|
||||
expect(Array.isArray(archived.recipes)).toBe(true);
|
||||
|
||||
const backup = tools.exportDatabaseBackup();
|
||||
expect(backup.format_version).toBe("1.0.0");
|
||||
expect(backup.summary.recipes_count).toBe(stats.recipes);
|
||||
expect(backup.summary.ingredients_count).toBe(stats.ingredients);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { openDatabase } from "../lib/database.ts";
|
||||
import { createMcpTools } from "./tools.ts";
|
||||
|
||||
export function createFormulationMcpServer() {
|
||||
const server = new McpServer({
|
||||
name: "formulation",
|
||||
version: "2.0.0",
|
||||
});
|
||||
|
||||
const getDb = () => {
|
||||
const db = openDatabase({ readOnly: false });
|
||||
if (!db) throw new Error("Formulation database unavailable at var/recipe-book.sqlite");
|
||||
return db;
|
||||
};
|
||||
|
||||
const tools = createMcpTools(getDb);
|
||||
|
||||
function wrap<T>(fn: () => T | Promise<T>) {
|
||||
return async () => {
|
||||
try {
|
||||
const result = await fn();
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err: any) {
|
||||
return { content: [{ type: "text" as const, text: `Error: ${err.message}` }], isError: true };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Search Recipes
|
||||
server.tool(
|
||||
"search_recipes",
|
||||
"Search recipes in the Formulation culinary database by keyword, category, or tag.",
|
||||
{
|
||||
query: z.string().optional().describe("Search keywords for title or ingredient"),
|
||||
category: z.string().optional().describe("Filter by category (e.g. 'sauce', 'bread')"),
|
||||
tag: z.string().optional().describe("Filter by tag"),
|
||||
limit: z.number().int().min(1).max(100).default(25).describe("Max results"),
|
||||
},
|
||||
async (args) => wrap(() => tools.searchRecipes(args))()
|
||||
);
|
||||
|
||||
// 2. Get Recipe
|
||||
server.tool(
|
||||
"get_recipe",
|
||||
"Get full recipe formulation with components, items, steps, and optional scaling.",
|
||||
{
|
||||
id: z.string().describe("Recipe ID"),
|
||||
scale_factor: z.number().positive().optional().describe("Scale multiplier (e.g. 2 for double batch)"),
|
||||
target_yield: z.number().positive().optional().describe("Target yield quantity to scale to"),
|
||||
target_yield_unit: z.string().optional().describe("Target yield unit if different from recipe"),
|
||||
},
|
||||
async (args) => wrap(() => tools.getRecipe(args))()
|
||||
);
|
||||
|
||||
// 3. Save Recipe
|
||||
server.tool(
|
||||
"save_recipe",
|
||||
"Create or update a recipe with validated components, items, and preparation steps.",
|
||||
{
|
||||
id: z.string().optional().describe("Recipe ID (omit to create new)"),
|
||||
title: z.string().min(1).describe("Recipe title"),
|
||||
summary: z.string().nullable().optional().describe("Brief culinary summary"),
|
||||
yield_quantity: z.number().positive().describe("Finished yield quantity"),
|
||||
yield_unit_id: z.string().describe("Yield unit (e.g. 'gram', 'each')"),
|
||||
yield_servings: z.number().positive().nullable().optional().describe("Number of servings"),
|
||||
yield_basis: z.string().optional().describe("Yield basis: theoretical, measured, estimated"),
|
||||
categories: z.array(z.string()).optional().default([]),
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
components: z.array(z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().default("Main"),
|
||||
items: z.array(z.object({
|
||||
id: z.string().optional(),
|
||||
ingredient_id: z.string().optional(),
|
||||
subrecipe_id: z.string().optional(),
|
||||
quantity: z.number().positive(),
|
||||
unit_id: z.string(),
|
||||
percentage: z.number().nullable().optional(),
|
||||
basis_member: z.boolean().default(false),
|
||||
optional: z.boolean().default(false),
|
||||
notes: z.string().nullable().optional(),
|
||||
})),
|
||||
})).min(1).describe("Components with ingredient line items"),
|
||||
steps: z.array(z.object({
|
||||
id: z.string().optional(),
|
||||
instruction: z.string().min(1),
|
||||
equipment_ids: z.array(z.string()).optional(),
|
||||
})).min(1).describe("Ordered preparation steps"),
|
||||
notes: z.array(z.string()).optional().default([]),
|
||||
},
|
||||
async (args) => wrap(() => tools.saveRecipe(args as any))()
|
||||
);
|
||||
|
||||
// 4. Delete Recipe
|
||||
server.tool(
|
||||
"delete_recipe",
|
||||
"Archive a recipe from the active formulation library.",
|
||||
{ id: z.string().describe("Recipe ID to archive") },
|
||||
async (args) => wrap(() => tools.deleteRecipe(args))()
|
||||
);
|
||||
|
||||
// 5. Recipe Quality Audit
|
||||
server.tool(
|
||||
"audit_recipe_quality",
|
||||
"Run automated quality audit on all recipes (unpriced items, placeholder steps, missing yield basis).",
|
||||
{},
|
||||
async () => wrap(() => tools.auditRecipeQuality())()
|
||||
);
|
||||
|
||||
// 6. Calculate Recipe Cost
|
||||
server.tool(
|
||||
"calculate_recipe_cost",
|
||||
"Compute itemized ingredient costs, total batch cost, and cost per serving for a recipe.",
|
||||
{
|
||||
recipe_id: z.string().describe("Recipe ID"),
|
||||
currency: z.string().default("USD").describe("Currency code"),
|
||||
},
|
||||
async (args) => wrap(() => tools.calculateRecipeCost(args))()
|
||||
);
|
||||
|
||||
// 7. Calculate Recipe Nutrition
|
||||
server.tool(
|
||||
"calculate_recipe_nutrition",
|
||||
"Calculate nutrition facts (macros, vitamins) per 100g and per serving.",
|
||||
{
|
||||
recipe_id: z.string().describe("Recipe ID"),
|
||||
serving_size_g: z.number().positive().optional().describe("Serving size in grams"),
|
||||
},
|
||||
async (args) => wrap(() => tools.calculateRecipeNutrition(args))()
|
||||
);
|
||||
|
||||
// 8. Search Ingredients
|
||||
server.tool(
|
||||
"search_ingredients",
|
||||
"Search ingredients in the pantry catalog with cost and nutrition status.",
|
||||
{
|
||||
query: z.string().optional().describe("Search keywords"),
|
||||
category: z.string().optional().describe("Filter by category"),
|
||||
missing_cost: z.boolean().optional().describe("Show only ingredients missing purchase prices"),
|
||||
limit: z.number().int().min(1).max(100).default(25).describe("Max results"),
|
||||
},
|
||||
async (args) => wrap(() => tools.searchIngredients(args))()
|
||||
);
|
||||
|
||||
// 9. Get Ingredient
|
||||
server.tool(
|
||||
"get_ingredient",
|
||||
"Get detailed ingredient with density, equivalencies, aliases, and purchase prices.",
|
||||
{ id: z.string().describe("Ingredient ID") },
|
||||
async (args) => wrap(() => tools.getIngredient(args))()
|
||||
);
|
||||
|
||||
// 10. Save Ingredient
|
||||
server.tool(
|
||||
"save_ingredient",
|
||||
"Create or update an ingredient with category, aliases, and density data.",
|
||||
{
|
||||
id: z.string().optional().describe("Ingredient ID (omit to create new from name)"),
|
||||
name: z.string().min(1).describe("Ingredient name"),
|
||||
description: z.string().nullable().optional().describe("Culinary description"),
|
||||
categories: z.array(z.string()).optional().default([]),
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
aliases: z.array(z.object({ name: z.string(), kind: z.string().optional() })).optional(),
|
||||
density: z.object({
|
||||
mass_quantity: z.number().positive(),
|
||||
mass_unit_id: z.string(),
|
||||
volume_quantity: z.number().positive(),
|
||||
volume_unit_id: z.string(),
|
||||
}).optional().describe("Density measurement for volume-to-weight conversions"),
|
||||
},
|
||||
async (args) => wrap(() => tools.saveIngredient(args))()
|
||||
);
|
||||
|
||||
// 11. Delete Ingredient
|
||||
server.tool(
|
||||
"delete_ingredient",
|
||||
"Archive an ingredient (fails safely if used in active recipes).",
|
||||
{ id: z.string().describe("Ingredient ID to archive") },
|
||||
async (args) => wrap(() => tools.deleteIngredient(args))()
|
||||
);
|
||||
|
||||
// 12. Purchasing & Prices
|
||||
server.tool(
|
||||
"list_purchase_items",
|
||||
"List purchase items with latest price observations.",
|
||||
{
|
||||
ingredient_id: z.string().optional().describe("Filter by ingredient ID"),
|
||||
status: z.string().optional().describe("Filter by status (active, discontinued)"),
|
||||
limit: z.number().int().min(1).max(200).default(50).describe("Max results"),
|
||||
},
|
||||
async (args) => wrap(() => tools.listPurchaseItems(args))()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"get_purchase_item",
|
||||
"Get purchase item with full price history observations.",
|
||||
{ id: z.string().describe("Purchase Item ID") },
|
||||
async (args) => wrap(() => tools.getPurchaseItem(args))()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"save_purchase_item",
|
||||
"Create or update a purchase item with package specification and initial price.",
|
||||
{
|
||||
id: z.string().optional().describe("Purchase Item ID"),
|
||||
ingredient_id: z.string().describe("Target ingredient ID"),
|
||||
name: z.string().min(1).describe("Product package name"),
|
||||
brand: z.string().nullable().optional(),
|
||||
supplier_id: z.string().nullable().optional(),
|
||||
supplier_sku: z.string().nullable().optional(),
|
||||
package_quantity: z.number().positive().describe("Package quantity"),
|
||||
package_unit_id: z.string().describe("Package unit ID (e.g. 'pound', 'gram')"),
|
||||
units_per_case: z.number().positive().default(1),
|
||||
usable_yield_factor: z.number().positive().default(1),
|
||||
initial_price: z.object({
|
||||
amount: z.number().positive(),
|
||||
currency: z.string().default("USD"),
|
||||
effective_at: z.string().optional(),
|
||||
}).optional(),
|
||||
},
|
||||
async (args) => wrap(() => tools.savePurchaseItem(args as any))()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"record_price_observation",
|
||||
"Record a new price observation for an existing purchase item.",
|
||||
{
|
||||
purchase_item_id: z.string().describe("Purchase Item ID"),
|
||||
amount: z.number().positive().describe("Price amount"),
|
||||
currency: z.string().default("USD").describe("Currency code"),
|
||||
effective_at: z.string().optional().describe("Effective date (YYYY-MM-DD)"),
|
||||
},
|
||||
async (args) => wrap(() => tools.recordPriceObservation(args))()
|
||||
);
|
||||
|
||||
// 13. Convert Units
|
||||
server.tool(
|
||||
"convert_units",
|
||||
"Convert culinary units using ingredient-specific density and equivalency data.",
|
||||
{
|
||||
ingredient_id: z.string().optional().describe("Ingredient ID for cross-dimension conversion"),
|
||||
quantity: z.number().positive().describe("Quantity to convert"),
|
||||
from_unit: z.string().describe("Source unit ID (e.g. 'cup_us', 'gram')"),
|
||||
to_unit: z.string().describe("Target unit ID (e.g. 'gram', 'oz')"),
|
||||
},
|
||||
async (args) => wrap(() => tools.convertUnits(args))()
|
||||
);
|
||||
|
||||
// 14. Reference Catalogs
|
||||
server.tool(
|
||||
"list_units",
|
||||
"List all measurement units (mass, volume, count, time, temperature) with base conversion factors.",
|
||||
{
|
||||
dimension: z.string().optional().describe("Filter by dimension (mass, volume, count, time, temperature, length)"),
|
||||
system: z.string().optional().describe("Filter by system (metric, customary, si)"),
|
||||
},
|
||||
async (args) => wrap(() => tools.listUnits(args))()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"list_equipment",
|
||||
"List kitchen equipment items with categories and notes.",
|
||||
{ category: z.string().optional().describe("Filter by category") },
|
||||
async (args) => wrap(() => tools.listEquipment(args))()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"list_prep_actions",
|
||||
"List culinary prep actions (peel, trim, chop) and default yield factors.",
|
||||
{},
|
||||
async () => wrap(() => tools.listPrepActions())()
|
||||
);
|
||||
|
||||
// 15. Inventory Counts
|
||||
server.tool(
|
||||
"list_inventory_counts",
|
||||
"List inventory counting sessions with status and valuations.",
|
||||
{ status: z.enum(["all", "open", "completed"]).default("all").describe("Filter by status") },
|
||||
async (args) => wrap(() => tools.listInventoryCounts(args))()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"get_inventory_count",
|
||||
"Get full inventory count sheet with counted items, locations, unit costs, and valuations.",
|
||||
{ id: z.string().describe("Inventory Count ID") },
|
||||
async (args) => wrap(() => tools.getInventoryCount(args))()
|
||||
);
|
||||
|
||||
// 16. Recipe Books / Collections
|
||||
server.tool(
|
||||
"list_recipe_books",
|
||||
"List recipe books (collections) with included recipe counts.",
|
||||
{},
|
||||
async () => wrap(() => tools.listRecipeBooks())()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"get_recipe_book",
|
||||
"Get recipe book details with ordered included recipes.",
|
||||
{ id: z.string().describe("Recipe Book ID") },
|
||||
async (args) => wrap(() => tools.getRecipeBook(args))()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"save_recipe_book",
|
||||
"Create or update a recipe book with name, description, and included recipe IDs.",
|
||||
{
|
||||
id: z.string().optional().describe("Recipe book ID"),
|
||||
name: z.string().min(1).describe("Recipe book name"),
|
||||
description: z.string().nullable().optional().describe("Description"),
|
||||
recipe_ids: z.array(z.string()).optional().describe("Ordered list of recipe IDs"),
|
||||
},
|
||||
async (args) => wrap(() => tools.saveRecipeBook(args))()
|
||||
);
|
||||
|
||||
// 17. Archive & Restore
|
||||
server.tool(
|
||||
"list_archived_items",
|
||||
"List all archived (soft-deleted) items across recipes, ingredients, purchases, books, and inventory.",
|
||||
{},
|
||||
async () => wrap(() => tools.listArchivedItems())()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"restore_archived_items",
|
||||
"Restore archived items by ID and type (recipe, ingredient, purchase, book, inventory).",
|
||||
{
|
||||
items: z.array(z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(["recipe", "ingredient", "purchase", "book", "inventory"]),
|
||||
})).min(1).describe("List of items to restore"),
|
||||
},
|
||||
async (args) => wrap(() => tools.restoreArchived(args))()
|
||||
);
|
||||
|
||||
// 18. Database Export & Stats
|
||||
server.tool(
|
||||
"export_database_backup",
|
||||
"Export a complete JSON backup of the Formulation database (all 25 tables).",
|
||||
{},
|
||||
async () => wrap(() => tools.exportDatabaseBackup())()
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"get_database_stats",
|
||||
"Get entity count statistics across all database tables.",
|
||||
{},
|
||||
async () => wrap(() => tools.getDatabaseStats())()
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
export async function runStdioServer() {
|
||||
const server = createFormulationMcpServer();
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error("Formulation MCP Server running on stdio");
|
||||
}
|
||||
+1063
File diff suppressed because it is too large
Load Diff
+6
-7
@@ -41,14 +41,13 @@
|
||||
--ink-secondary: #3C4679;
|
||||
--muted: #8283a0;
|
||||
--muted-light: #a5a9c1;
|
||||
--muted-subtle: #757677;
|
||||
--paper: #f3f3f3;
|
||||
--paper: #ffffff;
|
||||
--card: #ffffff;
|
||||
--card-alt: #fbfbfb;
|
||||
--card-alt: #fafbfd;
|
||||
--card-paper: #f1f5fe;
|
||||
--line: #ececec;
|
||||
--line-subtle: #f3f3f3;
|
||||
--line-focus: #dadada;
|
||||
--line: #edf0f5;
|
||||
--line-subtle: #f4f5f8;
|
||||
--line-focus: #3d5df6;
|
||||
--blue: #3d5df6;
|
||||
--blue-hover: #1236e1;
|
||||
--blue-active: #001992;
|
||||
@@ -152,7 +151,7 @@ a { color: inherit; text-decoration-thickness: 1px; text-underline-offset: .2em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #fbfbfb;
|
||||
background: #ffffff;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
|
||||
+120
-69
@@ -43,14 +43,13 @@
|
||||
--ink-secondary: #3C4679;
|
||||
--muted: #8283a0;
|
||||
--muted-light: #a5a9c1;
|
||||
--muted-subtle: #757677;
|
||||
--paper: #f3f3f3;
|
||||
--paper: #ffffff;
|
||||
--card: #ffffff;
|
||||
--card-alt: #fbfbfb;
|
||||
--card-alt: #fafbfd;
|
||||
--card-paper: #f1f5fe;
|
||||
--line: #ececec;
|
||||
--line-subtle: #f3f3f3;
|
||||
--line-focus: #dadada;
|
||||
--line: #edf0f5;
|
||||
--line-subtle: #f4f5f8;
|
||||
--line-focus: #3d5df6;
|
||||
--blue: #3d5df6;
|
||||
--blue-hover: #1236e1;
|
||||
--blue-active: #001992;
|
||||
@@ -252,28 +251,28 @@ th { font-size:.7rem; letter-spacing:.06em; }
|
||||
.ingredient-purchase-form > header button { padding:5px 7px; color:#f63d48; background:transparent; border:0; cursor:pointer; font-size:11px; }
|
||||
.ingredient-purchase-form .purchase-item-form { margin:0; padding:0; background:#fff; border:0; border-radius:0; }
|
||||
.ingredient-purchase-form .purchase-item-form label { color:#3c4679; font-size:11px; font-weight:500; letter-spacing:0; text-transform:none; }
|
||||
.ingredient-purchase-form .purchase-item-form input,.ingredient-purchase-form .purchase-item-form select { min-height:44px; padding:9px 10px; background:#fbfbfb; border-color:#ececec; border-radius:0; }
|
||||
.ingredient-purchase-form .purchase-item-form input,.ingredient-purchase-form .purchase-item-form select { min-height:44px; padding:9px 10px; background:#ffffff; border-color:#ececec; border-radius:0; }
|
||||
.ingredient-purchase-form .purchase-form-actions button { padding:9px 16px; background:#3d5df6; border-color:#3d5df6; border-radius:20px; }
|
||||
.ingredient-cost-row { display:flex; align-items:center; justify-content:space-between; gap:20px; min-height:68px; padding:12px 0; border-bottom:1px solid #f3f3f3; }
|
||||
.ingredient-cost-row { display:flex; align-items:center; justify-content:space-between; gap:20px; min-height:68px; padding:12px 0; border-bottom:1px solid #edf0f5; }
|
||||
.ingredient-cost-row > span:last-child { text-align:right; }
|
||||
.ingredient-cost-row strong,.ingredient-cost-row small { display:block; }
|
||||
.ingredient-cost-row small { margin-top:3px; color:#a5a9c1; font-size:11px; }
|
||||
.detail-actions-menu label { display:grid; gap:6px; padding:9px 14px; color:#a5a9c1; font-size:11px; }
|
||||
.detail-actions-menu select { width:250px; padding:9px; }
|
||||
.application-body .ingredient-actions-menu > div { width:290px; }
|
||||
@media (min-width:981px) { .application-body .entity-detail-shell .entity-primary { background:#fbfbfb; } }
|
||||
.application-body .entity-detail-shell .entity-detail-header { background:#fbfbfb; }
|
||||
@media (min-width:981px) { .application-body .entity-detail-shell .entity-primary { background:#ffffff; } }
|
||||
.application-body .entity-detail-shell .entity-detail-header { background:#ffffff; }
|
||||
.application-body .entity-detail-shell .entity-primary { padding-top:35px; }
|
||||
.application-body .entity-detail-shell .entity-section { margin-bottom:35px; }
|
||||
.application-body .entity-detail-shell .entity-section h2,.application-body .entity-detail-shell .entity-tab-panel h2 { color:#050841; font-size:22px; font-weight:700; line-height:1.25; }
|
||||
.application-body .entity-detail-shell .panel-intro { max-width:640px; margin:5px 0 20px; color:#95969c; font-size:14px; line-height:1.5; }
|
||||
.application-body .entity-detail-shell .entity-tabs { position:relative; min-height:58px; background:#fff; border-color:#f3f3f3; }
|
||||
.application-body .entity-detail-shell .entity-tabs { position:relative; min-height:58px; background:#fff; border-color:#edf0f5; }
|
||||
.application-body .entity-detail-shell .entity-tabs button { min-height:58px; padding:8px 12px; color:#050841; font-size:15px; font-weight:400; }
|
||||
.application-body .entity-detail-shell .entity-tabs button.active { color:#050841; background:#f1f5fe; border-bottom:2px solid #3d5df6; }
|
||||
.application-body .entity-detail-shell .entity-tab-panel { padding:35px 2px 60px; }
|
||||
.application-body .entity-detail-shell .prep-action-table { width:100%; margin-top:26px; table-layout:fixed; }
|
||||
.application-body .entity-detail-shell .prep-action-table th { height:40px; padding:8px 10px; color:#757677; background:#fbfbfb; border-color:#f3f3f3; font-size:12px; }
|
||||
.application-body .entity-detail-shell .prep-action-table td { height:58px; padding:10px; color:#050841; background:#fff; border:1px solid #f3f3f3; font-size:14px; }
|
||||
.application-body .entity-detail-shell .prep-action-table th { height:40px; padding:8px 10px; color:#757677; background:#ffffff; border-color:#edf0f5; font-size:12px; }
|
||||
.application-body .entity-detail-shell .prep-action-table td { height:58px; padding:10px; color:#050841; background:#fff; border:1px solid #edf0f5; font-size:14px; }
|
||||
.application-body .entity-detail-shell .inline-editor > summary,.application-body .entity-detail-shell .add-purchase > summary { display:inline-flex; align-items:center; min-height:40px; padding:8px 16px; color:#202962; background:#fff; border:1px solid #202962; border-radius:22px; font-size:14px; font-weight:600; list-style:none; }
|
||||
.application-body .entity-detail-shell .inline-editor > summary::-webkit-details-marker,.application-body .entity-detail-shell .add-purchase > summary::-webkit-details-marker { display:none; }
|
||||
.application-body .entity-detail-shell .inline-form { gap:10px; margin-top:18px; padding-top:18px; border-color:#f3f3f3; }
|
||||
@@ -375,13 +374,13 @@ th { font-size:.7rem; letter-spacing:.06em; }
|
||||
.application-body {
|
||||
--ink:#050841;
|
||||
--muted:#a5a9c1;
|
||||
--paper:#f3f3f3;
|
||||
--paper:#ffffff;
|
||||
--card:#fff;
|
||||
--line:#eeeeF3;
|
||||
--line:#edf0f5;
|
||||
--green:#3d5df6;
|
||||
--orange:#647df8;
|
||||
color:#050841;
|
||||
background:#f3f3f3;
|
||||
background:#ffffff;
|
||||
font-family:var(--meez-font-sans);
|
||||
font-size:16px;
|
||||
font-weight:300;
|
||||
@@ -396,10 +395,10 @@ th { font-size:.7rem; letter-spacing:.06em; }
|
||||
|
||||
/* Home workspace */
|
||||
.application-body .directory-workspace { width:min(1120px,calc(100% - 48px)); padding:26px 0 32px; }
|
||||
.application-body .workspace-search-tools { position:sticky; top:0; z-index:20; display:flex; align-items:center; min-height:80px; margin:0; background:#fbfbfb; }
|
||||
.application-body .workspace-search-tools { position:sticky; top:0; z-index:20; display:flex; align-items:center; min-height:80px; margin:0; background:#ffffff; }
|
||||
.application-body .workspace-search-tools-inner { width:min(1120px,calc(100% - 48px)); margin-inline:auto; display:flex; align-items:center; justify-content:flex-end; gap:12px; }
|
||||
.application-body .workspace-search-tools-inner .workspace-new-menu { margin-left:0; }
|
||||
.application-body .workspace-global-search { flex:0 1 504px; min-height:40px; padding:0 12px; display:flex; align-items:center; gap:5px; background:#fff; border:1px solid #f3f3f3; border-radius:4px; box-shadow:none; }
|
||||
.application-body .workspace-global-search { flex:0 1 504px; min-height:40px; padding:0 12px; display:flex; align-items:center; gap:5px; background:#fff; border:1px solid #edf0f5; border-radius:4px; box-shadow:none; }
|
||||
.application-body .workspace-global-search .search-icon { flex:none; color:#8283a0; }
|
||||
.application-body .workspace-global-search:focus-within { box-shadow:none; }
|
||||
.application-body form.workspace-global-search input[type="search"] { flex:1; min-width:0; padding:8px 0 6px; color:rgba(0,0,0,0.87); background:transparent; border:0; border-radius:0; font-size:16px; font-weight:400; }
|
||||
@@ -478,7 +477,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .workspace-pill-icon.book { background:#f3a642; }
|
||||
.application-body .workspace-pills .filter-menu summary { display:inline-flex; align-items:center; gap:10px; min-height:44px; padding:10px 16px; color:rgba(0,0,0,0.87); background:transparent; border:0; border-radius:20px; font-size:16px; font-weight:400; }
|
||||
.application-body .entity-directory-table { overflow:visible; border:0; background:#fff; }
|
||||
.application-body .entity-directory-toolbar { grid-template-columns:36px 64px minmax(0,1fr) auto; gap:8px; min-height:57px; padding:0; background:#fbfbfb; border-bottom:1px solid #f3f3f3; }
|
||||
.application-body .entity-directory-toolbar { grid-template-columns:36px 64px minmax(0,1fr) auto; gap:8px; min-height:57px; padding:0; background:#ffffff; border-bottom:1px solid #edf0f5; }
|
||||
.application-body .entity-directory-toolbar.has-selection { grid-template-columns:36px minmax(0,1fr); }
|
||||
.application-body .entity-directory-selection-bar { display:flex; align-items:center; justify-content:space-between; width:100%; padding-right:8px; }
|
||||
.application-body .selection-count { color:#3d5df6; font-size:15px; font-weight:600; }
|
||||
@@ -491,7 +490,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .bulk-action-btn.bulk-clear:hover { background:#f4f6fa; border-color:#a5a9c1; color:#050841; }
|
||||
.application-body .entity-directory-head > span { font-size:14px; font-weight:400; color:#a5a9c1; text-transform:none; }
|
||||
.application-body .entity-directory-head > span:nth-child(2) { text-align:left; padding-left:2px; }
|
||||
.application-body .entity-directory-row,.application-body .entity-directory-row.columns-1,.application-body .entity-directory-row.columns-2,.application-body .entity-directory-row.columns-3 { grid-template-columns:36px 64px minmax(14rem,1fr) 36px; gap:8px; min-height:57px; padding:0; border-bottom:1px solid #f3f3f3; }
|
||||
.application-body .entity-directory-row,.application-body .entity-directory-row.columns-1,.application-body .entity-directory-row.columns-2,.application-body .entity-directory-row.columns-3 { grid-template-columns:36px 64px minmax(14rem,1fr) 36px; gap:8px; min-height:57px; padding:0; border-bottom:1px solid #edf0f5; }
|
||||
.application-body .entity-directory-table.read-only .entity-directory-row { grid-template-columns:30px minmax(0,1fr); }
|
||||
.application-body .entity-directory-row.selected { background:#f1f5fe; }
|
||||
.application-body .entity-directory-name a,.application-body .entity-directory-name strong { color:#050841; font-size:15px; font-weight:500; }
|
||||
@@ -536,7 +535,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
|
||||
/* Full-width entity workspaces */
|
||||
.application-body .immersive-main { color:#050841; background:#fff; }
|
||||
.application-body .detail-utility { min-height:58px; padding:8px 28px; background:#fbfbfb; border-color:#f3f3f3; }
|
||||
.application-body .detail-utility { min-height:58px; padding:8px 28px; background:#ffffff; border-color:#edf0f5; }
|
||||
.application-body .detail-utility input::placeholder { color:#a5a9c1; }
|
||||
.application-body .detail-utility > a { color:#202962; font-size:13px; font-weight:500; }
|
||||
.application-body .immersive-main .entity-detail-header { min-height:164px; padding:25px 38px; border-color:#eeeef3; }
|
||||
@@ -547,7 +546,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .immersive-main .entity-detail-grid,.application-body .immersive-main .recipe-structure-workspace .structure-editor { border-color:#eeeef3; }
|
||||
.application-body .immersive-main .entity-primary { padding:42px 38px; border-color:#eeeef3; }
|
||||
.application-body .immersive-main .entity-secondary { padding:0 32px; }
|
||||
.application-body .entity-tabs { min-height:58px; border-color:#f3f3f3; }
|
||||
.application-body .entity-tabs { min-height:58px; border-color:#edf0f5; }
|
||||
.application-body .entity-tabs button,.application-body .recipe-workspace-tabs button { padding:17px 10px; color:#202962; font-size:15px; font-weight:400; }
|
||||
.application-body .entity-tabs button.active,.application-body .recipe-workspace-tabs button.active { color:#050841; background:#f1f5fe; border-bottom:2px solid #3d5df6; }
|
||||
.application-body .entity-tab-panel { padding-top:38px; }
|
||||
@@ -798,7 +797,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .detail-actions-menu button { display:block; width:100% !important; max-width:100% !important; min-height:44px; padding:10px 20px; color:#050841; background:transparent; border:0; border-radius:0; cursor:pointer; font-size:15px; font-weight:500; text-align:left; white-space:nowrap; box-sizing:border-box; }
|
||||
.application-body .detail-actions-menu button:hover { background:#f1f5fe; }
|
||||
/* Recipe Books */
|
||||
.application-body .recipe-book-page { min-height: 100vh; background: #f3f3f3; padding-top: 48px; }
|
||||
.application-body .recipe-book-page { min-height: 100vh; background: #ffffff; padding-top: 48px; }
|
||||
.application-body .recipe-book-workspace { width: min(1120px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 64px; }
|
||||
.application-body .recipe-book-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 28px; }
|
||||
.application-body .recipe-book-breadcrumbs { margin-bottom: 8px; }
|
||||
@@ -815,13 +814,13 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .book-action-btn.active { color: #687086; }
|
||||
.application-body .book-notice { margin-bottom: 20px; padding: 12px 16px; background: #fff1f1; border: 1px solid #f5cece; border-radius: 6px; color: #8d2e2e; font-size: 14px; }
|
||||
.application-body .book-directory-table { background: #fff; border: 1px solid #eef0f5; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
|
||||
.application-body .book-directory-toolbar { display: flex; align-items: center; justify-content: space-between; min-height: 48px; padding: 0 20px; background: #fbfbfb; border-bottom: 1px solid #f3f3f3; }
|
||||
.application-body .book-directory-toolbar { display: flex; align-items: center; justify-content: space-between; min-height: 48px; padding: 0 20px; background: #ffffff; border-bottom: 1px solid #edf0f5; }
|
||||
.application-body .book-toolbar-title { color: #050841; font-size: 14px; font-weight: 600; }
|
||||
.application-body .book-toolbar-count { color: #8b93a7; font-size: 13px; font-weight: 400; }
|
||||
.application-body .book-recipe-list { display: flex; flex-direction: column; }
|
||||
.application-body .book-recipe-row { display: grid; grid-template-columns: 36px minmax(0, 1fr) 36px; align-items: center; gap: 12px; min-height: 58px; padding: 0 20px; border-bottom: 1px solid #f3f3f3; transition: background-color 0.12s ease; }
|
||||
.application-body .book-recipe-row { display: grid; grid-template-columns: 36px minmax(0, 1fr) 36px; align-items: center; gap: 12px; min-height: 58px; padding: 0 20px; border-bottom: 1px solid #edf0f5; transition: background-color 0.12s ease; }
|
||||
.application-body .book-recipe-row:last-child { border-bottom: 0; }
|
||||
.application-body .book-recipe-row:hover { background: #fbfbfb; }
|
||||
.application-body .book-recipe-row:hover { background: #f8faff; }
|
||||
.application-body .book-recipe-icon { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; background: #3c4679; border-radius: 50%; color: #fff; }
|
||||
.application-body .book-recipe-title { color: #050841; font-size: 15px; font-weight: 500; text-decoration: none; transition: color 0.12s ease; }
|
||||
.application-body .book-recipe-title:hover { color: #3d5df6; }
|
||||
@@ -846,7 +845,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .book-input, .application-body .book-textarea { width: 100%; padding: 10px 14px; background: #fff; border: 1px solid #dfe3ec; border-radius: 4px; color: #050841; font-family: var(--meez-font-sans); font-size: 15px; font-weight: 400; outline: none; box-sizing: border-box; transition: border-color 0.15s ease, box-shadow 0.15s ease; }
|
||||
.application-body .book-input:focus, .application-body .book-textarea:focus { border-color: #3d5df6; box-shadow: 0 0 0 2px rgba(61, 93, 246, 0.15); }
|
||||
.application-body .book-recipe-checklist { display: flex; flex-direction: column; gap: 4px; max-height: 480px; overflow-y: auto; padding-right: 4px; }
|
||||
.application-body .book-checklist-item { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 8px 14px; background: #fff; border: 1px solid #f3f3f3; border-radius: 6px; cursor: pointer; transition: background 0.12s ease, border-color 0.12s ease; }
|
||||
.application-body .book-checklist-item { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 8px 14px; background: #fff; border: 1px solid #edf0f5; border-radius: 6px; cursor: pointer; transition: background 0.12s ease, border-color 0.12s ease; }
|
||||
.application-body .book-checklist-item:hover { background: #f7f9fd; border-color: #e4e7ed; }
|
||||
.application-body .book-checklist-item:has(.book-checkbox:checked) { background: #f1f5fe; border-color: #cfd5fa; }
|
||||
.application-body .book-checkbox { width: 18px; height: 18px; margin: 0; accent-color: #3d5df6; cursor: pointer; }
|
||||
@@ -880,7 +879,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .workspace-pills > a.archive-link { margin-left:auto; color:#a5a9c1; background:transparent; border-color:transparent; }
|
||||
|
||||
/* Archive Workspace */
|
||||
.application-body .archive-page { min-height: 100vh; background: #f3f3f3; padding-top: 48px; }
|
||||
.application-body .archive-page { min-height: 100vh; background: #ffffff; padding-top: 48px; }
|
||||
.application-body .archive-workspace { width: min(1120px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 64px; }
|
||||
.application-body .archive-header { margin-bottom: 24px; }
|
||||
.application-body .archive-breadcrumbs { margin-bottom: 8px; }
|
||||
@@ -894,10 +893,10 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .archive-chip.active { background: #DBE4FF; border-color: #DBE4FF; }
|
||||
.application-body .archive-chip .chip-count { color: #8b93a7; font-size: 12px; font-weight: 500; }
|
||||
.application-body .archive-table { background: #fff; border: 1px solid #eef0f5; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
|
||||
.application-body .archive-table-head { display: grid; grid-template-columns: 56px minmax(0, 1fr) 180px 120px; align-items: center; min-height: 48px; padding: 0 20px; background: #fbfbfb; border-bottom: 1px solid #f3f3f3; color: #8b93a7; font-size: 13px; font-weight: 500; }
|
||||
.application-body .archive-table-row { display: grid; grid-template-columns: 56px minmax(0, 1fr) 180px 120px; align-items: center; min-height: 58px; padding: 0 20px; border-bottom: 1px solid #f3f3f3; transition: background-color 0.12s ease; }
|
||||
.application-body .archive-table-head { display: grid; grid-template-columns: 56px minmax(0, 1fr) 180px 120px; align-items: center; min-height: 48px; padding: 0 20px; background: #ffffff; border-bottom: 1px solid #edf0f5; color: #8b93a7; font-size: 13px; font-weight: 500; }
|
||||
.application-body .archive-table-row { display: grid; grid-template-columns: 56px minmax(0, 1fr) 180px 120px; align-items: center; min-height: 58px; padding: 0 20px; border-bottom: 1px solid #edf0f5; transition: background-color 0.12s ease; }
|
||||
.application-body .archive-table-row:last-child { border-bottom: 0; }
|
||||
.application-body .archive-table-row:hover { background: #fbfbfb; }
|
||||
.application-body .archive-table-row:hover { background: #f8faff; }
|
||||
.application-body .archive-type-cell { display: flex; align-items: center; }
|
||||
.application-body .archive-name-cell { display: flex; flex-direction: column; min-width: 0; }
|
||||
.application-body .archive-item-title { color: #050841; font-size: 15px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -908,7 +907,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .empty-icon-circle { display: grid; place-items: center; width: 56px; height: 56px; margin-bottom: 16px; background: #f1f5fe; border-radius: 50%; color: #3d5df6; }
|
||||
|
||||
/* Purchasing Review Tool */
|
||||
.application-body .purchasing-review-page { min-height: 100vh; background: #f3f3f3; padding-top: 48px; }
|
||||
.application-body .purchasing-review-page { min-height: 100vh; background: #ffffff; padding-top: 48px; }
|
||||
.application-body .purchasing-review-workspace { width: min(1120px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 64px; }
|
||||
.application-body .purchasing-review-header { margin-bottom: 24px; }
|
||||
.application-body .purchasing-breadcrumbs { margin-bottom: 8px; }
|
||||
@@ -922,7 +921,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .stats-label { color: #050841; font-size: 14px; font-weight: 500; }
|
||||
.application-body .stats-hint { color: #8b93a7; font-size: 12px; }
|
||||
.application-body .purchasing-review-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.application-body .purchasing-search-box { display: flex; align-items: center; gap: 6px; min-height: 38px; padding: 0 12px; background: #fbfbfb; border: 1px solid #dfe3ec; border-radius: 4px; }
|
||||
.application-body .purchasing-search-box { display: flex; align-items: center; gap: 6px; min-height: 38px; padding: 0 12px; background: #ffffff; border: 1px solid #dfe3ec; border-radius: 4px; }
|
||||
.application-body .purchasing-search-box .search-icon { color: #8283a0; flex: none; }
|
||||
.application-body .purchasing-search-box input { border: 0; background: transparent; outline: none; font-family: var(--meez-font-sans); font-size: 14px; color: #050841; min-width: 180px; }
|
||||
.application-body .search-clear-btn { background: transparent; border: 0; color: #a5a9c1; font-size: 18px; cursor: pointer; padding: 0 2px; }
|
||||
@@ -1163,8 +1162,13 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
color: #050841;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.application-body .unified-recipe-editor .editor-items tr.component-header-row,
|
||||
.application-body .unified-recipe-editor .editor-items tr.component-header-row:hover,
|
||||
.application-body .unified-recipe-editor .editor-items tr.component-header-row:focus-within {
|
||||
background: transparent !important;
|
||||
}
|
||||
.application-body .unified-recipe-editor .editor-items tr.component-header-row td {
|
||||
padding: 16px 0 6px;
|
||||
padding: 18px 0 6px;
|
||||
border-bottom: 0;
|
||||
background: transparent;
|
||||
}
|
||||
@@ -1173,15 +1177,17 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
padding: 4px 12px;
|
||||
background: #f3f3f3;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.15s ease;
|
||||
min-height: 38px;
|
||||
padding: 4px 0;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
border-radius: 0;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
.application-body .unified-recipe-editor .component-header-content:hover,
|
||||
.application-body .unified-recipe-editor .component-header-content:focus-within {
|
||||
background: #ebedf2;
|
||||
background: transparent;
|
||||
border-bottom-color: #3d5df6;
|
||||
}
|
||||
.application-body .unified-recipe-editor .component-name-input {
|
||||
width: 100%;
|
||||
@@ -1933,9 +1939,9 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.bulk-ingredient-dialog .bulk-submit:disabled { background:#aeb9f8; cursor:not-allowed; }
|
||||
.bulk-prep-dialog { width:min(860px,100%); }
|
||||
.bulk-prep-dialog textarea { min-height:390px; }
|
||||
.application-body .unified-recipe-editor .method-editor li.prep-heading { min-height:70px; padding-block:18px; background:#fbfbfd; }
|
||||
.application-body .unified-recipe-editor .method-editor li.prep-heading { min-height:70px; padding-block:18px; background:#ffffff; }
|
||||
.application-body .unified-recipe-editor .method-editor li.prep-heading textarea { min-height:34px; font-weight:700; }
|
||||
.application-body .unified-recipe-editor .method-editor li.prep-note { min-height:76px; background:#fafbfe; }
|
||||
.application-body .unified-recipe-editor .method-editor li.prep-note { min-height:76px; background:#ffffff; }
|
||||
.application-body .unified-recipe-editor .method-editor li.prep-note textarea { min-height:44px; color:#727a91; font-style:italic; }/* Recipe edit title and breadcrumb refinements. */
|
||||
.application-body .editable-entity-title {
|
||||
margin: 0;
|
||||
@@ -1957,10 +1963,10 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
}
|
||||
|
||||
/* Persistent detail breadcrumbs. */
|
||||
.application-body .immersive-main .detail-utility { position:absolute; z-index:30; top:0; left:0; right:0; width:100%; height:48px; min-height:48px; display:grid; grid-template-columns:50% 50%; gap:0; padding:0; background:#fbfbfb; border-bottom:1px solid #f3f3f3; }
|
||||
.application-body .immersive-main .detail-utility { position:absolute; z-index:30; top:0; left:0; right:0; width:100%; height:48px; min-height:48px; display:grid; grid-template-columns:50% 50%; gap:0; padding:0; background:#ffffff; border-bottom:1px solid #edf0f5; }
|
||||
.application-body .detail-context,.application-body .detail-tools { display:flex; align-items:center; min-width:0; height:48px; }
|
||||
.application-body .detail-context { gap:14px; padding:0 36px; }
|
||||
.application-body .detail-tools { gap:18px; padding:0 32px; border-left:1px solid #f3f3f3; }
|
||||
.application-body .detail-tools { gap:18px; padding:0 32px; border-left:1px solid #edf0f5; }
|
||||
.application-body .detail-tools form { flex:1; width:auto; }
|
||||
.application-body .detail-back { display:grid; place-items:center; width:30px; height:30px; color:#202962; text-decoration:none; font-size:30px; font-weight:400; line-height:1; }
|
||||
.application-body .detail-back:hover { color:#3d5df6; }
|
||||
@@ -2054,7 +2060,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
|
||||
|
||||
/* Recipe read view — meez skin (measured live 2026-08-14). */
|
||||
.application-body .immersive-main { background:#f3f3f3; }
|
||||
.application-body .immersive-main { background:#ffffff; }
|
||||
.application-body .recipe-detail-shell.recipe-read-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
@@ -2064,7 +2070,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
min-height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #fbfbfb;
|
||||
background: #ffffff;
|
||||
}
|
||||
.application-body .recipe-read-left {
|
||||
width: 100%;
|
||||
@@ -2099,13 +2105,13 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .recipe-read-right {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background: #fbfbfb;
|
||||
background: #ffffff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.application-body .recipe-read-right .recipe-workspace-tabs {
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
background: #fbfbfb;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
.application-body .recipe-read-right .recipe-view-details {
|
||||
@@ -2113,7 +2119,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
background: transparent;
|
||||
border-left: 0;
|
||||
}
|
||||
.application-body .recipe-detail-shell .entity-detail-header { background:#fbfbfb; }
|
||||
.application-body .recipe-detail-shell .entity-detail-header { background:#ffffff; }
|
||||
.application-body .recipe-detail-shell > .entity-detail-header > div:first-child > p { display:block; }
|
||||
.application-body .recipe-detail-shell .entity-breadcrumb a { color:#202962; font-size:12px; font-weight:400; text-decoration:none; }
|
||||
.application-body .recipe-detail-shell .entity-detail-header h1 { font-size:28px; font-weight:900; line-height:42px; }
|
||||
@@ -2125,8 +2131,8 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
/* Tab row: 48px, icon + 15px label, active = #f1f5fe with blue underline. */
|
||||
.application-body .recipe-workspace-tabs {
|
||||
min-height: 48px;
|
||||
background: #fbfbfb;
|
||||
border-bottom: 1px solid #f3f3f3;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.application-body .recipe-workspace-tabs button {
|
||||
@@ -2257,10 +2263,10 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
display: grid;
|
||||
}
|
||||
|
||||
/* Two-column workspace: #fbfbfb cards on #f3f3f3. */
|
||||
.application-body .recipe-view-workspace { grid-template-columns:1fr 1fr; border-top:0; background:#fbfbfb; }
|
||||
.application-body .recipe-view-formula { padding:26px 36px 35px; border-right:0; background:#fbfbfb; }
|
||||
.application-body .recipe-view-details { padding:26px 32px 60px; border-left:1px solid #f3f3f3; background:#fbfbfb; }
|
||||
/* Two-column workspace: clean pure white on white. */
|
||||
.application-body .recipe-view-workspace { grid-template-columns:1fr 1fr; border-top:0; background:#ffffff; }
|
||||
.application-body .recipe-view-formula { padding:26px 36px 35px; border-right:0; background:#ffffff; }
|
||||
.application-body .recipe-view-details { padding:26px 32px 60px; border-left:1px solid #edf0f5; background:#ffffff; }
|
||||
|
||||
/* Formula heading: label + value text controls, no input boxes. */
|
||||
.application-body .recipe-view-formula .calculator-heading { display:flex; align-items:center; gap:36px; margin-bottom:26px; }
|
||||
@@ -2274,7 +2280,26 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .recipe-view-formula .calculator-heading .input-with-unit > span { border-left:0; color:#050841; font-size:14px; font-weight:400; }
|
||||
|
||||
/* Formula table: borderless rows, meez typography. */
|
||||
.application-body .recipe-view-formula .formula-component { margin:0 0 16px 0; border:0; background:transparent; font:inherit; }
|
||||
.application-body .recipe-view-formula .formula-component { margin:0 0 20px 0; border:0; background:transparent; font:inherit; }
|
||||
.application-body .recipe-view-formula .formula-component h3,
|
||||
.application-body .recipe-view-formula .formula-component-heading {
|
||||
margin: 18px 0 8px 0;
|
||||
padding: 0 0 6px 0;
|
||||
color: #050841;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.application-body .recipe-view-formula .formula-component-note {
|
||||
margin: 4px 0 8px;
|
||||
color: #757677;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.application-body .recipe-view-formula .recipe-ingredients thead { display:none; }
|
||||
.application-body .recipe-view-formula .recipe-ingredients td { padding:8px 0; border:0; border-bottom:0; background:transparent; }
|
||||
.application-body .recipe-view-formula .recipe-ingredients td:first-child { padding-right:10px; }
|
||||
@@ -2288,12 +2313,38 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
/* Prep method panel. */
|
||||
.application-body .recipe-view-method h2 { margin:0 0 34px; font-size:22px; font-weight:700; line-height:33px; }
|
||||
.application-body .recipe-view-method h2 small { margin-left:8px; color:#a5a9c1; font-size:16px; font-weight:700; }
|
||||
.application-body .recipe-view-method ol { padding:0; list-style:none; }
|
||||
.application-body .recipe-view-method li { grid-template-columns:28px minmax(0,1fr); gap:16px; min-height:0; padding:16px 0; background:transparent; border:0; border-bottom:1px solid #f3f3f3; }
|
||||
.application-body .recipe-view-method ol { padding:0; list-style:none; margin:0; }
|
||||
.application-body .recipe-view-method li { grid-template-columns:28px minmax(0,1fr); gap:16px; min-height:0; padding:16px 0; background:transparent; border:0; border-bottom:1px solid #edf0f5; }
|
||||
.application-body .recipe-view-method li:last-child { border-bottom:0; }
|
||||
.application-body .recipe-view-method li > strong { color:#050841; font-size:16px; font-weight:700; }
|
||||
.application-body .recipe-view-method li > span { color:#050841; font-size:16px; font-weight:300; line-height:20px; }
|
||||
.application-body .recipe-view-method li.placeholder > span { color:#a5a9c1; }
|
||||
.application-body .recipe-view-method li.prep-heading {
|
||||
display: block;
|
||||
padding: 20px 0 6px;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
.application-body .recipe-view-method li.prep-heading h3,
|
||||
.application-body .recipe-view-method .method-heading-text {
|
||||
margin: 0;
|
||||
color: #050841;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
}
|
||||
.application-body .recipe-view-method li.prep-note {
|
||||
display: block;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.application-body .recipe-view-method li.prep-note p,
|
||||
.application-body .recipe-view-method .method-note-text {
|
||||
margin: 0;
|
||||
color: #757677;
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
line-height: 1.4;
|
||||
}
|
||||
/* Secondary panels: meez panel headings and copy. */
|
||||
/* U of M Equivalency: Meez Style */
|
||||
.application-body .recipe-view-equivalencies {
|
||||
@@ -2574,7 +2625,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
}
|
||||
|
||||
/* Recipe read view — meez steps clone (measured live 2026-08-15). */
|
||||
.application-body .immersive-main:has(.recipe-read-shell) { background:#fbfbfb; }
|
||||
.application-body .immersive-main:has(.recipe-read-shell) { background:#ffffff; }
|
||||
.application-body .immersive-main .recipe-read-shell {
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1fr) minmax(0,1fr);
|
||||
@@ -2586,7 +2637,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .recipe-read-shell .recipe-view-formula-pane,
|
||||
.application-body .recipe-read-shell .recipe-view-details-pane { flex:none; min-width:0; }
|
||||
|
||||
/* App bar: 48px #fbfbfb, no bottom border. */
|
||||
/* App bar: 48px #ffffff, no bottom border. */
|
||||
.application-body .recipe-read-shell .detail-utility {
|
||||
position:sticky;
|
||||
top:0;
|
||||
@@ -2594,7 +2645,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
height:48px;
|
||||
min-height:48px;
|
||||
padding:0;
|
||||
background:#fbfbfb;
|
||||
background:#ffffff;
|
||||
border-bottom:0;
|
||||
}
|
||||
.application-body .recipe-read-shell .detail-context,
|
||||
@@ -2674,8 +2725,8 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
min-height:48px;
|
||||
margin:0;
|
||||
padding:0;
|
||||
background:#fbfbfb;
|
||||
border-bottom:2px solid #f3f3f3;
|
||||
background:#ffffff;
|
||||
border-bottom:2px solid #edf0f5;
|
||||
}
|
||||
.application-body .immersive-main .recipe-read-shell .recipe-workspace-tabs button,
|
||||
.application-body .recipe-read-shell .recipe-workspace-tabs button,
|
||||
@@ -2814,8 +2865,8 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .immersive-main .recipe-read-shell .recipe-view-details {
|
||||
padding:0 32px 32px;
|
||||
border-top:0;
|
||||
border-left:1px solid #f3f3f3;
|
||||
background:#fbfbfb;
|
||||
border-left:1px solid #edf0f5;
|
||||
background:#ffffff;
|
||||
}
|
||||
/* Tab panel switching: clean active state with no layout reflow or margin leakage */
|
||||
.application-body .recipe-view-details > .recipe-tab-panel,
|
||||
@@ -3135,7 +3186,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
min-height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #fbfbfb;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.application-body .ingredient-read-left {
|
||||
@@ -3470,7 +3521,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background: #fbfbfb;
|
||||
background: #ffffff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -3775,9 +3826,9 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
height: 48px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
background: #fbfbfb !important;
|
||||
background: #ffffff !important;
|
||||
border: 0 !important;
|
||||
border-bottom: 1px solid #f3f3f3 !important;
|
||||
border-bottom: 1px solid #edf0f5 !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
*/
|
||||
:root {
|
||||
/* Surface Colors */
|
||||
--meez-bg-page: #f3f3f3;
|
||||
--meez-bg-card: #fbfbfb;
|
||||
--meez-bg-card-alt: #f1f5fe;
|
||||
--meez-bg-card-hover: #f1f5fe;
|
||||
--meez-bg-page: #ffffff;
|
||||
--meez-bg-card: #ffffff;
|
||||
--meez-bg-card-alt: #fafbfd;
|
||||
--meez-bg-card-hover: #f8faff;
|
||||
--meez-bg-white: #ffffff;
|
||||
--meez-bg-active-pill: #dbe4ff;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user