Commit 7700032
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
- locales/en-US
- src
- server
- handlers
- util
- templates
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
22 | 22 | | |
23 | 23 | | |
24 | 24 | | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
41 | | - | |
| 41 | + | |
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
26 | 26 | | |
27 | 27 | | |
28 | 28 | | |
| 29 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
17 | | - | |
18 | | - | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
19 | 20 | | |
20 | 21 | | |
21 | 22 | | |
| |||
0 commit comments