feat: migrate shopping list to new format with checked state#318
feat: migrate shopping list to new format with checked state#318
Conversation
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
- 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).
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.
`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).
…ndling
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.
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.
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.
…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.
Code ReviewOverall this is a solid and well-thought-out migration. The design decisions (append-only checked log, atomic compact via rename, in-process mutex over file locks) are well documented and correct. A few things worth addressing: Bug:
|
Summary
ShoppingListStoreto use the new.shopping-listformat (cooklang-rs parser) instead of the old tab-delimited.shopping_list.txt.shopping-checkedfile for server-side persistent ingredient check state, replacing localStorage.bak)POST /check,POST /uncheck,GET /checked,POST /compactDepends on: cooklang/cooklang-rs#(the checked file support PR)
Test plan
.shopping_list.txt— verify auto-migration creates.shopping-listand renames old file.shopping-listfile updates correctly.shopping-checkedfile receives+ name/- nameentries.shopping-checkedis compacted (stale entries removed)