Skip to content

Commit 7700032

Browse files
authored
feat: migrate shopping list to new format with checked state (#318)
* feat: migrate shopping list to new format with server-side checked state Replaces the old tab-delimited `.shopping_list.txt` with the new `.shopping-list` format from proposal 0016 and adds `.shopping-checked` for persistent ingredient check state: - Rewrite ShoppingListStore to use cooklang-rs shopping_list parser - Auto-migrate from legacy format on first load (renames old file to .bak) - Add check/uncheck/checked/compact API endpoints - Replace localStorage-based checked state with server-side persistence - Compact checked log on recipe removal to prune stale entries * feat: recipe refs, menu plans, and PR review fixes - Add per-recipe reference checkboxes so users control which sub-recipes get included when adding to the shopping list (all checked by default). - Add menus as a single plan entry with nested recipes (and their sub-refs as grandchildren) instead of one top-level entry per recipe. - Expose name as a derived, read-only field: recipe_display_name(path) is computed on load and on add, so any client-supplied name is no longer silently discarded. - Fix migration bypass: add(), add_menu(), and remove() now call migrate_if_needed() before touching .shopping-list, so the legacy .shopping_list.txt can't be orphaned by a write before the first load. - Fix compact() race: acquire an exclusive advisory file lock (fs2) for the read-modify-write so concurrent check()/uncheck() appends can't be lost. check()/uncheck() also lock while appending. - Ignore .shopping-list, .shopping-checked, and .shopping_list.txt.bak in .gitignore. - Flag the ../cooklang-rs path dependency with a TODO referencing cooklang/cooklang-rs#93 until that PR is released. - Refresh recipe snapshot tests for the new upstream parser output (full unit names, scalable flag). * fix(ci): use git dependency for cooklang so CI can resolve it The `path = "../cooklang-rs"` dep only works locally. Point at the upstream branch feat/shopping-list-checked-support until cooklang/cooklang-rs#93 is merged and released. * fix(shopping-list): stop wiping checked log on recipe removal `store.remove()` was calling `compact()`, which delegated to `cooklang::shopping_list::compact_checked(entries, &ShoppingList)`. That upstream helper only kept entries whose names appeared as `Ingredient` items in the list — but our on-disk `.shopping-list` persists recipe *references* only. Every checked entry was therefore considered stale and dropped, so removing any recipe cleared the whole checked file. Pair with the cooklang-rs contract change: `compact_checked` now accepts the user-visible ingredient names directly. Introduce `aggregate_current_ingredient_names()` which walks the stored list and expands each reference via `extract_ingredients` (honoring `included_references` and per-recipe scale). Both the `remove` and explicit `compact` endpoints feed those names into `store.compact()`. Remove failures in aggregation degrade to a warn log so a recipe removal can't fail on a render-time parse error. Also indent nested sub-recipes under their parent recipe card so Pizza Dough visibly nests under Neapolitan Pizza (border-l accent). * fix(shopping-list): address PR review — XSS, atomic compact, error handling Security (high): - Escape every untrusted interpolation (recipe names, paths, ref names, ingredient names, category names) in shopping_list.html. A recipe file called `<img src=x onerror=alert(1)>.cook` is a legal filename and previously executed script via `innerHTML`. - Move inline `onclick="removeRecipe('${path}')"` and `onchange="toggleItem('${itemId}', ..., '${name}')"` to delegated handlers keyed off `data-*` attributes. Script-attribute context no longer receives user-controlled data at all, so a single-quote break in a path/name can't escape. Correctness: - `aggregate_current_ingredient_names` now propagates parse errors via `with_context(..)?` instead of `let _ =`. On remove, the handler still treats compact as best-effort — aggregation failure warns and skips compaction, so a broken recipe can never wipe the checked log. - `compact()` now stages to `.shopping-checked.tmp` and renames into place. A crash between truncate and write previously left the file zero-length; rename is atomic on POSIX so the original is preserved until the new content is fully durable. Refactor: - Extract `to_multiplier(scale)` — was duplicated three times across `add`, `add_menu` (outer), and `add_menu` (inner). Dependency: - Repoint `cooklang` git dep from the now-merged `feat/shopping-list-checked-support` branch to `main` (picks up 0.18.5-dev with #93 merged plus the typed `ShoppingListError` follow- up). To swap to a crates.io version once the next release lands. * chore(deps): switch cooklang to crates.io 0.18.5 0.18.5 landed on crates.io with cooklang/cooklang-rs#93 merged, including the name-based `compact_checked` API plus the typed `ShoppingListError` follow-up. Repoint to the stable version and drop the branch-based git dep — also collapses the duplicate cooklang copies in the workspace (cooklang-find, cooklang-reports, cooklang-language-server all resolve 0.18.5 now), addressing the review concern about multiple cooklang versions compiling. * fix(shopping-list): serialize checked-log writes with in-process mutex File-level flock doesn't prevent two Axum tasks in the same process from racing on `.shopping-checked` — the kernel treats them as one lock owner, so a concurrent compact could still truncate between an append's open and write. Replace flock with a `tokio::sync::Mutex<()>` on AppState held by every check/uncheck/compact handler (including the post-remove compact). Drops the fs2 dependency and simplifies `compact()` to a plain read + atomic temp-file rename. Also refreshes 5 snapshot fixtures to match cooklang 0.18.5 output. * fix(shopping-list): address PR review — clear race, dup recipes, URL encoding - `clear_shopping_list` now acquires `checked_log_lock` so a concurrent check/uncheck can't recreate `.shopping-checked` between our remove_file and the caller's view of a cleared list. - `aggregate_current_ingredient_names` now resets `seen` per top-level shopping-list entry. The list may legitimately contain the same recipe multiple times (e.g. duplicates from legacy format); sharing one `seen` across all entries misreported the second occurrence as a circular dependency, skipped compaction, and let stale checks accumulate. - Replaced `encodeURI(path)` in shopping_list.html with a segment-wise `encodeRecipePath` helper that uses `encodeURIComponent` per segment. `encodeURI` leaves `#`, `?`, `&`, `=` alone (they're valid URI delimiters), which breaks recipe filenames that contain them. - `compact()` now removes the `.shopping-checked.tmp` file on write or rename failure so crashes don't leave dangling temp files.
1 parent b79e033 commit 7700032

13 files changed

Lines changed: 950 additions & 125 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,6 @@ playwright/.cache/
2222
*.tmp
2323
*.log
2424
.shopping_list.txt
25+
.shopping_list.txt.bak
26+
.shopping-list
27+
.shopping-checked

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ camino = { version = "1", features = ["serde1"] }
3838
chrono = "0.4"
3939
clap = { version = "4.5", features = ["derive"] }
4040
base64 = { version = "0.22", optional = true }
41-
cooklang = { version = "0.18.4", default-features = false, features = ["aisle", "pantry"] }
41+
cooklang = { version = "0.18.5", default-features = false, features = ["aisle", "pantry", "shopping_list"] }
4242
cooklang-find = { version = "0.5.0" }
4343
cooklang-import = "0.9.3"
4444
cooklang-sync-client = { version = "0.4.11", optional = true }

locales/en-US/shopping.ftl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ shopping-failed-to-generate = Failed to generate shopping list
2626
shopping-failed-to-add = Failed to add to shopping list
2727
shopping-error = Error
2828
shopping-print = Print
29+
shopping-include-in-list = Include in shopping list

src/server/handlers/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ pub use pantry::{
1414
};
1515
pub use recipes::{all_recipes, recipe, recipe_delete, recipe_raw, recipe_save, reload, search};
1616
pub use shopping_list::{
17-
add_to_shopping_list, clear_shopping_list, get_shopping_list_items, remove_from_shopping_list,
18-
shopping_list,
17+
add_menu_to_shopping_list, add_to_shopping_list, check_shopping_item, clear_shopping_list,
18+
compact_checked, get_checked_items, get_shopping_list_items, remove_from_shopping_list,
19+
shopping_list, uncheck_shopping_item,
1920
};
2021
pub use stats::stats;
2122
#[cfg(feature = "sync")]

0 commit comments

Comments
 (0)