From a56934dc5fa48991302bff7c3616194d16e5ed3a Mon Sep 17 00:00:00 2001 From: Myst <1592048+LeMyst@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:28:08 +0200 Subject: [PATCH 1/4] feat(server): pages to manage aisles `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 --- docs/api.md | 150 ++++++ locales/de-DE/aisles.ftl | 43 ++ locales/en-US/aisles.ftl | 43 ++ locales/es-ES/aisles.ftl | 43 ++ locales/eu-ES/aisles.ftl | 43 ++ locales/fr-FR/aisles.ftl | 43 ++ locales/it-IT/aisles.ftl | 43 ++ locales/ja-JP/aisles.ftl | 43 ++ locales/nl-NL/aisles.ftl | 43 ++ locales/sv-SE/aisles.ftl | 43 ++ src/server/aisle_file.rs | 654 +++++++++++++++++++++++++++ src/server/auth/middleware.rs | 9 + src/server/handlers/aisles.rs | 534 ++++++++++++++++++++++ src/server/handlers/mod.rs | 4 + src/server/handlers/shopping_list.rs | 2 +- src/server/mod.rs | 31 ++ src/server/ui.rs | 40 +- src/web/api_docs.rs | 215 +++++++++ src/web/templates.rs | 18 + templates/aisles.html | 513 +++++++++++++++++++++ templates/preferences.html | 3 + templates/shopping_list.html | 66 +++ tests/aisles_api_test.rs | 421 +++++++++++++++++ tests/e2e/aisles.spec.ts | 120 +++++ tests/server_auth_test.rs | 6 +- 25 files changed, 3169 insertions(+), 4 deletions(-) create mode 100644 locales/de-DE/aisles.ftl create mode 100644 locales/en-US/aisles.ftl create mode 100644 locales/es-ES/aisles.ftl create mode 100644 locales/eu-ES/aisles.ftl create mode 100644 locales/fr-FR/aisles.ftl create mode 100644 locales/it-IT/aisles.ftl create mode 100644 locales/ja-JP/aisles.ftl create mode 100644 locales/nl-NL/aisles.ftl create mode 100644 locales/sv-SE/aisles.ftl create mode 100644 src/server/aisle_file.rs create mode 100644 src/server/handlers/aisles.rs create mode 100644 templates/aisles.html create mode 100644 tests/aisles_api_test.rs create mode 100644 tests/e2e/aisles.spec.ts diff --git a/docs/api.md b/docs/api.md index db4a4362..e0d7264d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -35,6 +35,7 @@ Every failure returns the same shape, with the status code carrying the meaning: - [Menus](#menus) - [Shopping List](#shopping-list) - [Pantry](#pantry) +- [Aisles](#aisles) - [Search & Stats](#search--stats) - [Realtime](#realtime) - [Sync](#sync) @@ -1012,6 +1013,155 @@ Response: ] ``` +## Aisles + +Reads and changes `aisle.conf`, which groups the shopping list by store aisle: an `[aisle]` line, then one ingredient per line with its other names after `|`. Changes edit only the lines concerned, so comments and blank lines elsewhere in the file are kept. Each response carries the file's `revision`; send it back with a change and the change is refused with `409` if the file has been changed since, along with the current `revision`. Leave it out to apply the change regardless. Changes need the `shopper` role when sign-in is on. + +### `GET /api/aisles` + +Read the aisles + +Aisles in file order, which is the order the shopping list uses. Each ingredient's first name is the one the shopping list shows; the others are merged into it. `warnings` lists the lines the parser skipped, such as a name listed twice. Without an aisle file, `configured` is `false`, `path` and `revision` are `null` and `aisles` is empty. The response below is trimmed to two of the seed's eight aisles and a few ingredients each. + +Response: + +```json +{ + "configured": true, + "path": "/path/to/recipes/config/aisle.conf", + "revision": "edbf1fd2579f964e", + "aisles": [ + { + "name": "fruit and veg", + "ingredients": [ + { "names": ["apples"] }, + { "names": ["avocado", "avocados"] } + ] + }, + { + "name": "milk and dairy", + "ingredients": [ + { "names": ["milk"] }, + { "names": ["butter"] } + ] + } + ], + "warnings": [] +} +``` + +### `POST /api/aisles` + +Create an aisle file + +Creates an empty `config/aisle.conf` in the recipe directory and answers `201` with the same shape as `GET /api/aisles`. `409` if an aisle file already exists, wherever it was found. + +### `POST /api/aisles/changes` + +Change the aisles + +One change per request, named by `action`; the other fields depend on it. `add_aisle` (`name`, optional `position`, counted from 0; last when omitted), `rename_aisle` (`name`, `new_name`), `remove_aisle` (`name`; its ingredients go with it), `move_aisle` (`name`, `position`), `add_ingredient` (`aisle`, `names`), `update_ingredient` (`name`, `names`, optional `aisle` to move it), `remove_ingredient` (`name`). An ingredient is found by any of its names, ignoring case. Answers with the aisles as `GET /api/aisles` does. `400` for a name the file cannot hold (empty, or containing `|` or `//`) or one already used — ingredient names ignoring case, as the shopping list matches them; `404` for an aisle or ingredient that does not exist, or when there is no aisle file; `409` for a stale `revision`. + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| `action` | body | `string` | yes | `add_aisle`, `rename_aisle`, `remove_aisle`, `move_aisle`, `add_ingredient`, `update_ingredient` or `remove_ingredient`. | +| `revision` | body | `string` | no | The `revision` the change was made against. | +| `name` | body | `string` | no | The aisle, or any one of the ingredient's names. | +| `names` | body | `string[]` | no | An ingredient's names, the one to show first. | +| `aisle` | body | `string` | no | The aisle to put it in. | +| `new_name` | body | `string` | no | For `rename_aisle`. | +| `position` | body | `integer` | no | Where the aisle goes among the aisles, from 0. | + +Request body: + +```json +{ + "revision": "edbf1fd2579f964e", + "action": "add_ingredient", + "aisle": "fruit and veg", + "names": ["leek", "leeks"] +} +``` + +Response: + +```json +{ + "error": "\"Avocados\" is already in the \"fruit and veg\" aisle" +} +``` + +### `GET /api/aisles/raw` + +Read the aisle file as text + +The file exactly as written, with its `revision`. `404` when there is no aisle file. The `content` below is cut short. + +Response: + +```json +{ + "path": "/path/to/recipes/config/aisle.conf", + "content": "[fruit and veg]\napples\nbananas\ngrapes\n", + "revision": "edbf1fd2579f964e" +} +``` + +### `PUT /api/aisles/raw` + +Replace the aisle file + +Writes `content` as the whole file once the aisle parser accepts it, and answers as `GET /api/aisles` does. Otherwise `400` naming the line, and the file is left alone. + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| `content` | body | `string` | yes | The new file. | +| `revision` | body | `string` | no | The `revision` the text was edited from. | + +Request body: + +```json +{ + "content": "[a]\nx\n[b]\nx\n" +} +``` + +Response: + +```json +{ + "error": "Line 4: Duplicate ingredient: 'x'" +} +``` + +### `GET /api/aisles/uncategorized` + +Ingredients without an aisle + +The ingredients the recipes and menus use that no aisle names — what `cook doctor aisle` reports — with the files using each. `link` is what follows `/recipe/` in that file's page address. Scans the whole collection, so it takes longer than the other reads. The response below is trimmed to two of the seed's eleven. + +Response: + +```json +{ + "total_recipes": 14, + "ingredients": [ + { + "name": "San Marzano tomato sauce", + "recipes": [ + { "path": "Neapolitan Pizza.cook", "link": "Neapolitan Pizza" } + ] + }, + { + "name": "almonds", + "recipes": [ + { "path": "Weekly Plan.menu", "link": "Weekly Plan.menu" } + ] + } + ] +} +``` + ## Search & Stats Collection-wide queries. diff --git a/locales/de-DE/aisles.ftl b/locales/de-DE/aisles.ftl new file mode 100644 index 00000000..84ada40a --- /dev/null +++ b/locales/de-DE/aisles.ftl @@ -0,0 +1,43 @@ +# Gänge +aisles-title = Gänge +aisles-manage = Gänge verwalten +aisles-back-to-list = Einkaufsliste +aisles-intro = Die Einkaufsliste gruppiert Zutaten nach diesen Gängen, in dieser Reihenfolge. Weitere Namen einer Zutat werden mit ihrem ersten Namen zusammengefasst. +aisles-no-config = Noch keine Gang-Datei +aisles-no-config-help = Ohne sie landet jede Zutat der Einkaufsliste unter „other“. +aisles-create = aisle.conf anlegen +aisles-warnings = Einige Zeilen der Gang-Datei wurden übersprungen: +aisles-tab-aisles = Gänge +aisles-tab-uncategorized = Ohne Gang +aisles-tab-text = Text +aisles-empty = Noch keine Gänge. Füge unten einen hinzu. +aisles-empty-aisle = Keine Zutaten in diesem Gang +aisles-aisle-name = Name des Gangs +aisles-add-aisle = Gang hinzufügen +aisles-rename = Gang umbenennen +aisles-move-up = Nach oben +aisles-move-down = Nach unten +aisles-remove-aisle = Gang entfernen +aisles-remove-aisle-confirm = Den Gang „%s“ und seine %n Zutaten entfernen? +aisles-edit = Bearbeiten +aisles-remove = Entfernen +aisles-remove-ingredient-confirm = „%s“ aus den Gängen entfernen? +aisles-save = Speichern +aisles-cancel = Abbrechen +aisles-add = Hinzufügen +aisles-add-ingredient = Zutat hinzufügen +aisles-ingredient-names = Namen, getrennt durch | +aisles-ingredient-placeholder = z. B. Frühlingszwiebel | Lauchzwiebel +aisles-move-to = Gang +aisles-choose-aisle = Gang wählen… +aisles-assign = Gang zuordnen… +aisles-conflict = Die Gang-Datei wurde anderswo geändert; die Seite zeigt jetzt ihre neue Fassung. Bitte versuche es erneut. +aisles-failed = Die Änderung konnte nicht gespeichert werden +aisles-uncategorized-intro = Zutaten deiner Rezepte, die in keinem Gang stehen. Auf der Einkaufsliste erscheinen sie unter „other“. +aisles-uncategorized-loading = Rezepte werden durchsucht… +aisles-uncategorized-none = Jede Zutat deiner Rezepte hat einen Gang. +aisles-used-in = Verwendet in +aisles-text-intro = Bearbeite die Datei direkt: ein Gang pro Zeile [Name], darunter eine Zutat pro Zeile, weitere Namen nach |. Alles nach // ist ein Kommentar. +aisles-text-save = Datei speichern +aisles-text-saved = Gespeichert +aisles-text-conflict = Die Gang-Datei wurde anderswo geändert, während du bearbeitet hast. Dein Text bleibt erhalten; speichere erneut, um jene Änderung damit zu ersetzen. diff --git a/locales/en-US/aisles.ftl b/locales/en-US/aisles.ftl new file mode 100644 index 00000000..a32c6d64 --- /dev/null +++ b/locales/en-US/aisles.ftl @@ -0,0 +1,43 @@ +# Aisles +aisles-title = Aisles +aisles-manage = Manage aisles +aisles-back-to-list = Shopping list +aisles-intro = The shopping list groups ingredients by these aisles, in this order. Other names for an ingredient are merged into its first name. +aisles-no-config = No aisle file yet +aisles-no-config-help = Without one, every ingredient on the shopping list lands under "other". +aisles-create = Create aisle.conf +aisles-warnings = Some lines of the aisle file were skipped: +aisles-tab-aisles = Aisles +aisles-tab-uncategorized = Uncategorized +aisles-tab-text = Text +aisles-empty = No aisles yet. Add one below. +aisles-empty-aisle = No ingredients in this aisle +aisles-aisle-name = Aisle name +aisles-add-aisle = Add aisle +aisles-rename = Rename aisle +aisles-move-up = Move up +aisles-move-down = Move down +aisles-remove-aisle = Remove aisle +aisles-remove-aisle-confirm = Remove the aisle "%s" and its %n ingredients? +aisles-edit = Edit +aisles-remove = Remove +aisles-remove-ingredient-confirm = Remove "%s" from the aisles? +aisles-save = Save +aisles-cancel = Cancel +aisles-add = Add +aisles-add-ingredient = Add ingredient +aisles-ingredient-names = Names, separated by | +aisles-ingredient-placeholder = e.g. spring onion | scallion +aisles-move-to = Aisle +aisles-choose-aisle = Choose an aisle… +aisles-assign = Assign aisle… +aisles-conflict = The aisle file was changed elsewhere, so the page now shows its new version. Please try again. +aisles-failed = Could not save the change +aisles-uncategorized-intro = Ingredients your recipes use that no aisle lists. On the shopping list they appear under "other". +aisles-uncategorized-loading = Scanning recipes… +aisles-uncategorized-none = Every ingredient in your recipes has an aisle. +aisles-used-in = Used in +aisles-text-intro = Edit the file directly: an aisle per [name] line, then one ingredient per line with its other names after |. Anything after // is a comment. +aisles-text-save = Save file +aisles-text-saved = Saved +aisles-text-conflict = The aisle file was changed elsewhere while you were editing. Your text is kept; save again to replace that change with it. diff --git a/locales/es-ES/aisles.ftl b/locales/es-ES/aisles.ftl new file mode 100644 index 00000000..06cc66e8 --- /dev/null +++ b/locales/es-ES/aisles.ftl @@ -0,0 +1,43 @@ +# Pasillos +aisles-title = Pasillos +aisles-manage = Gestionar pasillos +aisles-back-to-list = Lista de la compra +aisles-intro = La lista de la compra agrupa los ingredientes por estos pasillos, en este orden. Los otros nombres de un ingrediente se unen a su primer nombre. +aisles-no-config = Aún no hay archivo de pasillos +aisles-no-config-help = Sin él, todos los ingredientes de la lista de la compra acaban en «other». +aisles-create = Crear aisle.conf +aisles-warnings = Se omitieron algunas líneas del archivo de pasillos: +aisles-tab-aisles = Pasillos +aisles-tab-uncategorized = Sin pasillo +aisles-tab-text = Texto +aisles-empty = Aún no hay pasillos. Añade uno abajo. +aisles-empty-aisle = No hay ingredientes en este pasillo +aisles-aisle-name = Nombre del pasillo +aisles-add-aisle = Añadir pasillo +aisles-rename = Renombrar pasillo +aisles-move-up = Subir +aisles-move-down = Bajar +aisles-remove-aisle = Eliminar pasillo +aisles-remove-aisle-confirm = ¿Eliminar el pasillo «%s» y sus %n ingredientes? +aisles-edit = Editar +aisles-remove = Eliminar +aisles-remove-ingredient-confirm = ¿Quitar «%s» de los pasillos? +aisles-save = Guardar +aisles-cancel = Cancelar +aisles-add = Añadir +aisles-add-ingredient = Añadir ingrediente +aisles-ingredient-names = Nombres, separados por | +aisles-ingredient-placeholder = p. ej. cebolleta | cebolla tierna +aisles-move-to = Pasillo +aisles-choose-aisle = Elige un pasillo… +aisles-assign = Asignar pasillo… +aisles-conflict = El archivo de pasillos se cambió en otro lugar, así que la página muestra ahora su nueva versión. Inténtalo de nuevo. +aisles-failed = No se pudo guardar el cambio +aisles-uncategorized-intro = Ingredientes de tus recetas que ningún pasillo incluye. En la lista de la compra aparecen bajo «other». +aisles-uncategorized-loading = Analizando recetas… +aisles-uncategorized-none = Todos los ingredientes de tus recetas tienen pasillo. +aisles-used-in = Usado en +aisles-text-intro = Edita el archivo directamente: un pasillo por línea [nombre] y debajo un ingrediente por línea, con sus otros nombres tras |. Todo lo que sigue a // es un comentario. +aisles-text-save = Guardar archivo +aisles-text-saved = Guardado +aisles-text-conflict = El archivo de pasillos se cambió en otro lugar mientras editabas. Tu texto se conserva; guarda de nuevo para reemplazar ese cambio con él. diff --git a/locales/eu-ES/aisles.ftl b/locales/eu-ES/aisles.ftl new file mode 100644 index 00000000..4512dd65 --- /dev/null +++ b/locales/eu-ES/aisles.ftl @@ -0,0 +1,43 @@ +# Korridoreak +aisles-title = Korridoreak +aisles-manage = Kudeatu korridoreak +aisles-back-to-list = Erosketa-zerrenda +aisles-intro = Erosketa-zerrendak osagaiak korridore hauen arabera taldekatzen ditu, ordena honetan. Osagai baten beste izenak bere lehen izenarekin batzen dira. +aisles-no-config = Oraindik ez dago korridore-fitxategirik +aisles-no-config-help = Hura gabe, erosketa-zerrendako osagai guztiak "other" atalean geratzen dira. +aisles-create = Sortu aisle.conf +aisles-warnings = Korridore-fitxategiko lerro batzuk saltatu dira: +aisles-tab-aisles = Korridoreak +aisles-tab-uncategorized = Korridorerik gabe +aisles-tab-text = Testua +aisles-empty = Oraindik ez dago korridorerik. Gehitu bat behean. +aisles-empty-aisle = Ez dago osagairik korridore honetan +aisles-aisle-name = Korridorearen izena +aisles-add-aisle = Gehitu korridorea +aisles-rename = Aldatu korridorearen izena +aisles-move-up = Igo +aisles-move-down = Jaitsi +aisles-remove-aisle = Kendu korridorea +aisles-remove-aisle-confirm = "%s" korridorea eta bere %n osagaiak kendu? +aisles-edit = Editatu +aisles-remove = Kendu +aisles-remove-ingredient-confirm = "%s" korridoreetatik kendu? +aisles-save = Gorde +aisles-cancel = Utzi +aisles-add = Gehitu +aisles-add-ingredient = Gehitu osagaia +aisles-ingredient-names = Izenak, | bidez bereizita +aisles-ingredient-placeholder = adib. tipulin | tipula berria +aisles-move-to = Korridorea +aisles-choose-aisle = Aukeratu korridore bat… +aisles-assign = Esleitu korridorea… +aisles-conflict = Korridore-fitxategia beste nonbait aldatu da, eta orriak bertsio berria erakusten du orain. Saiatu berriro. +aisles-failed = Ezin izan da aldaketa gorde +aisles-uncategorized-intro = Zure errezetetako osagaiak, inongo korridoretan ez daudenak. Erosketa-zerrendan "other" atalean agertzen dira. +aisles-uncategorized-loading = Errezetak aztertzen… +aisles-uncategorized-none = Zure errezetetako osagai guztiek dute korridorea. +aisles-used-in = Hemen erabilia: +aisles-text-intro = Editatu fitxategia zuzenean: korridore bat [izena] lerro bakoitzeko, eta azpian osagai bat lerro bakoitzeko, beste izenak | ondoren. // ondorengo guztia iruzkina da. +aisles-text-save = Gorde fitxategia +aisles-text-saved = Gordeta +aisles-text-conflict = Korridore-fitxategia beste nonbait aldatu da editatzen ari zinela. Zure testua mantentzen da; gorde berriro aldaketa hura zurearekin ordezteko. diff --git a/locales/fr-FR/aisles.ftl b/locales/fr-FR/aisles.ftl new file mode 100644 index 00000000..58a3f674 --- /dev/null +++ b/locales/fr-FR/aisles.ftl @@ -0,0 +1,43 @@ +# Rayons +aisles-title = Rayons +aisles-manage = Gérer les rayons +aisles-back-to-list = Liste de courses +aisles-intro = La liste de courses regroupe les ingrédients par rayon, dans cet ordre. Les autres noms d'un ingrédient sont regroupés sous son premier nom. +aisles-no-config = Pas encore de fichier de rayons +aisles-no-config-help = Sans lui, tous les ingrédients de la liste de courses se retrouvent sous « other ». +aisles-create = Créer aisle.conf +aisles-warnings = Certaines lignes du fichier de rayons ont été ignorées : +aisles-tab-aisles = Rayons +aisles-tab-uncategorized = Sans rayon +aisles-tab-text = Texte +aisles-empty = Aucun rayon pour l'instant. Ajoutez-en un ci-dessous. +aisles-empty-aisle = Aucun ingrédient dans ce rayon +aisles-aisle-name = Nom du rayon +aisles-add-aisle = Ajouter un rayon +aisles-rename = Renommer le rayon +aisles-move-up = Monter +aisles-move-down = Descendre +aisles-remove-aisle = Supprimer le rayon +aisles-remove-aisle-confirm = Supprimer le rayon « %s » et ses %n ingrédients ? +aisles-edit = Modifier +aisles-remove = Supprimer +aisles-remove-ingredient-confirm = Retirer « %s » des rayons ? +aisles-save = Enregistrer +aisles-cancel = Annuler +aisles-add = Ajouter +aisles-add-ingredient = Ajouter un ingrédient +aisles-ingredient-names = Noms, séparés par | +aisles-ingredient-placeholder = ex. oignon nouveau | cébette +aisles-move-to = Rayon +aisles-choose-aisle = Choisir un rayon… +aisles-assign = Ranger dans un rayon… +aisles-conflict = Le fichier de rayons a été modifié ailleurs ; la page affiche maintenant sa nouvelle version. Veuillez réessayer. +aisles-failed = Impossible d'enregistrer la modification +aisles-uncategorized-intro = Les ingrédients de vos recettes qu'aucun rayon ne mentionne. Sur la liste de courses, ils apparaissent sous « other ». +aisles-uncategorized-loading = Analyse des recettes… +aisles-uncategorized-none = Tous les ingrédients de vos recettes ont un rayon. +aisles-used-in = Utilisé dans +aisles-text-intro = Modifiez le fichier directement : un rayon par ligne [nom], puis un ingrédient par ligne avec ses autres noms après |. Tout ce qui suit // est un commentaire. +aisles-text-save = Enregistrer le fichier +aisles-text-saved = Enregistré +aisles-text-conflict = Le fichier de rayons a été modifié ailleurs pendant votre saisie. Votre texte est conservé ; enregistrez à nouveau pour remplacer cette modification. diff --git a/locales/it-IT/aisles.ftl b/locales/it-IT/aisles.ftl new file mode 100644 index 00000000..82acb45c --- /dev/null +++ b/locales/it-IT/aisles.ftl @@ -0,0 +1,43 @@ +# Corsie +aisles-title = Corsie +aisles-manage = Gestisci corsie +aisles-back-to-list = Lista della spesa +aisles-intro = La lista della spesa raggruppa gli ingredienti per queste corsie, in quest'ordine. Gli altri nomi di un ingrediente vengono uniti al suo primo nome. +aisles-no-config = Ancora nessun file delle corsie +aisles-no-config-help = Senza di esso, ogni ingrediente della lista della spesa finisce sotto «other». +aisles-create = Crea aisle.conf +aisles-warnings = Alcune righe del file delle corsie sono state ignorate: +aisles-tab-aisles = Corsie +aisles-tab-uncategorized = Senza corsia +aisles-tab-text = Testo +aisles-empty = Ancora nessuna corsia. Aggiungine una qui sotto. +aisles-empty-aisle = Nessun ingrediente in questa corsia +aisles-aisle-name = Nome della corsia +aisles-add-aisle = Aggiungi corsia +aisles-rename = Rinomina corsia +aisles-move-up = Sposta su +aisles-move-down = Sposta giù +aisles-remove-aisle = Rimuovi corsia +aisles-remove-aisle-confirm = Rimuovere la corsia «%s» e i suoi %n ingredienti? +aisles-edit = Modifica +aisles-remove = Rimuovi +aisles-remove-ingredient-confirm = Rimuovere «%s» dalle corsie? +aisles-save = Salva +aisles-cancel = Annulla +aisles-add = Aggiungi +aisles-add-ingredient = Aggiungi ingrediente +aisles-ingredient-names = Nomi, separati da | +aisles-ingredient-placeholder = es. cipollotto | cipolla novella +aisles-move-to = Corsia +aisles-choose-aisle = Scegli una corsia… +aisles-assign = Assegna corsia… +aisles-conflict = Il file delle corsie è stato modificato altrove, quindi la pagina ora ne mostra la nuova versione. Riprova. +aisles-failed = Impossibile salvare la modifica +aisles-uncategorized-intro = Ingredienti delle tue ricette che nessuna corsia elenca. Nella lista della spesa compaiono sotto «other». +aisles-uncategorized-loading = Analisi delle ricette… +aisles-uncategorized-none = Ogni ingrediente delle tue ricette ha una corsia. +aisles-used-in = Usato in +aisles-text-intro = Modifica direttamente il file: una corsia per riga [nome], poi un ingrediente per riga con gli altri nomi dopo |. Tutto ciò che segue // è un commento. +aisles-text-save = Salva file +aisles-text-saved = Salvato +aisles-text-conflict = Il file delle corsie è stato modificato altrove mentre lo modificavi. Il tuo testo è conservato; salva di nuovo per sostituire quella modifica. diff --git a/locales/ja-JP/aisles.ftl b/locales/ja-JP/aisles.ftl new file mode 100644 index 00000000..0eb15738 --- /dev/null +++ b/locales/ja-JP/aisles.ftl @@ -0,0 +1,43 @@ +# 売り場 +aisles-title = 売り場 +aisles-manage = 売り場を管理 +aisles-back-to-list = 買い物リスト +aisles-intro = 買い物リストは材料をこれらの売り場ごとに、この順番でまとめます。材料の別名は最初の名前にまとめられます。 +aisles-no-config = 売り場ファイルはまだありません +aisles-no-config-help = ファイルがないと、買い物リストの材料はすべて「other」に入ります。 +aisles-create = aisle.conf を作成 +aisles-warnings = 売り場ファイルの一部の行は読み飛ばされました: +aisles-tab-aisles = 売り場 +aisles-tab-uncategorized = 未分類 +aisles-tab-text = テキスト +aisles-empty = 売り場はまだありません。下から追加してください。 +aisles-empty-aisle = この売り場に材料はありません +aisles-aisle-name = 売り場の名前 +aisles-add-aisle = 売り場を追加 +aisles-rename = 売り場の名前を変更 +aisles-move-up = 上へ移動 +aisles-move-down = 下へ移動 +aisles-remove-aisle = 売り場を削除 +aisles-remove-aisle-confirm = 売り場「%s」とその材料 %n 件を削除しますか? +aisles-edit = 編集 +aisles-remove = 削除 +aisles-remove-ingredient-confirm = 「%s」を売り場から削除しますか? +aisles-save = 保存 +aisles-cancel = キャンセル +aisles-add = 追加 +aisles-add-ingredient = 材料を追加 +aisles-ingredient-names = 名前(| で区切る) +aisles-ingredient-placeholder = 例: 青ねぎ | 万能ねぎ +aisles-move-to = 売り場 +aisles-choose-aisle = 売り場を選択… +aisles-assign = 売り場に割り当て… +aisles-conflict = 売り場ファイルが別の場所で変更されたため、新しい内容を表示しています。もう一度お試しください。 +aisles-failed = 変更を保存できませんでした +aisles-uncategorized-intro = レシピで使われているのに、どの売り場にも載っていない材料です。買い物リストでは「other」に表示されます。 +aisles-uncategorized-loading = レシピを確認しています… +aisles-uncategorized-none = レシピの材料はすべて売り場に割り当てられています。 +aisles-used-in = 使用レシピ: +aisles-text-intro = ファイルを直接編集します: [名前] の行が売り場、その下に材料を 1 行ずつ書き、別名は | の後に続けます。// 以降はコメントです。 +aisles-text-save = ファイルを保存 +aisles-text-saved = 保存しました +aisles-text-conflict = 編集中に売り場ファイルが別の場所で変更されました。入力した内容は残っています。もう一度保存すると、その変更をこの内容で置き換えます。 diff --git a/locales/nl-NL/aisles.ftl b/locales/nl-NL/aisles.ftl new file mode 100644 index 00000000..2cabe316 --- /dev/null +++ b/locales/nl-NL/aisles.ftl @@ -0,0 +1,43 @@ +# Gangpaden +aisles-title = Gangpaden +aisles-manage = Gangpaden beheren +aisles-back-to-list = Boodschappenlijst +aisles-intro = De boodschappenlijst groepeert ingrediënten per gangpad, in deze volgorde. Andere namen van een ingrediënt worden samengevoegd onder de eerste naam. +aisles-no-config = Nog geen gangpadenbestand +aisles-no-config-help = Zonder dit bestand komt elk ingrediënt op de boodschappenlijst onder "other" terecht. +aisles-create = aisle.conf aanmaken +aisles-warnings = Sommige regels van het gangpadenbestand zijn overgeslagen: +aisles-tab-aisles = Gangpaden +aisles-tab-uncategorized = Zonder gangpad +aisles-tab-text = Tekst +aisles-empty = Nog geen gangpaden. Voeg er hieronder een toe. +aisles-empty-aisle = Geen ingrediënten in dit gangpad +aisles-aisle-name = Naam van het gangpad +aisles-add-aisle = Gangpad toevoegen +aisles-rename = Gangpad hernoemen +aisles-move-up = Omhoog +aisles-move-down = Omlaag +aisles-remove-aisle = Gangpad verwijderen +aisles-remove-aisle-confirm = Het gangpad "%s" en de %n ingrediënten erin verwijderen? +aisles-edit = Bewerken +aisles-remove = Verwijderen +aisles-remove-ingredient-confirm = "%s" uit de gangpaden verwijderen? +aisles-save = Opslaan +aisles-cancel = Annuleren +aisles-add = Toevoegen +aisles-add-ingredient = Ingrediënt toevoegen +aisles-ingredient-names = Namen, gescheiden door | +aisles-ingredient-placeholder = bijv. lente-ui | bosui +aisles-move-to = Gangpad +aisles-choose-aisle = Kies een gangpad… +aisles-assign = Gangpad toewijzen… +aisles-conflict = Het gangpadenbestand is elders gewijzigd, dus de pagina toont nu de nieuwe versie. Probeer het opnieuw. +aisles-failed = De wijziging kon niet worden opgeslagen +aisles-uncategorized-intro = Ingrediënten uit je recepten die in geen enkel gangpad staan. Op de boodschappenlijst verschijnen ze onder "other". +aisles-uncategorized-loading = Recepten worden doorzocht… +aisles-uncategorized-none = Elk ingrediënt in je recepten heeft een gangpad. +aisles-used-in = Gebruikt in +aisles-text-intro = Bewerk het bestand direct: één gangpad per regel [naam], daaronder één ingrediënt per regel met de andere namen na |. Alles na // is commentaar. +aisles-text-save = Bestand opslaan +aisles-text-saved = Opgeslagen +aisles-text-conflict = Het gangpadenbestand is elders gewijzigd terwijl je aan het bewerken was. Je tekst blijft staan; sla opnieuw op om die wijziging ermee te vervangen. diff --git a/locales/sv-SE/aisles.ftl b/locales/sv-SE/aisles.ftl new file mode 100644 index 00000000..bf7d2a64 --- /dev/null +++ b/locales/sv-SE/aisles.ftl @@ -0,0 +1,43 @@ +# Gångar +aisles-title = Gångar +aisles-manage = Hantera gångar +aisles-back-to-list = Inköpslista +aisles-intro = Inköpslistan grupperar ingredienser efter dessa gångar, i den här ordningen. En ingrediens andra namn slås ihop med dess första namn. +aisles-no-config = Ingen gångfil ännu +aisles-no-config-help = Utan den hamnar varje ingrediens på inköpslistan under ”other”. +aisles-create = Skapa aisle.conf +aisles-warnings = Några rader i gångfilen hoppades över: +aisles-tab-aisles = Gångar +aisles-tab-uncategorized = Utan gång +aisles-tab-text = Text +aisles-empty = Inga gångar ännu. Lägg till en nedan. +aisles-empty-aisle = Inga ingredienser i den här gången +aisles-aisle-name = Gångens namn +aisles-add-aisle = Lägg till gång +aisles-rename = Byt namn på gång +aisles-move-up = Flytta upp +aisles-move-down = Flytta ned +aisles-remove-aisle = Ta bort gång +aisles-remove-aisle-confirm = Ta bort gången ”%s” och dess %n ingredienser? +aisles-edit = Redigera +aisles-remove = Ta bort +aisles-remove-ingredient-confirm = Ta bort ”%s” från gångarna? +aisles-save = Spara +aisles-cancel = Avbryt +aisles-add = Lägg till +aisles-add-ingredient = Lägg till ingrediens +aisles-ingredient-names = Namn, åtskilda med | +aisles-ingredient-placeholder = t.ex. salladslök | vårlök +aisles-move-to = Gång +aisles-choose-aisle = Välj en gång… +aisles-assign = Placera i gång… +aisles-conflict = Gångfilen ändrades någon annanstans, så sidan visar nu dess nya version. Försök igen. +aisles-failed = Ändringen kunde inte sparas +aisles-uncategorized-intro = Ingredienser i dina recept som ingen gång tar upp. På inköpslistan visas de under ”other”. +aisles-uncategorized-loading = Söker igenom recepten… +aisles-uncategorized-none = Varje ingrediens i dina recept har en gång. +aisles-used-in = Används i +aisles-text-intro = Redigera filen direkt: en gång per rad [namn], sedan en ingrediens per rad med dess andra namn efter |. Allt efter // är en kommentar. +aisles-text-save = Spara fil +aisles-text-saved = Sparat +aisles-text-conflict = Gångfilen ändrades någon annanstans medan du redigerade. Din text finns kvar; spara igen för att ersätta den ändringen med den. diff --git a/src/server/aisle_file.rs b/src/server/aisle_file.rs new file mode 100644 index 00000000..b3da58e9 --- /dev/null +++ b/src/server/aisle_file.rs @@ -0,0 +1,654 @@ +//! Edits to `aisle.conf` made one line at a time. +//! +//! `cooklang::aisle::write` would rewrite the whole file from the parsed +//! configuration, dropping every comment and the blank lines people use to +//! group a long aisle. These edits touch only the lines they are about, so the +//! rest of the file stays as its owner wrote it. +//! +//! Lines are read the way `cooklang::aisle::parse` reads them: anything after +//! `//` is a comment, `[name]` starts an aisle, and any other line lists one +//! ingredient's names separated by `|`, the first being the name the shopping +//! list shows. + +use std::collections::hash_map::DefaultHasher; +use std::fmt; +use std::hash::{Hash, Hasher}; +use std::ops::Range; + +/// Why an edit was refused. The file is left as it was. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EditError { + /// A name the file could not hold as typed. + InvalidName(String), + /// The name is already used. + Taken(String), + /// Nothing has that name. + NotFound(String), +} + +impl fmt::Display for EditError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + EditError::InvalidName(message) + | EditError::Taken(message) + | EditError::NotFound(message) => f.write_str(message), + } + } +} + +impl std::error::Error for EditError {} + +/// A short fingerprint of the file's text. A page sends back the one it was +/// shown, so an edit made against a file someone else has changed since is +/// refused instead of applied to lines that moved. +/// +/// `DefaultHasher::new` uses fixed keys, so the value is the same for the +/// same text for as long as the server runs, which is all it is compared +/// across. +pub fn revision(text: &str) -> String { + let mut hasher = DefaultHasher::new(); + text.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Kind { + Blank, + Comment, + Aisle(String), + Ingredient(Vec), +} + +/// The line without its comment, and the comment from its `//` on. +fn split_comment(line: &str) -> (&str, Option<&str>) { + match line.find("//") { + Some(at) => (&line[..at], Some(&line[at..])), + None => (line, None), + } +} + +fn kind(line: &str) -> Kind { + let (code, comment) = split_comment(line); + let code = code.trim_ascii(); + if code.starts_with('[') && code.ends_with(']') { + // Not trimmed inside the brackets: neither is the parser. + Kind::Aisle(code[1..code.len() - 1].to_string()) + } else if !code.is_empty() { + Kind::Ingredient( + code.split('|') + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect(), + ) + } else if comment.is_some() { + Kind::Comment + } else { + Kind::Blank + } +} + +/// `line` with `code` in place of what it had before its comment, keeping +/// the indentation and the comment. +fn replace_code(line: &str, code: &str) -> String { + let (old, comment) = split_comment(line); + let indent = &old[..old.len() - old.trim_start().len()]; + match comment { + Some(comment) => { + let gap = &old[old.trim_end().len()..]; + let gap = if gap.is_empty() { " " } else { gap }; + format!("{indent}{code}{gap}{comment}") + } + None => format!("{indent}{code}"), + } +} + +fn clean(name: &str, what: &str) -> Result { + let name = name.trim(); + let problem = if name.is_empty() { + "cannot be empty" + } else if name.contains('|') { + "cannot contain '|'" + } else if name.contains("//") { + "cannot contain '//'" + } else if name.contains(['\n', '\r']) { + "must fit on one line" + } else { + return Ok(name.to_string()); + }; + Err(EditError::InvalidName(format!("{what} {problem}"))) +} + +fn aisle_name(name: &str) -> Result { + clean(name, "An aisle name") +} + +fn ingredient_names(names: &[String]) -> Result, EditError> { + let names = names + .iter() + .map(|name| clean(name, "An ingredient name")) + .collect::, _>>()?; + let (Some(first), Some(last)) = (names.first(), names.last()) else { + return Err(EditError::InvalidName( + "An ingredient needs a name".to_string(), + )); + }; + if first.starts_with('[') && last.ends_with(']') { + return Err(EditError::InvalidName(format!( + "\"{}\" would be read as an aisle", + names.join(" | ") + ))); + } + Ok(names) +} + +/// An `aisle.conf` held as its lines. +#[derive(Debug, Clone)] +pub struct AisleFile { + lines: Vec, + eol: &'static str, +} + +impl AisleFile { + pub fn parse(text: &str) -> Self { + let eol = if text.contains("\r\n") { "\r\n" } else { "\n" }; + Self { + lines: text.lines().map(str::to_string).collect(), + eol, + } + } + + /// The file's text, ending with a line break. + pub fn to_text(&self) -> String { + let mut text = self.lines.join(self.eol); + if !self.lines.is_empty() { + text.push_str(self.eol); + } + text + } + + /// Each aisle's header line and name, in file order. + fn aisles(&self) -> Vec<(usize, String)> { + self.lines + .iter() + .enumerate() + .filter_map(|(at, line)| match kind(line) { + Kind::Aisle(name) => Some((at, name)), + _ => None, + }) + .collect() + } + + fn find_aisle(&self, name: &str) -> Result { + self.aisles() + .into_iter() + .find(|(_, aisle)| aisle == name) + .map(|(at, _)| at) + .ok_or_else(|| EditError::NotFound(format!("There is no aisle called \"{name}\""))) + } + + /// The aisle a line falls under, if any. + fn aisle_of(&self, line: usize) -> Option { + self.lines[..line] + .iter() + .rev() + .find_map(|line| match kind(line) { + Kind::Aisle(name) => Some(name), + _ => None, + }) + } + + fn check_aisle_free(&self, name: &str, except: Option) -> Result<(), EditError> { + let folded = name.to_lowercase(); + match self + .aisles() + .into_iter() + .find(|(at, aisle)| Some(*at) != except && aisle.to_lowercase() == folded) + { + Some((_, aisle)) => Err(EditError::Taken(format!( + "There is already an aisle called \"{aisle}\"" + ))), + None => Ok(()), + } + } + + /// The lines that go with the aisle whose header is at `header`: the + /// comments right above it, the header, and what follows up to the next + /// aisle and the comments right above that one. + fn block(&self, header: usize) -> Range { + let is_comment = |at: usize| kind(&self.lines[at]) == Kind::Comment; + let mut start = header; + while start > 0 && is_comment(start - 1) { + start -= 1; + } + let next = (header + 1..self.lines.len()) + .find(|&at| matches!(kind(&self.lines[at]), Kind::Aisle(_))) + .unwrap_or(self.lines.len()); + let mut end = next; + if next < self.lines.len() { + while end > header + 1 && is_comment(end - 1) { + end -= 1; + } + } + start..end + } + + /// Takes `range` out, returning its lines without the blank ones that + /// ended it. + fn take_block(&mut self, range: Range) -> Vec { + let at_end = range.end == self.lines.len(); + let mut block: Vec = self.lines.drain(range).collect(); + while block.last().is_some_and(|line| kind(line) == Kind::Blank) { + block.pop(); + } + if at_end { + while self + .lines + .last() + .is_some_and(|line| kind(line) == Kind::Blank) + { + self.lines.pop(); + } + } + block + } + + /// Puts `block` before the aisle now at `position`, or after the last one, + /// with a blank line on either side. + fn put_block(&mut self, mut block: Vec, position: Option) { + let before = position.and_then(|position| self.aisles().get(position).map(|a| a.0)); + match before { + Some(header) => { + let at = self.block(header).start; + block.push(String::new()); + if at > 0 && kind(&self.lines[at - 1]) != Kind::Blank { + block.insert(0, String::new()); + } + self.lines.splice(at..at, block); + } + None => { + if self + .lines + .last() + .is_some_and(|line| kind(line) != Kind::Blank) + { + self.lines.push(String::new()); + } + self.lines.extend(block); + } + } + } + + /// Adds an empty aisle at `position` among the aisles, or last. + pub fn add_aisle(&mut self, name: &str, position: Option) -> Result<(), EditError> { + let name = aisle_name(name)?; + self.check_aisle_free(&name, None)?; + self.put_block(vec![format!("[{name}]")], position); + Ok(()) + } + + pub fn rename_aisle(&mut self, name: &str, new_name: &str) -> Result<(), EditError> { + let header = self.find_aisle(name)?; + let new_name = aisle_name(new_name)?; + self.check_aisle_free(&new_name, Some(header))?; + self.lines[header] = replace_code(&self.lines[header], &format!("[{new_name}]")); + Ok(()) + } + + /// Removes an aisle along with the ingredients listed under it. + pub fn remove_aisle(&mut self, name: &str) -> Result<(), EditError> { + let header = self.find_aisle(name)?; + let range = self.block(header); + self.take_block(range); + Ok(()) + } + + /// Moves an aisle so that it becomes the one at `position`, or the last + /// when `position` is past the end. The shopping list follows the file's + /// order. + pub fn move_aisle(&mut self, name: &str, position: usize) -> Result<(), EditError> { + let header = self.find_aisle(name)?; + let aisles = self.aisles(); + let current = aisles.iter().position(|(at, _)| *at == header); + if current == Some(position) + || (current == Some(aisles.len() - 1) && position >= aisles.len()) + { + return Ok(()); + } + let range = self.block(header); + let block = self.take_block(range); + self.put_block(block, Some(position)); + Ok(()) + } + + /// Each ingredient line and the names on it. + fn ingredients(&self) -> impl Iterator)> + '_ { + self.lines + .iter() + .enumerate() + .filter_map(|(at, line)| match kind(line) { + Kind::Ingredient(names) => Some((at, names)), + _ => None, + }) + } + + /// The line naming `name`, compared ignoring case as the shopping list + /// does. + fn find_ingredient(&self, name: &str) -> Result { + let folded = name.trim().to_lowercase(); + self.ingredients() + .find(|(_, names)| names.iter().any(|n| n.to_lowercase() == folded)) + .map(|(at, _)| at) + .ok_or_else(|| EditError::NotFound(format!("\"{}\" is not in any aisle", name.trim()))) + } + + /// Refuses names listed twice among `names`, or already on a line other + /// than `except`. The shopping list looks names up ignoring case, so so + /// does this. + fn check_names_free(&self, names: &[String], except: Option) -> Result<(), EditError> { + for (i, name) in names.iter().enumerate() { + let folded = name.to_lowercase(); + if names[..i] + .iter() + .any(|other| other.to_lowercase() == folded) + { + return Err(EditError::Taken(format!("\"{name}\" is listed twice"))); + } + let taken = self.ingredients().find(|(at, names)| { + Some(*at) != except && names.iter().any(|n| n.to_lowercase() == folded) + }); + if let Some((at, _)) = taken { + return Err(EditError::Taken(match self.aisle_of(at) { + Some(aisle) => format!("\"{name}\" is already in the \"{aisle}\" aisle"), + None => format!("\"{name}\" is already in the file"), + })); + } + } + Ok(()) + } + + /// Puts an ingredient line under `aisle`, after its last ingredient. + fn put_ingredient(&mut self, aisle: &str, line: String) -> Result<(), EditError> { + let header = self.find_aisle(aisle)?; + let range = self.block(header); + let at = (header + 1..range.end) + .rev() + .find(|&at| matches!(kind(&self.lines[at]), Kind::Ingredient(_))) + .map_or(header + 1, |at| at + 1); + self.lines.insert(at, line); + Ok(()) + } + + /// Lists an ingredient under `aisle`; the first of `names` is the one the + /// shopping list shows, the others are its synonyms. + pub fn add_ingredient(&mut self, aisle: &str, names: &[String]) -> Result<(), EditError> { + let names = ingredient_names(names)?; + self.find_aisle(aisle)?; + self.check_names_free(&names, None)?; + self.put_ingredient(aisle, names.join(" | ")) + } + + /// Gives the ingredient called `name` (by any of its names) the names + /// `names`, and moves it to `aisle` when that is another aisle. + pub fn update_ingredient( + &mut self, + name: &str, + names: &[String], + aisle: Option<&str>, + ) -> Result<(), EditError> { + let at = self.find_ingredient(name)?; + let names = ingredient_names(names)?; + self.check_names_free(&names, Some(at))?; + let line = replace_code(&self.lines[at], &names.join(" | ")); + match aisle { + Some(aisle) if Some(aisle) != self.aisle_of(at).as_deref() => { + self.find_aisle(aisle)?; + self.lines.remove(at); + self.put_ingredient(aisle, line.trim_start().to_string()) + } + _ => { + self.lines[at] = line; + Ok(()) + } + } + } + + pub fn remove_ingredient(&mut self, name: &str) -> Result<(), EditError> { + let at = self.find_ingredient(name)?; + self.lines.remove(at); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "\ +// Store layout, front to back + +[fruit and veg] +apples +avocado | avocados // ripe ones + +onion | onions + +// cold stuff +[dairy] +milk +butter + +[bakery] +bread +"; + + fn names(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() + } + + fn edited(edit: impl FnOnce(&mut AisleFile) -> Result<(), EditError>) -> String { + let mut file = AisleFile::parse(SAMPLE); + edit(&mut file).unwrap(); + let text = file.to_text(); + cooklang::aisle::parse(&text).expect("the edit left a file the parser rejects"); + text + } + + fn aisle_order(text: &str) -> Vec { + cooklang::aisle::parse(text) + .unwrap() + .categories + .iter() + .map(|c| c.name.to_string()) + .collect() + } + + #[test] + fn an_untouched_file_comes_back_as_it_was() { + assert_eq!(AisleFile::parse(SAMPLE).to_text(), SAMPLE); + } + + #[test] + fn windows_line_endings_are_kept() { + let text = SAMPLE.replace('\n', "\r\n"); + let mut file = AisleFile::parse(&text); + file.add_ingredient("dairy", &names(&["eggs"])).unwrap(); + let out = file.to_text(); + assert!(out.contains("butter\r\neggs\r\n")); + assert!(!out.replace("\r\n", "").contains('\n')); + } + + #[test] + fn an_ingredient_goes_after_the_last_one_of_its_aisle() { + let text = edited(|f| f.add_ingredient("fruit and veg", &names(&[" leek ", "leeks"]))); + assert_eq!( + text, + SAMPLE.replace("onion | onions\n", "onion | onions\nleek | leeks\n") + ); + } + + #[test] + fn an_ingredient_can_go_into_an_empty_aisle() { + let mut file = AisleFile::parse("[empty]\n\n[other]\nx\n"); + file.add_ingredient("empty", &names(&["y"])).unwrap(); + assert_eq!(file.to_text(), "[empty]\ny\n\n[other]\nx\n"); + } + + #[test] + fn names_already_listed_are_refused_ignoring_case() { + let mut file = AisleFile::parse(SAMPLE); + assert_eq!( + file.add_ingredient("dairy", &names(&["Avocados"])), + Err(EditError::Taken( + "\"Avocados\" is already in the \"fruit and veg\" aisle".to_string() + )) + ); + assert!(matches!( + file.add_ingredient("dairy", &names(&["cream", "Cream"])), + Err(EditError::Taken(_)) + )); + assert_eq!(file.to_text(), SAMPLE); + } + + #[test] + fn names_the_file_could_not_hold_are_refused() { + let mut file = AisleFile::parse(SAMPLE); + for bad in [ + names(&[]), + names(&[" "]), + names(&["a|b"]), + names(&["a // b"]), + names(&["a\nb"]), + names(&["[x]"]), + names(&["[x", "y]"]), + ] { + assert!( + matches!( + file.add_ingredient("dairy", &bad), + Err(EditError::InvalidName(_)) + ), + "{bad:?} was accepted" + ); + } + assert!(matches!( + file.add_aisle("a|b", None), + Err(EditError::InvalidName(_)) + )); + assert_eq!(file.to_text(), SAMPLE); + } + + #[test] + fn renaming_an_ingredient_keeps_its_comment() { + let text = edited(|f| { + f.update_ingredient("AVOCADOS", &names(&["avocado", "avocados", "hass"]), None) + }); + assert_eq!( + text, + SAMPLE.replace( + "avocado | avocados // ripe ones", + "avocado | avocados | hass // ripe ones" + ) + ); + } + + #[test] + fn an_ingredient_keeps_its_own_names_when_edited() { + // Its current names are not "taken" by itself. + edited(|f| { + f.update_ingredient("onion", &names(&["Onion", "onions"]), Some("fruit and veg")) + }); + } + + #[test] + fn moving_an_ingredient_takes_its_comment_along() { + let text = edited(|f| f.update_ingredient("avocado", &names(&["avocado"]), Some("bakery"))); + assert!(!text.contains("[fruit and veg]\napples\navocado")); + assert!(text.ends_with("[bakery]\nbread\navocado // ripe ones\n")); + } + + #[test] + fn removing_an_ingredient_removes_only_its_line() { + let text = edited(|f| f.remove_ingredient("butter")); + assert_eq!(text, SAMPLE.replace("butter\n", "")); + let mut file = AisleFile::parse(SAMPLE); + assert!(matches!( + file.remove_ingredient("caviar"), + Err(EditError::NotFound(_)) + )); + } + + #[test] + fn a_new_aisle_goes_last_or_where_asked() { + let text = edited(|f| f.add_aisle("frozen", None)); + assert!(text.ends_with("bread\n\n[frozen]\n")); + + let text = edited(|f| f.add_aisle("frozen", Some(1))); + assert_eq!( + aisle_order(&text), + ["fruit and veg", "frozen", "dairy", "bakery"] + ); + // Before the comment that belongs to "dairy", not between the two. + assert!(text.contains("onion | onions\n\n[frozen]\n\n// cold stuff\n[dairy]")); + } + + #[test] + fn aisle_names_are_unique_ignoring_case() { + let mut file = AisleFile::parse(SAMPLE); + assert!(matches!( + file.add_aisle("Dairy", None), + Err(EditError::Taken(_)) + )); + assert!(matches!( + file.rename_aisle("bakery", "DAIRY"), + Err(EditError::Taken(_)) + )); + // A change of case of the aisle itself is fine. + file.rename_aisle("dairy", "Dairy").unwrap(); + assert!(file.to_text().contains("// cold stuff\n[Dairy]\nmilk")); + } + + #[test] + fn removing_an_aisle_takes_its_comment_and_ingredients() { + let text = edited(|f| f.remove_aisle("dairy")); + assert_eq!( + text, + SAMPLE.replace("// cold stuff\n[dairy]\nmilk\nbutter\n\n", "") + ); + let text = edited(|f| f.remove_aisle("bakery")); + assert!(text.ends_with("milk\nbutter\n")); + } + + #[test] + fn moving_an_aisle_carries_its_block() { + let text = edited(|f| f.move_aisle("dairy", 0)); + assert_eq!(aisle_order(&text), ["dairy", "fruit and veg", "bakery"]); + // The file's own heading stays at the top, the aisle's comment moves. + assert!(text.starts_with( + "// Store layout, front to back\n\n// cold stuff\n[dairy]\nmilk\nbutter\n\n[fruit and veg]\n" + )); + assert!(text.contains("onion | onions\n\n[bakery]\nbread\n")); + + let text = edited(|f| f.move_aisle("fruit and veg", 9)); + assert_eq!(aisle_order(&text), ["dairy", "bakery", "fruit and veg"]); + assert!(text.ends_with( + "bread\n\n[fruit and veg]\napples\navocado | avocados // ripe ones\n\nonion | onions\n" + )); + + assert_eq!(edited(|f| f.move_aisle("dairy", 1)), SAMPLE); + assert_eq!(edited(|f| f.move_aisle("bakery", 7)), SAMPLE); + } + + #[test] + fn an_empty_file_gets_its_first_aisle() { + let mut file = AisleFile::parse(""); + file.add_aisle("pantry", None).unwrap(); + file.add_ingredient("pantry", &names(&["rice"])).unwrap(); + assert_eq!(file.to_text(), "[pantry]\nrice\n"); + } + + #[test] + fn the_revision_follows_the_text() { + assert_eq!(revision(SAMPLE), revision(SAMPLE)); + assert_ne!(revision(SAMPLE), revision(&SAMPLE.replace("milk", "Milk"))); + } +} diff --git a/src/server/auth/middleware.rs b/src/server/auth/middleware.rs index 8ebd8e02..b8331557 100644 --- a/src/server/auth/middleware.rs +++ b/src/server/auth/middleware.rs @@ -41,6 +41,7 @@ const PROTECTED_READS: &[(&str, Capability)] = &[ const WRITES: &[(&str, Capability)] = &[ ("/api/shopping_list", Capability::EditLists), ("/api/pantry", Capability::EditLists), + ("/api/aisles", Capability::EditLists), ("/api/recipes", Capability::EditRecipes), ("/api/recipe_image", Capability::EditRecipes), ("/api/recipe_rename", Capability::EditRecipes), @@ -265,6 +266,9 @@ mod tests { (Method::POST, "/api/pantry/add"), (Method::PUT, "/api/pantry/dairy/milk"), (Method::DELETE, "/api/pantry/dairy/milk"), + (Method::POST, "/api/aisles"), + (Method::POST, "/api/aisles/changes"), + (Method::PUT, "/api/aisles/raw"), ] { assert_eq!( needs(method.clone(), path), @@ -304,6 +308,7 @@ mod tests { (Method::POST, "/api/recipes-bulk"), (Method::POST, "/api/recipe_renamex/Soup.cook"), (Method::POST, "/api/pantryx"), + (Method::POST, "/api/aislesx"), (Method::POST, "/edit/Soup.cook"), (Method::POST, "/loginx"), ] { @@ -343,12 +348,16 @@ mod tests { "/directory/Mains", "/shopping-list", "/pantry", + "/aisles", "/preferences", "/login", "/api/recipes", "/api/recipes/Soup.cook", "/api/shopping_list/items", "/api/pantry", + "/api/aisles", + "/api/aisles/raw", + "/api/aisles/uncategorized", "/api/static/Soup.jpg", // Look-alikes of protected paths. "/newsletter", diff --git a/src/server/handlers/aisles.rs b/src/server/handlers/aisles.rs new file mode 100644 index 00000000..3430cd8e --- /dev/null +++ b/src/server/handlers/aisles.rs @@ -0,0 +1,534 @@ +//! The aisle configuration, read and changed from the web UI. +//! +//! Changes go through [`AisleFile`], which edits the lines concerned and +//! leaves the rest of `aisle.conf` — comments, blank lines — as it was. Every +//! change carries the revision of the file it was made against, so one made +//! from a page someone else's edit has outdated is refused rather than applied +//! to whatever now sits at those lines. + +use super::common::{json_error, ApiError}; +use axum::{ + extract::{Extension, Json, State}, + http::StatusCode, +}; +use camino::Utf8PathBuf; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::server::aisle_file::{revision, AisleFile, EditError}; +use crate::server::{activity, fs_atomic, AppState}; +use crate::web::viewer::Viewer; + +/// The aisles as the shopping list sees them. +#[derive(Debug, Serialize)] +pub struct Aisles { + /// Whether there is an aisle file at all. Without one, everything lands + /// in the shopping list's "other" group. + pub configured: bool, + pub path: Option, + /// Hands back with a change; see the module note. + pub revision: Option, + pub aisles: Vec, + /// What the parser had to skip: a duplicate name, an ingredient above + /// the first aisle. + pub warnings: Vec, +} + +#[derive(Debug, Serialize)] +pub struct Aisle { + pub name: String, + pub ingredients: Vec, +} + +#[derive(Debug, Serialize)] +pub struct Ingredient { + /// The first is the name the shopping list shows; the others are the + /// spellings it is merged with. + pub names: Vec, +} + +impl Aisles { + fn unconfigured() -> Self { + Self { + configured: false, + path: None, + revision: None, + aisles: Vec::new(), + warnings: Vec::new(), + } + } + + fn from_text(path: &Utf8PathBuf, text: &str) -> Self { + let parsed = cooklang::aisle::parse_lenient(text); + let aisles = parsed + .output() + .map(|conf| { + conf.categories + .iter() + .map(|category| Aisle { + name: category.name.to_string(), + ingredients: category + .ingredients + .iter() + .map(|ingredient| Ingredient { + names: ingredient + .names + .iter() + .filter(|name| !name.is_empty()) + .map(|name| name.to_string()) + .collect(), + }) + .filter(|ingredient| !ingredient.names.is_empty()) + .collect(), + }) + .collect() + }) + .unwrap_or_default(); + Self { + configured: true, + path: Some(path.to_string()), + revision: Some(revision(text)), + aisles, + warnings: parsed + .report() + .warnings() + .map(|warning| warning.to_string()) + .collect(), + } + } +} + +/// The aisle file and its text, or `None` when there is none. +async fn read(state: &AppState) -> Result, ApiError> { + let Some(path) = state.aisle_file() else { + return Ok(None); + }; + match tokio::fs::read_to_string(&path).await { + Ok(text) => Ok(Some((path, text))), + Err(e) => { + tracing::error!("Failed to read aisle file {path}: {e}"); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + json_error(format!("Failed to read the aisle file: {e}")), + )) + } + } +} + +async fn read_existing(state: &AppState) -> Result<(Utf8PathBuf, String), ApiError> { + read(state).await?.ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + json_error("There is no aisle file yet; create one first"), + ) + }) +} + +async fn write(path: &Utf8PathBuf, text: String) -> Result<(), ApiError> { + fs_atomic::write_replace_async(path.clone(), text.into_bytes()) + .await + .map_err(|e| { + tracing::error!("Failed to write aisle file {path}: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json_error(format!("Failed to write the aisle file: {e}")), + ) + }) +} + +/// Refuses a change made against another version of the file. A change sent +/// without a revision is applied to whatever is there. +fn check_revision(text: &str, sent: Option<&str>) -> Result<(), ApiError> { + let current = revision(text); + match sent { + Some(sent) if sent != current => Err(( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": "The aisle file changed since this page loaded it", + "revision": current, + })), + )), + _ => Ok(()), + } +} + +fn edit_error(error: EditError) -> ApiError { + let status = match error { + EditError::NotFound(_) => StatusCode::NOT_FOUND, + EditError::InvalidName(_) | EditError::Taken(_) => StatusCode::BAD_REQUEST, + }; + (status, json_error(error)) +} + +/// The aisle parser's complaint about `text`, with the line it is on. +fn parse_problem(text: &str) -> Option { + use cooklang::aisle::AisleConfError; + let error = cooklang::aisle::parse(text).err()?; + let at = match &error { + AisleConfError::Parse { span, .. } => span.start(), + AisleConfError::DuplicateCategory { second_span, .. } + | AisleConfError::DuplicateIngredient { second_span, .. } => second_span.start(), + }; + let line = text[..at.min(text.len())].matches('\n').count() + 1; + Some(format!("Line {line}: {error}")) +} + +/// The aisles, for the API and for the aisles page alike. +pub async fn load(state: &AppState) -> Result { + Ok(match read(state).await? { + Some((path, text)) => Aisles::from_text(&path, &text), + None => Aisles::unconfigured(), + }) +} + +/// `GET /api/aisles` +pub async fn get_aisles(State(state): State>) -> Result, ApiError> { + Ok(Json(load(&state).await?)) +} + +/// `POST /api/aisles`: starts an empty `config/aisle.conf` in the recipe +/// directory, where none was found. +pub async fn create_aisles( + State(state): State>, + Extension(viewer): Extension, +) -> Result<(StatusCode, Json), ApiError> { + let _guard = state.aisle_lock.lock().await; + if let Some(path) = state.aisle_file() { + return Err(( + StatusCode::CONFLICT, + json_error(format!("There is already an aisle file at {path}")), + )); + } + + let path = state.local_aisle_file(); + let created = async { + if let Some(dir) = path.parent() { + tokio::fs::create_dir_all(dir).await?; + } + tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .await + } + .await; + if let Err(e) = created { + tracing::error!("Failed to create aisle file {path}: {e}"); + let status = if e.kind() == std::io::ErrorKind::AlreadyExists { + StatusCode::CONFLICT + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + return Err(( + status, + json_error(format!("Failed to create the aisle file: {e}")), + )); + } + + activity::record( + &viewer, + format_args!( + "created the aisle file {}", + activity::file(&state.base_path, &path) + ), + ); + Ok((StatusCode::CREATED, Json(Aisles::from_text(&path, "")))) +} + +/// One change to the aisles. `name` is an aisle's name, or any one of an +/// ingredient's names, matched ignoring case. +#[derive(Debug, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum Change { + /// An empty aisle, at `position` among the aisles or last. + AddAisle { + name: String, + position: Option, + }, + RenameAisle { + name: String, + new_name: String, + }, + /// Removes the aisle and the ingredients listed under it. + RemoveAisle { + name: String, + }, + /// Makes the aisle the one at `position`; the shopping list follows + /// this order. + MoveAisle { + name: String, + position: usize, + }, + /// Lists an ingredient under `aisle`: the first of `names` is shown on + /// the shopping list, the others are merged into it. + AddIngredient { + aisle: String, + names: Vec, + }, + /// Replaces the ingredient's names, and moves it when `aisle` is given. + UpdateIngredient { + name: String, + names: Vec, + aisle: Option, + }, + RemoveIngredient { + name: String, + }, +} + +impl Change { + fn apply(&self, file: &mut AisleFile) -> Result<(), EditError> { + match self { + Change::AddAisle { name, position } => file.add_aisle(name, *position), + Change::RenameAisle { name, new_name } => file.rename_aisle(name, new_name), + Change::RemoveAisle { name } => file.remove_aisle(name), + Change::MoveAisle { name, position } => file.move_aisle(name, *position), + Change::AddIngredient { aisle, names } => file.add_ingredient(aisle, names), + Change::UpdateIngredient { name, names, aisle } => { + file.update_ingredient(name, names, aisle.as_deref()) + } + Change::RemoveIngredient { name } => file.remove_ingredient(name), + } + } + + fn record(&self, viewer: &Viewer) { + use activity::quoted; + match self { + Change::AddAisle { name, .. } => { + activity::record(viewer, format_args!("added the aisle {}", quoted(name))) + } + Change::RenameAisle { name, new_name } => activity::record( + viewer, + format_args!("renamed the aisle {} to {}", quoted(name), quoted(new_name)), + ), + Change::RemoveAisle { name } => { + activity::record(viewer, format_args!("removed the aisle {}", quoted(name))) + } + Change::MoveAisle { name, position } => activity::record( + viewer, + format_args!("moved the aisle {} to place {}", quoted(name), position + 1), + ), + Change::AddIngredient { aisle, names } => activity::record( + viewer, + format_args!( + "added {} to the aisle {}", + quoted(&names.join(" | ")), + quoted(aisle) + ), + ), + Change::UpdateIngredient { + name, + names, + aisle: Some(aisle), + } => activity::record( + viewer, + format_args!( + "changed the aisle entry {} to {} in {}", + quoted(name), + quoted(&names.join(" | ")), + quoted(aisle) + ), + ), + Change::UpdateIngredient { name, names, .. } => activity::record( + viewer, + format_args!( + "changed the aisle entry {} to {}", + quoted(name), + quoted(&names.join(" | ")) + ), + ), + Change::RemoveIngredient { name } => activity::record( + viewer, + format_args!("removed {} from the aisles", quoted(name)), + ), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct ChangeRequest { + pub revision: Option, + #[serde(flatten)] + pub change: Change, +} + +/// `POST /api/aisles/changes` +pub async fn change_aisles( + State(state): State>, + Extension(viewer): Extension, + Json(request): Json, +) -> Result, ApiError> { + let _guard = state.aisle_lock.lock().await; + let (path, text) = read_existing(&state).await?; + check_revision(&text, request.revision.as_deref())?; + + let mut file = AisleFile::parse(&text); + request.change.apply(&mut file).map_err(edit_error)?; + let changed = file.to_text(); + + // The edits are written to keep the file readable; this is the backstop. + // A file that was already broken is left to the text editor to mend. + if parse_problem(&text).is_none() { + if let Some(problem) = parse_problem(&changed) { + tracing::error!("Refused an aisle edit that would break the file: {problem}"); + return Err(( + StatusCode::BAD_REQUEST, + json_error(format!("That change would break the aisle file: {problem}")), + )); + } + } + + write(&path, changed.clone()).await?; + request.change.record(&viewer); + Ok(Json(Aisles::from_text(&path, &changed))) +} + +#[derive(Debug, Serialize)] +pub struct RawAisles { + pub path: String, + pub content: String, + pub revision: String, +} + +/// `GET /api/aisles/raw`: the file as written. +pub async fn get_raw_aisles( + State(state): State>, +) -> Result, ApiError> { + let (path, text) = read_existing(&state).await?; + Ok(Json(RawAisles { + path: path.to_string(), + revision: revision(&text), + content: text, + })) +} + +#[derive(Debug, Deserialize)] +pub struct RawAislesUpdate { + pub content: String, + pub revision: Option, +} + +/// `PUT /api/aisles/raw`: replaces the whole file, once the aisle parser +/// accepts the new text. +pub async fn put_raw_aisles( + State(state): State>, + Extension(viewer): Extension, + Json(update): Json, +) -> Result, ApiError> { + let _guard = state.aisle_lock.lock().await; + let (path, text) = read_existing(&state).await?; + check_revision(&text, update.revision.as_deref())?; + + if let Some(problem) = parse_problem(&update.content) { + return Err((StatusCode::BAD_REQUEST, json_error(problem))); + } + + write(&path, update.content.clone()).await?; + activity::record( + &viewer, + format_args!( + "rewrote the aisle file {}", + activity::file(&state.base_path, &path) + ), + ); + Ok(Json(Aisles::from_text(&path, &update.content))) +} + +#[derive(Debug, Serialize)] +pub struct Uncategorized { + pub total_recipes: usize, + pub ingredients: Vec, +} + +#[derive(Debug, Serialize)] +pub struct UncategorizedIngredient { + pub name: String, + pub recipes: Vec, +} + +#[derive(Debug, Serialize)] +pub struct RecipeLink { + /// Relative to the recipe directory, as the file is named. + pub path: String, + /// What follows `/recipe/` in the recipe page's address. + pub link: String, +} + +/// `GET /api/aisles/uncategorized`: the ingredients the recipes use that no +/// aisle lists — what `cook doctor aisle` reports. On the shopping list they +/// end up under "other". +pub async fn get_uncategorized( + State(state): State>, +) -> Result, ApiError> { + let base = state.base_path.clone(); + let aisle = match state.aisle_file() { + Some(path) => cookcli_core::source::ConfigSource::Path(path), + None => cookcli_core::source::ConfigSource::None, + }; + + let outcome = tokio::task::spawn_blocking(move || { + let ctx = cookcli_core::Context::discover(base).with_aisle(aisle); + cookcli_core::doctor::aisle_coverage(&ctx, Default::default()) + }) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + json_error(format!("Failed to scan the recipes: {e}")), + ) + })? + .map_err(|e| { + tracing::error!("Failed to check the recipes against the aisles: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json_error(format!("Failed to scan the recipes: {e}")), + ) + })?; + + let coverage = outcome.value; + Ok(Json(Uncategorized { + total_recipes: coverage.total_recipes, + ingredients: coverage + .unknown_entries() + .map(|ingredient| UncategorizedIngredient { + name: ingredient.name.clone(), + recipes: ingredient + .recipes + .iter() + .map(|path| RecipeLink { + path: path.to_string(), + link: path + .as_str() + .strip_suffix(".cook") + .unwrap_or(path.as_str()) + .to_string(), + }) + .collect(), + }) + .collect(), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_parse_problem_names_its_line() { + assert_eq!(parse_problem("[a]\nx\n"), None); + let problem = parse_problem("[a]\nx\n\n[b]\ny\nx\n").unwrap(); + assert!(problem.starts_with("Line 6:"), "{problem}"); + let problem = parse_problem("[a]\n[b]\n[a]\n").unwrap(); + assert!(problem.starts_with("Line 3:"), "{problem}"); + } + + #[test] + fn a_change_reads_as_one_flat_object() { + let request: ChangeRequest = serde_json::from_str( + r#"{"revision": "abc", "action": "add_ingredient", "aisle": "dairy", "names": ["eggs"]}"#, + ) + .unwrap(); + assert_eq!(request.revision.as_deref(), Some("abc")); + assert!(matches!(request.change, Change::AddIngredient { .. })); + } +} diff --git a/src/server/handlers/mod.rs b/src/server/handlers/mod.rs index 084e9079..f81af297 100644 --- a/src/server/handlers/mod.rs +++ b/src/server/handlers/mod.rs @@ -1,3 +1,4 @@ +pub mod aisles; pub(super) mod common; pub mod menus; pub mod pantry; @@ -11,6 +12,9 @@ pub mod stats; #[cfg(feature = "sync")] pub mod sync; +pub use aisles::{ + change_aisles, create_aisles, get_aisles, get_raw_aisles, get_uncategorized, put_raw_aisles, +}; pub use menus::{get_menu, list_menus}; pub use pantry::{ add_item as add_pantry_item, get_depleted, get_expiring, get_pantry, diff --git a/src/server/handlers/shopping_list.rs b/src/server/handlers/shopping_list.rs index 5f120a03..f6ac4215 100644 --- a/src/server/handlers/shopping_list.rs +++ b/src/server/handlers/shopping_list.rs @@ -74,7 +74,7 @@ pub async fn shopping_list( let mut list = merged(ingredients); // Load aisle configuration with lenient parsing - let aisle_content = if let Some(path) = &state.aisle_path { + let aisle_content = if let Some(path) = &state.aisle_file() { match std::fs::read_to_string(path) { Ok(content) => { tracing::debug!("Loaded aisle file from: {:?}", path); diff --git a/src/server/mod.rs b/src/server/mod.rs index 06b581ac..17b89671 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -47,6 +47,7 @@ use tower_http::{services::ServeDir, set_header::SetResponseHeader}; use tracing::{error, info}; mod activity; +mod aisle_file; pub mod auth; mod cors; mod fs_atomic; @@ -547,6 +548,7 @@ fn build_state( recipes_only, lsp_sessions: lsp_bridge::SessionLimit::new(args.max_lsp_sessions), checked_log_lock: Arc::new(tokio::sync::Mutex::new(())), + aisle_lock: tokio::sync::Mutex::new(()), shopping_list_events, #[cfg(feature = "sync")] sync_session: Arc::new(Mutex::new(session)), @@ -617,6 +619,9 @@ pub struct AppState { /// so we need an in-process mutex on top. All check / uncheck / compact /// handlers acquire this before touching the checked log. pub checked_log_lock: Arc>, + /// Serializes changes to the aisle file, each a read, an edit and a + /// write that must not interleave with another. + pub aisle_lock: tokio::sync::Mutex<()>, /// Broadcasts filesystem changes to `.shopping-list` / `.shopping-checked` /// to every open SSE subscriber. `None` means watcher init failed; SSE /// clients can still connect but will never receive events. @@ -641,6 +646,22 @@ pub struct AppState { #[cfg(feature = "sync")] impl AppState { + /// The aisle file the shopping list uses: the one found at startup, or + /// else `config/aisle.conf` in the recipe directory once it exists, so a + /// file created from the aisles page, or by hand, counts without a + /// restart. + pub fn aisle_file(&self) -> Option { + self.aisle_path.clone().or_else(|| { + let local = self.local_aisle_file(); + local.is_file().then_some(local) + }) + } + + /// Where the aisles page creates an aisle file when there is none. + pub fn local_aisle_file(&self) -> Utf8PathBuf { + self.base_path.join("config").join("aisle.conf") + } + /// Check sync status: returns (logged_in, email, syncing, reason). /// `reason` explains why sync is off (e.g. `"payment_required"`), and is /// `None` while syncing or when the last stop wasn't a known condition. @@ -716,6 +737,16 @@ fn api(_state: &AppState) -> Result>> { "/pantry/{section}/{name}", axum::routing::put(handlers::update_pantry_item), ) + .route( + "/aisles", + get(handlers::get_aisles).post(handlers::create_aisles), + ) + .route("/aisles/changes", post(handlers::change_aisles)) + .route( + "/aisles/raw", + get(handlers::get_raw_aisles).put(handlers::put_raw_aisles), + ) + .route("/aisles/uncategorized", get(handlers::get_uncategorized)) .route("/recipes", get(handlers::all_recipes)) .route("/recipes/raw/{*path}", get(handlers::recipe_raw)) // More specific route must come first .route( diff --git a/src/server/ui.rs b/src/server/ui.rs index 5f43bb23..aac92aea 100644 --- a/src/server/ui.rs +++ b/src/server/ui.rs @@ -45,6 +45,7 @@ pub fn ui() -> Router> { .route("/new", get(new_page).post(create_recipe)) .route("/shopping-list", get(shopping_list_page)) .route("/pantry", get(pantry_page)) + .route("/aisles", get(aisles_page)) .route("/preferences", get(preferences_page)) .route("/api-docs", get(api_docs_page)) .route("/atom.xml", get(atom_feed)) @@ -194,11 +195,12 @@ async fn recipe_page( ) -> axum::response::Response { let scale = query.scale.unwrap_or(1.0); + let aisle_file = state.aisle_file(); let input = crate::web::builders::RecipeBuildInput { base_path: &state.base_path, url_prefix: &state.url_prefix, recipe_path: &path, - aisle_path: state.aisle_path.as_ref(), + aisle_path: aisle_file.as_ref(), scale, servings: query.servings, lang: lang.clone(), @@ -889,6 +891,40 @@ async fn pantry_page( }) } +async fn aisles_page( + State(state): State>, + Extension(lang): Extension, + Extension(features): Extension, + Extension(viewer): Extension, +) -> axum::response::Response { + let aisles = match crate::server::handlers::aisles::load(&state).await { + Ok(aisles) => aisles, + Err((status, _)) => { + let mut response = error_page( + lang, + &state.url_prefix, + "Failed to read the aisle file", + features, + viewer, + ); + *response.status_mut() = status; + return response; + } + }; + + AislesTemplate { + active: "shopping".to_string(), + aisles: serde_json::to_value(aisles).unwrap_or_default(), + tr: Tr::new(lang), + prefix: state.url_prefix.clone(), + static_mode: false, + repo_url: None, + features, + viewer, + } + .into_response() +} + async fn preferences_page( State(state): State>, Extension(lang): Extension, @@ -918,7 +954,7 @@ async fn preferences_page( PreferencesTemplate { active: "preferences".to_string(), - aisle_path: path(state.aisle_path.as_ref()), + aisle_path: path(state.aisle_file().as_ref()), pantry_path: path(state.pantry_path.as_ref()), base_path: path(Some(&state.base_path)), version: format!("{} - in food we trust", env!("CARGO_PKG_VERSION")), diff --git a/src/web/api_docs.rs b/src/web/api_docs.rs index 0c3deec9..d541502b 100644 --- a/src/web/api_docs.rs +++ b/src/web/api_docs.rs @@ -219,6 +219,7 @@ pub fn sections() -> Vec { menus(), shopping_list(), pantry(), + aisles(), search_and_stats(), realtime(), sync(), @@ -1351,6 +1352,220 @@ fn shopping_list() -> ApiSection { ) } +fn aisles() -> ApiSection { + section( + "aisles", + "Aisles", + "Reads and changes `aisle.conf`, which groups the shopping list by store aisle: an \ + `[aisle]` line, then one ingredient per line with its other names after `|`. \ + Changes edit only the lines concerned, so comments and blank lines elsewhere in the \ + file are kept. Each response carries the file's `revision`; send it back with a \ + change and the change is refused with `409` if the file has been changed since, \ + along with the current `revision`. Leave it out to apply the change regardless. \ + Changes need the `shopper` role when sign-in is on.", + vec![ + ep( + "GET", + "/api/aisles", + "Read the aisles", + "Aisles in file order, which is the order the shopping list uses. Each \ + ingredient's first name is the one the shopping list shows; the others are \ + merged into it. `warnings` lists the lines the parser skipped, such as a \ + name listed twice. Without an aisle file, `configured` is `false`, \ + `path` and `revision` are `null` and `aisles` is empty. The response below \ + is trimmed to two of the seed's eight aisles and a few ingredients each.", + ) + .response( + r#" +{ + "configured": true, + "path": "/path/to/recipes/config/aisle.conf", + "revision": "edbf1fd2579f964e", + "aisles": [ + { + "name": "fruit and veg", + "ingredients": [ + { "names": ["apples"] }, + { "names": ["avocado", "avocados"] } + ] + }, + { + "name": "milk and dairy", + "ingredients": [ + { "names": ["milk"] }, + { "names": ["butter"] } + ] + } + ], + "warnings": [] +} +"#, + ), + ep( + "POST", + "/api/aisles", + "Create an aisle file", + "Creates an empty `config/aisle.conf` in the recipe directory and answers \ + `201` with the same shape as `GET /api/aisles`. `409` if an aisle file \ + already exists, wherever it was found.", + ), + ep( + "POST", + "/api/aisles/changes", + "Change the aisles", + "One change per request, named by `action`; the other fields depend on it. \ + `add_aisle` (`name`, optional `position`, counted from 0; last when \ + omitted), `rename_aisle` (`name`, `new_name`), `remove_aisle` (`name`; its \ + ingredients go with it), `move_aisle` (`name`, `position`), \ + `add_ingredient` (`aisle`, `names`), `update_ingredient` (`name`, `names`, \ + optional `aisle` to move it), `remove_ingredient` (`name`). An ingredient \ + is found by any of its names, ignoring case. Answers with the aisles as \ + `GET /api/aisles` does. `400` for a name the file cannot hold (empty, or \ + containing `|` or `//`) or one already used — ingredient names ignoring \ + case, as the shopping list matches them; `404` for an aisle or ingredient \ + that does not exist, or when there is no aisle file; `409` for a stale \ + `revision`.", + ) + .params(vec![ + param( + "action", + "body", + "string", + true, + "`add_aisle`, `rename_aisle`, `remove_aisle`, `move_aisle`, \ + `add_ingredient`, `update_ingredient` or `remove_ingredient`.", + ), + param( + "revision", + "body", + "string", + false, + "The `revision` the change was made against.", + ), + param( + "name", + "body", + "string", + false, + "The aisle, or any one of the ingredient's names.", + ), + param( + "names", + "body", + "string[]", + false, + "An ingredient's names, the one to show first.", + ), + param("aisle", "body", "string", false, "The aisle to put it in."), + param("new_name", "body", "string", false, "For `rename_aisle`."), + param( + "position", + "body", + "integer", + false, + "Where the aisle goes among the aisles, from 0.", + ), + ]) + .request( + r#" +{ + "revision": "edbf1fd2579f964e", + "action": "add_ingredient", + "aisle": "fruit and veg", + "names": ["leek", "leeks"] +} +"#, + ) + .response( + r#" +{ + "error": "\"Avocados\" is already in the \"fruit and veg\" aisle" +} +"#, + ), + ep( + "GET", + "/api/aisles/raw", + "Read the aisle file as text", + "The file exactly as written, with its `revision`. `404` when there is no \ + aisle file. The `content` below is cut short.", + ) + .response( + r#" +{ + "path": "/path/to/recipes/config/aisle.conf", + "content": "[fruit and veg]\napples\nbananas\ngrapes\n", + "revision": "edbf1fd2579f964e" +} +"#, + ), + ep( + "PUT", + "/api/aisles/raw", + "Replace the aisle file", + "Writes `content` as the whole file once the aisle parser accepts it, and \ + answers as `GET /api/aisles` does. Otherwise `400` naming the line, and the \ + file is left alone.", + ) + .params(vec![ + param("content", "body", "string", true, "The new file."), + param( + "revision", + "body", + "string", + false, + "The `revision` the text was edited from.", + ), + ]) + .request( + r#" +{ + "content": "[a]\nx\n[b]\nx\n" +} +"#, + ) + .response( + r#" +{ + "error": "Line 4: Duplicate ingredient: 'x'" +} +"#, + ), + ep( + "GET", + "/api/aisles/uncategorized", + "Ingredients without an aisle", + "The ingredients the recipes and menus use that no aisle names — what \ + `cook doctor aisle` reports — with the files using each. `link` is what \ + follows `/recipe/` in that file's page address. Scans the whole \ + collection, so it takes longer than the other reads. The response below is \ + trimmed to two of the seed's eleven.", + ) + .response( + r#" +{ + "total_recipes": 14, + "ingredients": [ + { + "name": "San Marzano tomato sauce", + "recipes": [ + { "path": "Neapolitan Pizza.cook", "link": "Neapolitan Pizza" } + ] + }, + { + "name": "almonds", + "recipes": [ + { "path": "Weekly Plan.menu", "link": "Weekly Plan.menu" } + ] + } + ] +} +"#, + ), + ], + ) +} + fn pantry() -> ApiSection { section( "pantry", diff --git a/src/web/templates.rs b/src/web/templates.rs index 653b1056..240406ca 100644 --- a/src/web/templates.rs +++ b/src/web/templates.rs @@ -663,6 +663,23 @@ pub struct PreferencesTemplate { pub viewer: Viewer, } +#[cfg(feature = "server")] +#[derive(Template)] +#[template(path = "aisles.html")] +pub struct AislesTemplate { + pub active: String, + /// What `GET /api/aisles` returns; the page draws itself from it and + /// from each change's answer. + pub aisles: serde_json::Value, + pub tr: Tr, + pub prefix: String, + pub static_mode: bool, + pub repo_url: Option, + pub features: FeatureFlags, + /// Who the page is for; decides whether editing controls are shown. + pub viewer: Viewer, +} + #[cfg(feature = "server")] #[derive(Template)] #[template(path = "pantry.html")] @@ -1064,6 +1081,7 @@ impl_into_response!( ShoppingListTemplate, PreferencesTemplate, PantryTemplate, + AislesTemplate, EditTemplate, NewTemplate, ApiDocsTemplate, diff --git a/templates/aisles.html b/templates/aisles.html new file mode 100644 index 00000000..335f7768 --- /dev/null +++ b/templates/aisles.html @@ -0,0 +1,513 @@ +{% extends "base.html" %} + +{% block title %}{{ tr.t("aisles-title") }} - Cook{% endblock %} + +{% block content %} +
+ + +
+
+

{{ tr.t("aisles-title") }}

+ {{ tr.t("aisles-back-to-list") }} +
+

{{ tr.t("aisles-intro") }}

+

+
+ + + + +
+ + +{% endblock %} diff --git a/templates/preferences.html b/templates/preferences.html index aeda22f7..ad8ee424 100644 --- a/templates/preferences.html +++ b/templates/preferences.html @@ -125,6 +125,9 @@

Configuration Files

{{ tr.t("pref-aisle-path") }}: {{ aisle_path }} + {% if !viewer.recipes_only() %} + {{ tr.t("aisles-manage") }} → + {% endif %}
{{ tr.t("pref-pantry-path") }}: diff --git a/templates/shopping_list.html b/templates/shopping_list.html index 7265ee61..617636f0 100644 --- a/templates/shopping_list.html +++ b/templates/shopping_list.html @@ -75,6 +75,7 @@

{{ tr.t("shopping-title") }}

+ {{ tr.t("aisles-manage") }} {% if viewer.can_edit_lists() %} + + {% endif %}
{% if viewer.can_edit_lists() %} +