Skip to content

feat(server): pages to manage aisles - #631

Merged
LeMyst merged 4 commits into
cooklang:mainfrom
LeMyst:feat/aisle-pages
Oct 7, 2026
Merged

LeMyst merged 4 commits into
cooklang:mainfrom
LeMyst:feat/aisle-pages

Conversation

@LeMyst

@LeMyst LeMyst commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #630.

A new /aisles page, linked from the shopping list header ("Manage aisles") and from Preferences next to the aisle file's path, manages aisle.conf from the browser.

Aisles

Grouping, bulk filing and new aisles (233119e)

After a first round of use:

  • Group several entries into one. "Select" on the Aisles tab turns chips into toggles. Group… asks which name the shopping list should show, offering the shortest main name first ("butter" over "salted butter"), and merges the others into it. The result stays where the chosen entry was, in its aisle and with its comment. Separately, the selection can be moved to an aisle in one go: "Move to aisle" has its own section of the bar, and is hidden while the Group panel is open, since grouping keeps the chosen entry's aisle.
  • The main name is explicit. Chips show it in bold, and the edit panel lists the names with the main one marked ★. Any other name can be made main (☆), removed (×), or added to. Moving the ingredient is on its own row below, labelled "Move to aisle" (3ce52b5).
  • Uncategorized in bulk. Each row has a checkbox, plus a filter and "Select all shown". The picked ingredients go into an aisle together, or join an existing ingredient as other names, which is how "cold butter" becomes part of "butter". Only picks the filter shows are acted on; hidden ones are kept for when it is cleared. Spellings that differ only in case (Salt/salt) are filed once, since the list matches ignoring case.
  • "+ New aisle…" in every aisle picker, including the shopping list's "Assign aisle…". It asks for a name and creates the aisle in the same request as what it was picked for.
Select bar Group
Names of one ingredient, and moving it Mobile
Uncategorized, several at once Mobile

What it does

  • Aisles tab: the aisles in file order, which is the order the shopping list uses. Each ingredient is a chip showing its first name and its other names. You can add, rename, move up or down and remove an aisle (removing one confirms how many ingredients go with it). Click a chip to change its names (and which one is main), move it to another aisle or remove it.
  • Uncategorized tab: the ingredients the recipes and menus use that no aisle names (the cook doctor aisle check, via cookcli_core::doctor::aisle_coverage), each with links to the recipes using it and an aisle picker.
  • Text tab: the raw file. It is saved only once cooklang::aisle::parse accepts it; if not, the error names the line.
  • No aisle file: a "Create aisle.conf" button starts an empty config/aisle.conf in the recipe directory. The server now uses an aisle file that appears after startup (AppState::aisle_file()), so no restart is needed.
  • Shopping list: items in other get an "Assign aisle…" picker. Picking an aisle adds the ingredient to it and regroups the list.
Editing an ingredient Uncategorized Shopping list
Text Mobile Preferences

Design notes

  • Line edits, not a rewrite. src/server/aisle_file.rs reads lines the way cooklang::aisle::parse does and changes only the lines an edit is about. Comments, and the blank lines seed/config/aisle.conf uses to group a long aisle, survive. cooklang::aisle::write would drop both. Comments directly above an aisle move and are removed with it; CRLF files stay CRLF. In the end-to-end test, adding, moving and removing an ingredient leaves seed's file byte-identical.
  • Revisions. Every response carries a revision, a hash of the file text. A change sent with an outdated revision gets 409, and the page reloads the aisles and asks you to try again. On the Text tab your typing is kept, and saving a second time replaces the other change. Changes are also serialised with an in-process lock.
  • Uniqueness. Ingredient names are kept unique ignoring case, because the shopping list matches them that way (ingredients_info lowercases), and so are aisle names. A name with | or // is refused, since it would turn into a synonym or a comment.
  • Backstop. A change that would leave a previously valid file unreadable to the strict parser is refused.

API

All under /api/aisles and documented in /api-docs and docs/api.md. Writes need Capability::EditLists (shopper and up), like the pantry, and are written to the activity log.

GET /api/aisles aisles, revision, parser warnings, and file (relative to the recipe directory) / shared (the global file)
POST /api/aisles create config/aisle.conf (201, or 409 if one exists)
POST /api/aisles/changes {revision?, action, …}: add_aisle, rename_aisle, remove_aisle, move_aisle, add_ingredient, update_ingredient, remove_ingredient, add_names, merge_ingredients; or {revision?, changes: [ … ]}, applied in order and written together or not at all (the error names the change that failed)
GET/PUT /api/aisles/raw the file as text
GET /api/aisles/uncategorized ingredients no aisle names, with their recipes

Under --recipes-only, guests cannot reach /aisles because of the existing allowlist.

