diff --git a/docs/api.md b/docs/api.md index db4a4362..234bf93f 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,160 @@ 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 + +A change is 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`), `add_names` (`name`, `names`: more names for that ingredient, ones it has skipped) and `merge_ingredients` (`names`, one per ingredient, and `main`: the one name the shopping list will show; the result keeps the place, aisle and comment of the ingredient `main` came from). An ingredient is found by any of its names, ignoring case. Send one change with its fields beside `revision`, or several as `changes`, a list: they are applied in order and written together, or not at all, and an error names the change that failed (`Change 4: …`). 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`, `remove_ingredient`, `add_names` or `merge_ingredients`. Not given when sending `changes`. | +| `changes` | body | `object[]` | no | Several changes, each with its own `action`, made together. | +| `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; for `merge_ingredients`, one name of each ingredient to group. | +| `main` | body | `string` | no | For `merge_ingredients`: the name the grouped ingredient goes by. | +| `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", + "changes": [ + { "action": "add_aisle", "name": "snacks" }, + { "action": "add_ingredient", "aisle": "snacks", "names": ["almonds"] }, + { "action": "add_names", "name": "butter", "names": ["Butter"] }, + { "action": "add_ingredient", "aisle": "snacks", "names": ["Butter"] } + ] +} +``` + +Response: + +```json +{ + "error": "Change 4: \"Butter\" is already in the \"milk and dairy\" 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..9da9d2cb --- /dev/null +++ b/locales/de-DE/aisles.ftl @@ -0,0 +1,63 @@ +# 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. Der fett gedruckte Hauptname einer Zutat ist der, den die Liste zeigt; ihre weiteren Namen werden darin 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-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. +aisles-new-aisle = + Neuer Gang… +aisles-new-aisle-prompt = Name des neuen Gangs: +aisles-select = Auswählen +aisles-select-done = Fertig +aisles-select-help = Wähle Zutaten aus, um sie zu einer zusammenzufassen oder gemeinsam zu verschieben. +aisles-selected = %n ausgewählt +aisles-group = Zusammenfassen… +aisles-group-help = Welchen Namen soll die Einkaufsliste zeigen? Die anderen werden darin zusammengefasst. +aisles-group-confirm = Zusammenfassen +aisles-move = Verschieben +aisles-clear = Auswahl aufheben +aisles-main-name = Hauptname +aisles-make-main = Zum Hauptnamen machen +aisles-remove-name = Diesen Namen entfernen +aisles-add-name = Weiteren Namen hinzufügen +aisles-filter = Zutaten filtern +aisles-select-all = Alle angezeigten auswählen +aisles-add-to-aisle = Zum Gang hinzufügen +aisles-add-as-names = Oder als weitere Namen hinzufügen von +aisles-choose-ingredient = Zutat… +aisles-move-to-aisle = In Gang verschieben diff --git a/locales/en-US/aisles.ftl b/locales/en-US/aisles.ftl new file mode 100644 index 00000000..3ff88e4e --- /dev/null +++ b/locales/en-US/aisles.ftl @@ -0,0 +1,63 @@ +# 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. An ingredient's main name, in bold, is the one the list shows; its other names are merged into it. +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-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. +aisles-new-aisle = + New aisle… +aisles-new-aisle-prompt = Name of the new aisle: +aisles-select = Select +aisles-select-done = Done +aisles-select-help = Pick ingredients to group them into one, or to move them together. +aisles-selected = %n selected +aisles-group = Group… +aisles-group-help = Which name should the shopping list show? The others are merged into it. +aisles-group-confirm = Group +aisles-move = Move +aisles-clear = Clear +aisles-main-name = main name +aisles-make-main = Make this the main name +aisles-remove-name = Remove this name +aisles-add-name = Add another name +aisles-filter = Filter ingredients +aisles-select-all = Select all shown +aisles-add-to-aisle = Add to aisle +aisles-add-as-names = Or add as other names of +aisles-choose-ingredient = Ingredient… +aisles-move-to-aisle = Move to aisle diff --git a/locales/es-ES/aisles.ftl b/locales/es-ES/aisles.ftl new file mode 100644 index 00000000..e6f88707 --- /dev/null +++ b/locales/es-ES/aisles.ftl @@ -0,0 +1,63 @@ +# 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. El nombre principal de un ingrediente, en negrita, es el que muestra la lista; sus otros nombres se unen a él. +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-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. +aisles-new-aisle = + Nuevo pasillo… +aisles-new-aisle-prompt = Nombre del nuevo pasillo: +aisles-select = Seleccionar +aisles-select-done = Listo +aisles-select-help = Elige ingredientes para agruparlos en uno solo o para moverlos juntos. +aisles-selected = %n seleccionados +aisles-group = Agrupar… +aisles-group-help = ¿Qué nombre debe mostrar la lista de la compra? Los demás se unen a él. +aisles-group-confirm = Agrupar +aisles-move = Mover +aisles-clear = Quitar selección +aisles-main-name = nombre principal +aisles-make-main = Convertir en nombre principal +aisles-remove-name = Quitar este nombre +aisles-add-name = Añadir otro nombre +aisles-filter = Filtrar ingredientes +aisles-select-all = Seleccionar todos los mostrados +aisles-add-to-aisle = Añadir al pasillo +aisles-add-as-names = O añadir como otros nombres de +aisles-choose-ingredient = Ingrediente… +aisles-move-to-aisle = Mover al pasillo diff --git a/locales/eu-ES/aisles.ftl b/locales/eu-ES/aisles.ftl new file mode 100644 index 00000000..a3a5efda --- /dev/null +++ b/locales/eu-ES/aisles.ftl @@ -0,0 +1,63 @@ +# 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 izen nagusia, lodiz, zerrendak erakusten duena da; beste izenak harekin 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-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. +aisles-new-aisle = + Korridore berria… +aisles-new-aisle-prompt = Korridore berriaren izena: +aisles-select = Hautatu +aisles-select-done = Eginda +aisles-select-help = Aukeratu osagaiak bakar batean taldekatzeko edo batera mugitzeko. +aisles-selected = %n hautatuta +aisles-group = Taldekatu… +aisles-group-help = Zein izen erakutsi behar du erosketa-zerrendak? Besteak harekin batzen dira. +aisles-group-confirm = Taldekatu +aisles-move = Mugitu +aisles-clear = Garbitu hautapena +aisles-main-name = izen nagusia +aisles-make-main = Bihurtu izen nagusi +aisles-remove-name = Kendu izen hau +aisles-add-name = Gehitu beste izen bat +aisles-filter = Iragazi osagaiak +aisles-select-all = Hautatu erakutsitako guztiak +aisles-add-to-aisle = Gehitu korridorera +aisles-add-as-names = Edo gehitu honen beste izen gisa: +aisles-choose-ingredient = Osagaia… +aisles-move-to-aisle = Mugitu korridorera diff --git a/locales/fr-FR/aisles.ftl b/locales/fr-FR/aisles.ftl new file mode 100644 index 00000000..32913383 --- /dev/null +++ b/locales/fr-FR/aisles.ftl @@ -0,0 +1,63 @@ +# 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. Le nom principal d'un ingrédient, en gras, est celui qu'affiche la liste ; ses autres noms y sont regroupés. +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-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. +aisles-new-aisle = + Nouveau rayon… +aisles-new-aisle-prompt = Nom du nouveau rayon : +aisles-select = Sélectionner +aisles-select-done = Terminé +aisles-select-help = Choisissez des ingrédients pour les regrouper en un seul, ou pour les déplacer ensemble. +aisles-selected = %n sélectionné(s) +aisles-group = Regrouper… +aisles-group-help = Quel nom la liste de courses doit-elle afficher ? Les autres y seront regroupés. +aisles-group-confirm = Regrouper +aisles-move = Déplacer +aisles-clear = Désélectionner +aisles-main-name = nom principal +aisles-make-main = En faire le nom principal +aisles-remove-name = Retirer ce nom +aisles-add-name = Ajouter un autre nom +aisles-filter = Filtrer les ingrédients +aisles-select-all = Tout sélectionner (affichés) +aisles-add-to-aisle = Ajouter au rayon +aisles-add-as-names = Ou ajouter comme autres noms de +aisles-choose-ingredient = Ingrédient… +aisles-move-to-aisle = Déplacer vers le rayon diff --git a/locales/it-IT/aisles.ftl b/locales/it-IT/aisles.ftl new file mode 100644 index 00000000..3e24e65e --- /dev/null +++ b/locales/it-IT/aisles.ftl @@ -0,0 +1,63 @@ +# 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. Il nome principale di un ingrediente, in grassetto, è quello mostrato dalla lista; gli altri nomi vengono uniti a esso. +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-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. +aisles-new-aisle = + Nuova corsia… +aisles-new-aisle-prompt = Nome della nuova corsia: +aisles-select = Seleziona +aisles-select-done = Fatto +aisles-select-help = Scegli gli ingredienti da raggruppare in uno solo o da spostare insieme. +aisles-selected = %n selezionati +aisles-group = Raggruppa… +aisles-group-help = Quale nome deve mostrare la lista della spesa? Gli altri vengono uniti a esso. +aisles-group-confirm = Raggruppa +aisles-move = Sposta +aisles-clear = Deseleziona +aisles-main-name = nome principale +aisles-make-main = Rendi nome principale +aisles-remove-name = Rimuovi questo nome +aisles-add-name = Aggiungi un altro nome +aisles-filter = Filtra ingredienti +aisles-select-all = Seleziona tutti quelli mostrati +aisles-add-to-aisle = Aggiungi alla corsia +aisles-add-as-names = Oppure aggiungi come altri nomi di +aisles-choose-ingredient = Ingrediente… +aisles-move-to-aisle = Sposta nella corsia diff --git a/locales/ja-JP/aisles.ftl b/locales/ja-JP/aisles.ftl new file mode 100644 index 00000000..f9d90972 --- /dev/null +++ b/locales/ja-JP/aisles.ftl @@ -0,0 +1,63 @@ +# 売り場 +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-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 = 編集中に売り場ファイルが別の場所で変更されました。入力した内容は残っています。もう一度保存すると、その変更をこの内容で置き換えます。 +aisles-new-aisle = + 新しい売り場… +aisles-new-aisle-prompt = 新しい売り場の名前: +aisles-select = 選択 +aisles-select-done = 完了 +aisles-select-help = 材料を選んで、1 つにまとめたり、まとめて移動したりできます。 +aisles-selected = %n 件選択中 +aisles-group = まとめる… +aisles-group-help = 買い物リストに表示する名前はどれですか?ほかの名前はその名前にまとめられます。 +aisles-group-confirm = まとめる +aisles-move = 移動 +aisles-clear = 選択を解除 +aisles-main-name = 主な名前 +aisles-make-main = 主な名前にする +aisles-remove-name = この名前を削除 +aisles-add-name = 別の名前を追加 +aisles-filter = 材料を絞り込む +aisles-select-all = 表示中をすべて選択 +aisles-add-to-aisle = 売り場に追加 +aisles-add-as-names = または次の材料の別名として追加: +aisles-choose-ingredient = 材料… +aisles-move-to-aisle = 売り場を移動 diff --git a/locales/nl-NL/aisles.ftl b/locales/nl-NL/aisles.ftl new file mode 100644 index 00000000..8f39baf0 --- /dev/null +++ b/locales/nl-NL/aisles.ftl @@ -0,0 +1,63 @@ +# 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. De vetgedrukte hoofdnaam van een ingrediënt is de naam die de lijst toont; de andere namen worden erin samengevoegd. +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-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. +aisles-new-aisle = + Nieuw gangpad… +aisles-new-aisle-prompt = Naam van het nieuwe gangpad: +aisles-select = Selecteren +aisles-select-done = Klaar +aisles-select-help = Kies ingrediënten om ze tot één samen te voegen of samen te verplaatsen. +aisles-selected = %n geselecteerd +aisles-group = Samenvoegen… +aisles-group-help = Welke naam moet de boodschappenlijst tonen? De andere worden erin samengevoegd. +aisles-group-confirm = Samenvoegen +aisles-move = Verplaatsen +aisles-clear = Selectie wissen +aisles-main-name = hoofdnaam +aisles-make-main = Tot hoofdnaam maken +aisles-remove-name = Deze naam verwijderen +aisles-add-name = Nog een naam toevoegen +aisles-filter = Ingrediënten filteren +aisles-select-all = Alle getoonde selecteren +aisles-add-to-aisle = Toevoegen aan gangpad +aisles-add-as-names = Of toevoegen als andere namen van +aisles-choose-ingredient = Ingrediënt… +aisles-move-to-aisle = Verplaatsen naar gangpad diff --git a/locales/sv-SE/aisles.ftl b/locales/sv-SE/aisles.ftl new file mode 100644 index 00000000..09906fa1 --- /dev/null +++ b/locales/sv-SE/aisles.ftl @@ -0,0 +1,63 @@ +# 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 huvudnamn, i fetstil, är det som listan visar; dess andra namn slås ihop med det. +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-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. +aisles-new-aisle = + Ny gång… +aisles-new-aisle-prompt = Namn på den nya gången: +aisles-select = Välj +aisles-select-done = Klar +aisles-select-help = Välj ingredienser för att slå ihop dem till en eller flytta dem tillsammans. +aisles-selected = %n valda +aisles-group = Slå ihop… +aisles-group-help = Vilket namn ska inköpslistan visa? De andra slås ihop med det. +aisles-group-confirm = Slå ihop +aisles-move = Flytta +aisles-clear = Avmarkera +aisles-main-name = huvudnamn +aisles-make-main = Gör till huvudnamn +aisles-remove-name = Ta bort det här namnet +aisles-add-name = Lägg till ett annat namn +aisles-filter = Filtrera ingredienser +aisles-select-all = Välj alla som visas +aisles-add-to-aisle = Lägg till i gång +aisles-add-as-names = Eller lägg till som andra namn på +aisles-choose-ingredient = Ingrediens… +aisles-move-to-aisle = Flytta till gång diff --git a/src/server/aisle_file.rs b/src/server/aisle_file.rs new file mode 100644 index 00000000..4fbaa36d --- /dev/null +++ b/src/server/aisle_file.rs @@ -0,0 +1,806 @@ +//! 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(()) + } + + /// The names on ingredient line `at`. + fn names_at(&self, at: usize) -> Vec { + match kind(&self.lines[at]) { + Kind::Ingredient(names) => names, + _ => Vec::new(), + } + } + + /// Adds `names` to the names of the ingredient called `name`, so the + /// shopping list merges them into its first name. Names it already has + /// are skipped. + pub fn add_names(&mut self, name: &str, names: &[String]) -> Result<(), EditError> { + let at = self.find_ingredient(name)?; + let added = ingredient_names(names)?; + let mut all = self.names_at(at); + for name in added { + let folded = name.to_lowercase(); + if !all.iter().any(|n| n.to_lowercase() == folded) { + all.push(name); + } + } + self.check_names_free(&all, Some(at))?; + self.lines[at] = replace_code(&self.lines[at], &all.join(" | ")); + Ok(()) + } + + /// Makes one ingredient of the ones `entries` name, one name for each, + /// with `main` as the name the shopping list shows. It stays where the + /// entry holding `main` was, in its aisle and with its comment; the other + /// entries' lines go, comments included. Their names follow `main`'s own, + /// in the order the entries were given. + pub fn merge_ingredients(&mut self, entries: &[String], main: &str) -> Result<(), EditError> { + let mut lines: Vec = Vec::new(); + for entry in entries { + let at = self.find_ingredient(entry)?; + if !lines.contains(&at) { + lines.push(at); + } + } + if lines.len() < 2 { + return Err(EditError::InvalidName( + "Choose at least two ingredients to group".to_string(), + )); + } + + let folded = main.trim().to_lowercase(); + let (keep, main) = lines + .iter() + .find_map(|&at| { + self.names_at(at) + .into_iter() + .find(|n| n.to_lowercase() == folded) + .map(|name| (at, name)) + }) + .ok_or_else(|| { + EditError::InvalidName(format!( + "\"{}\" is not one of the names being grouped", + main.trim() + )) + })?; + + let mut merged = vec![main]; + let others = std::iter::once(keep).chain(lines.iter().copied().filter(|&at| at != keep)); + for at in others { + for name in self.names_at(at) { + let folded = name.to_lowercase(); + if !merged.iter().any(|n| n.to_lowercase() == folded) { + merged.push(name); + } + } + } + let merged = ingredient_names(&merged)?; + + self.lines[keep] = replace_code(&self.lines[keep], &merged.join(" | ")); + let mut gone: Vec = lines.into_iter().filter(|&at| at != keep).collect(); + gone.sort_unstable_by(|a, b| b.cmp(a)); + for at in gone { + 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"))); + } + + #[test] + fn other_names_join_an_ingredient_and_keep_its_comment() { + let text = edited(|f| f.add_names("AVOCADOS", &names(&["hass", " Avocados "]))); + assert_eq!( + text, + SAMPLE.replace( + "avocado | avocados // ripe ones", + "avocado | avocados | hass // ripe ones" + ) + ); + + let mut file = AisleFile::parse(SAMPLE); + assert!(matches!( + file.add_names("milk", &names(&["Apples"])), + Err(EditError::Taken(_)) + )); + assert!(matches!( + file.add_names("cream", &names(&["double cream"])), + Err(EditError::NotFound(_)) + )); + assert_eq!(file.to_text(), SAMPLE); + } + + #[test] + fn grouping_keeps_the_main_entry_where_it_was() { + // Across aisles: everything ends up on the avocado line, in its + // aisle, with its comment, and the other lines go. + let text = + edited(|f| f.merge_ingredients(&names(&["butter", "avocados", "milk"]), "Avocado")); + assert_eq!( + text, + SAMPLE + .replace( + "avocado | avocados // ripe ones", + "avocado | avocados | butter | milk // ripe ones" + ) + .replace("[dairy]\nmilk\nbutter\n", "[dairy]\n") + ); + + // The main name may come from any entry, even a later one. + let text = edited(|f| f.merge_ingredients(&names(&["milk", "butter"]), "butter")); + assert_eq!( + text, + SAMPLE.replace("[dairy]\nmilk\nbutter\n", "[dairy]\nbutter | milk\n") + ); + } + + #[test] + fn grouping_needs_two_entries_and_one_of_their_names() { + let mut file = AisleFile::parse(SAMPLE); + for (entries, main) in [ + (names(&["milk"]), "milk"), + (names(&["milk", "MILK"]), "milk"), + (names(&["milk", "butter"]), "cream"), + ] { + assert!( + matches!( + file.merge_ingredients(&entries, main), + Err(EditError::InvalidName(_)) + ), + "{entries:?} as {main}" + ); + } + assert!(matches!( + file.merge_ingredients(&names(&["milk", "caviar"]), "milk"), + Err(EditError::NotFound(_)) + )); + assert_eq!(file.to_text(), SAMPLE); + } +} 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..ab2c9096 --- /dev/null +++ b/src/server/handlers/aisles.rs @@ -0,0 +1,637 @@ +//! 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, + }, + /// Adds `names` to the ingredient called `name`, so the shopping list + /// merges them into its first name. Names it already has are skipped. + AddNames { + name: String, + names: Vec, + }, + /// Makes one ingredient of the ones `names` name, one name for each, + /// shown on the shopping list as `main`. It keeps the place, aisle and + /// comment of the entry `main` came from. + MergeIngredients { + names: Vec, + main: 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), + Change::AddNames { name, names } => file.add_names(name, names), + Change::MergeIngredients { names, main } => file.merge_ingredients(names, main), + } + } + + 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)), + ), + Change::AddNames { name, names } => activity::record( + viewer, + format_args!( + "added {} as other names of {}", + quoted(&names.join(" | ")), + quoted(name) + ), + ), + Change::MergeIngredients { names, main } => activity::record( + viewer, + format_args!("grouped {} as {}", quoted(&names.join(" | ")), quoted(main)), + ), + } + } +} + +/// The body of `POST /api/aisles/changes`: one change, its fields beside +/// `revision`, or several under `changes`, applied in order and all or none. +#[derive(Debug)] +pub struct ChangeRequest { + pub revision: Option, + pub changes: Vec, +} + +impl<'de> Deserialize<'de> for ChangeRequest { + fn deserialize>(deserializer: D) -> Result { + // Told apart by hand rather than with `#[serde(untagged)]`, which + // would answer every mistake with "did not match any variant". + #[derive(Deserialize)] + struct Many { + revision: Option, + changes: Vec, + } + #[derive(Deserialize)] + struct One { + revision: Option, + #[serde(flatten)] + change: Change, + } + + use serde::de::Error; + let value = serde_json::Value::deserialize(deserializer)?; + if value.get("changes").is_some() { + let Many { revision, changes } = Many::deserialize(value).map_err(D::Error::custom)?; + Ok(Self { revision, changes }) + } else { + let One { revision, change } = One::deserialize(value).map_err(D::Error::custom)?; + Ok(Self { + revision, + changes: vec![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())?; + + if request.changes.is_empty() { + return Err((StatusCode::BAD_REQUEST, json_error("No changes to make"))); + } + let mut file = AisleFile::parse(&text); + let several = request.changes.len() > 1; + for (i, change) in request.changes.iter().enumerate() { + change.apply(&mut file).map_err(|error| { + let (status, Json(mut body)) = edit_error(error); + if several { + body["error"] = format!( + "Change {}: {}", + i + 1, + body["error"].as_str().unwrap_or_default() + ) + .into(); + } + (status, Json(body)) + })?; + } + 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?; + for change in &request.changes { + 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.changes[..], + [Change::AddIngredient { .. }] + )); + } + + #[test] + fn several_changes_read_as_a_list() { + let request: ChangeRequest = serde_json::from_str( + r#"{"changes": [ + {"action": "add_aisle", "name": "frozen"}, + {"action": "merge_ingredients", "names": ["butter", "cold butter"], "main": "butter"} + ]}"#, + ) + .unwrap(); + assert_eq!(request.revision, None); + assert!(matches!( + request.changes[..], + [Change::AddAisle { .. }, Change::MergeIngredients { .. }] + )); + + // A mistake is still named. + let error = serde_json::from_str::(r#"{"action": "add_aisle"}"#) + .unwrap_err() + .to_string(); + assert!(error.contains("missing field `name`"), "{error}"); + } +} 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..2d3dd0c9 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. @@ -639,6 +644,24 @@ pub struct AppState { pub shutdown_token: tokio_util::sync::CancellationToken, } +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") + } +} + #[cfg(feature = "sync")] impl AppState { /// Check sync status: returns (logged_in, email, syncing, reason). @@ -716,6 +739,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..3a00ea76 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,245 @@ 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", + "A change is 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`), `add_names` \ + (`name`, `names`: more names for that ingredient, ones it has skipped) and \ + `merge_ingredients` (`names`, one per ingredient, and `main`: the one name \ + the shopping list will show; the result keeps the place, aisle and comment \ + of the ingredient `main` came from). An ingredient is found by any of its \ + names, ignoring case. Send one change with its fields beside `revision`, or \ + several as `changes`, a list: they are applied in order and written \ + together, or not at all, and an error names the change that failed \ + (`Change 4: …`). 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`, `remove_ingredient`, `add_names` \ + or `merge_ingredients`. Not given when sending `changes`.", + ), + param( + "changes", + "body", + "object[]", + false, + "Several changes, each with its own `action`, made together.", + ), + 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; for `merge_ingredients`, \ + one name of each ingredient to group.", + ), + param( + "main", + "body", + "string", + false, + "For `merge_ingredients`: the name the grouped ingredient goes by.", + ), + 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", + "changes": [ + { "action": "add_aisle", "name": "snacks" }, + { "action": "add_ingredient", "aisle": "snacks", "names": ["almonds"] }, + { "action": "add_names", "name": "butter", "names": ["Butter"] }, + { "action": "add_ingredient", "aisle": "snacks", "names": ["Butter"] } + ] +} +"#, + ) + .response( + r#" +{ + "error": "Change 4: \"Butter\" is already in the \"milk and dairy\" 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..ff8e2182 --- /dev/null +++ b/templates/aisles.html @@ -0,0 +1,911 @@ +{% 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..7d5757e8 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() %}