No absolute paths (32daca3). The aisle file's full path is never sent: GET /api/aisles gives file, the path relative to the recipe directory (config/aisle.conf), or shared: true for the global aisle file. GET /api/aisles/raw and the 409 from POST /api/aisles don't say where the file is; the server's own log does. These reads are open to visitors who haven't signed in, so the full path would have shown them the server's directories and account name. A test checks that no answer, and not the page either, contains the server's directory. The Preferences page has the same issue from before this PR: #637.

Tests

  • aisle_file.rs: 19 unit tests covering preserved lines, CRLF, moving blocks with their comments, case-insensitive duplicates, invalid names, adding names and grouping (across aisles, main from any entry).
  • tests/aisles_api_test.rs: 7 tests that boot cook server (through with_isolated_config). They cover: only the changed lines are touched, 409 on a stale revision, 400/404 cases, raw text validation, creating the file from nothing (the shopping list picks it up without a restart), uncategorized, all-or-nothing batches, and grouped names showing on the shopping list under the main one.
  • server_auth_test: /api/aisles/changes added to the role matrix (reader refused, shopper and up allowed); middleware unit tests updated.
  • tests/e2e/aisles.spec.ts: 10 Playwright tests, including grouping, making a name main, filtered multi-select with joining an existing ingredient, bulk filing into a new aisle, and "+ New aisle…" on the shopping list. They restore seed/config/aisle.conf after each test and stub only the shopping list's item list, so they don't race other specs over the shared list.
  • Translations in all 9 locales (locales_test passes).

cargo fmt, cargo clippy --workspace --all-targets --all-features -- -D warnings, the same for --no-default-features --features server,lsp (the Docker build), cargo test (1149 passed) and cargo test --no-default-features (433 passed) are clean. The full Chromium suite locally: aisles, shopping-list and preferences specs all pass serially. With parallel workers, the only failures are the known shopping-list races between workers and static-file-search (needs a cook binary in the container), both seen on main in earlier runs; the shopping-list specs pass when run serially with this change.

🤖 Generated with Claude Code

LeMyst and others added 2 commits October 5, 2026 23:28
`aisle.conf` decides how the shopping list is grouped, but the web UI only
printed its path, and an ingredient no aisle named fell silently under
"other". A new /aisles page, reached from the shopping list and from
Preferences, shows the aisles in file order and lets a shopper:

- add, rename, reorder and remove aisles;
- add, rename, move and remove ingredients and their `a | b` synonyms;
- see the ingredients the recipes use that no aisle names (the
  `cook doctor aisle` check) and file each under an aisle;
- edit the file as text, saved only once the aisle parser reads it;
- start a `config/aisle.conf` where there is none.

The shopping list's "other" items get an "Assign aisle" picker too.

Edits go through `server::aisle_file`, which changes only the lines
concerned, so comments and the blank lines that group a long aisle stay
put; `cooklang::aisle::write` would have dropped both. Each change carries
the revision of the file it was made against and is refused with 409 if
the file moved on. Ingredient names are kept unique ignoring case, as the
shopping list matches them. The server now also picks up an aisle file
created after it started.

The routes live under /api/aisles at `Capability::EditLists`, like the
pantry; every write is recorded in the activity log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`AppState::aisle_file` sat in the impl block that only exists with
`sync`, so `--no-default-features --features server,lsp` (the Docker
image's build) did not compile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@LeMyst
LeMyst marked this pull request as ready for review October 5, 2026 23:22
LeMyst and others added 2 commits October 7, 2026 11:51
… from any picker

Feedback on the aisles page:

- Grouping. Several spellings of one thing (butter, salted butter,
  cold butter…) can now become one ingredient. "Select" on the Aisles
  tab picks chips; "Group…" asks which name the shopping list should
  show and merges the rest into it (`merge_ingredients`). The edit panel
  lists an ingredient's names with the main one marked, and any other
  can be made main, removed, or added to.
- Uncategorized in bulk. Rows get checkboxes, a filter and "Select all
  shown". The picked ones go into an aisle together, or join an existing
  ingredient as other names (`add_names`), which is how "cold butter"
  joins "butter". Only picks the filter shows are acted on.
- New aisle from any picker. Every aisle select, the shopping list's
  "Assign aisle…" included, ends with "+ New aisle…"; the aisle is
  created in the same request as what it was picked for.

`POST /api/aisles/changes` takes `changes`, a list applied in order and
written together or not at all; an error names the failing change. The
single flat change still works. The page also follows `#uncategorized`
and `#text` when the hash changes, not only on load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… page

Selecting ingredients printed "undefined" in the bar under the aisles, and
"false" once the Group panel was cancelled: the bar was filled with the
DOM's own `replaceChildren`, which writes a missing or false part out as
text where `el()` leaves it out. An aisle's header had the same pattern
and would print "false" for a reader. Both now go through `fill()`, which
skips them as `el()` does.

The aisle picker next to "Group…" and in an ingredient's edit panel read
as part of grouping or naming, which never use an aisle. It is moving,
so it now says so: "Move to aisle", in its own section of the bar,
hidden while the Group panel is open, and on its own row of the edit
panel, below the names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Manage aisles (aisle.conf) from the web server

1 participant