diff --git a/crates/core/src/pantry/edit.rs b/crates/core/src/pantry/edit.rs index a1bf32ce..fbc4025d 100644 --- a/crates/core/src/pantry/edit.rs +++ b/crates/core/src/pantry/edit.rs @@ -203,6 +203,46 @@ pub(super) fn remove(doc: &mut DocumentMut, section: &str, name: &str) { } } +/// The root key other than `except` that `name` would collide with, compared +/// ignoring case: a section, or an item above the first `[header]`. +/// +/// Ignoring case because `Dairy` next to `dairy` reads as one section to anyone +/// looking at the page, while the parser would keep them apart. +pub(super) fn root_key_like(doc: &DocumentMut, name: &str, except: &str) -> Option { + doc.as_table() + .iter() + .map(|(key, _)| key) + .find(|key| *key != except && key.to_lowercase() == name.to_lowercase()) + .map(str::to_string) +} + +/// Rename `section` to `new_name`, keeping where it is in the file. +/// +/// The root is rebuilt in its own order with only that key changed, so a +/// section written inline (`fridge = { … }`) keeps its place among the +/// top-level entries; a `[header]` table carries its own position, and its +/// decor — the comment above the header — moves with it. The caller has +/// checked that `section` exists and that `new_name` is free. +pub(super) fn rename_section(doc: &mut DocumentMut, section: &str, new_name: &str) { + let root = doc.as_table_mut(); + let keys: Vec = root.iter().map(|(key, _)| key.to_string()).collect(); + let entries: Vec<(toml_edit::Key, Item)> = keys + .iter() + .filter_map(|key| root.remove_entry(key)) + .collect(); + + for (key, item) in entries { + let key = if key.get() == section { + toml_edit::Key::new(new_name) + .with_leaf_decor(key.leaf_decor().clone()) + .with_dotted_decor(key.dotted_decor().clone()) + } else { + key + }; + root.insert_formatted(&key, item); + } +} + /// Apply `attributes` to the item already at `section`/`name`. /// /// Only the attributes that are set are written. Everything else the entry @@ -231,16 +271,23 @@ pub(super) fn apply( && attributes.bought.is_none() && attributes.expire.is_none() && attributes.low.is_none(); + // The spacing and comment around the value — `milk = "1%l" # semi-skimmed` + // — belong to the line, not to the amount, so they stay. + let decor = existing.as_value().map(|value| value.decor().clone()); + if stays_short { if let Some(quantity) = &attributes.quantity { *existing = toml_edit::value(quantity.as_str()); } - return Ok(()); + } else { + let mut table = as_inline_table(existing, section, name)?; + attributes.write_into(&mut table); + *existing = toml_edit::value(table); } - let mut table = as_inline_table(existing, section, name)?; - attributes.write_into(&mut table); - *existing = toml_edit::value(table); + if let (Some(decor), Some(value)) = (decor, existing.as_value_mut()) { + *value.decor_mut() = decor; + } Ok(()) } @@ -332,8 +379,24 @@ impl Attributes { ("expire", &self.expire), ("low", &self.low), ] { - if let Some(value) = value { + let Some(value) = value else { continue }; + if table.contains_key(key) { table.insert(key, value.as_str().into()); + continue; + } + // A key added after the last one takes over the space that + // closed the table, `{ quantity = "1%l" }` becoming + // `{ quantity = "1%l", expire = "…" }` and not `"1%l" , expire`. + let closing = table.iter_mut().last().and_then(|(_, last)| { + let suffix = last.decor().suffix()?.as_str()?.to_string(); + suffix.trim().is_empty().then(|| { + last.decor_mut().set_suffix(""); + suffix + }) + }); + table.insert(key, value.as_str().into()); + if let (Some(closing), Some(added)) = (closing, table.get_mut(key)) { + added.decor_mut().set_suffix(closing); } } } @@ -419,8 +482,51 @@ mod tests { assert!(out.contains("shelf = \"top\""), "{out}"); } + /// An attribute added to an item written inline is spaced like the ones + /// already there, whichever way the file spaces them. + #[test] + fn an_added_attribute_is_spaced_like_its_neighbours() { + let expire = Attributes { + expire: Some("2026-11-02".to_string()), + ..Default::default() + }; + let mut d = doc("[fridge]\nmilk = { quantity = \"2%l\" }\ncream = {quantity=\"1\"}\n"); + apply(&mut d, "fridge", "milk", &expire).expect("applies"); + apply(&mut d, "fridge", "cream", &expire).expect("applies"); + + let out = d.to_string(); + assert!( + out.contains("milk = { quantity = \"2%l\", expire = \"2026-11-02\" }"), + "{out}" + ); + assert!( + out.contains("cream = {quantity=\"1\", expire = \"2026-11-02\"}"), + "{out}" + ); + } + /// Changing only the quantity of a short-form item leaves it short form, /// rather than expanding it to `{ quantity = "..." }`. + #[test] + fn a_comment_after_an_item_survives_an_update() { + let mut d = doc("[fridge]\nmilk = \"1%l\" # semi-skimmed\neggs = \"6\" # free range\n"); + apply(&mut d, "fridge", "milk", &quantity("2%l")).unwrap(); + apply( + &mut d, + "fridge", + "eggs", + &Attributes { + expire: Some("2027-01-01".to_string()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + d.to_string(), + "[fridge]\nmilk = \"2%l\" # semi-skimmed\neggs = { quantity = \"6\", expire = \"2027-01-01\" } # free range\n" + ); + } + #[test] fn a_short_form_item_stays_short_when_only_the_quantity_changes() { let mut d = doc("[fridge]\nmilk = \"1%l\"\n"); @@ -520,4 +626,57 @@ mod tests { insert(&mut d, "fridge", "milk", &Attributes::default()); assert!(d.to_string().contains("milk = \"\""), "{d}"); } + + /// A rename touches the header and nothing else: the section stays where + /// it was, its comment stays above it, and its items keep their forms. + #[test] + fn a_renamed_section_keeps_its_place_comments_and_items() { + let mut d = doc("salt = \"1%kg\"\n\ + \n\ + # cold things\n\ + [fridge]\n\ + milk = \"1%l\" # semi-skimmed\n\ + eggs = { quantity = \"6\", expire = \"2027-01-01\" }\n\ + \n\ + [freezer]\n\ + peas = \"500%g\"\n"); + rename_section(&mut d, "fridge", "Fridge door"); + + assert_eq!( + d.to_string(), + "salt = \"1%kg\"\n\ + \n\ + # cold things\n\ + [\"Fridge door\"]\n\ + milk = \"1%l\" # semi-skimmed\n\ + eggs = { quantity = \"6\", expire = \"2027-01-01\" }\n\ + \n\ + [freezer]\n\ + peas = \"500%g\"\n" + ); + } + + /// A section written inline among the top-level entries keeps its place + /// among them rather than moving to the end. + #[test] + fn an_inline_section_keeps_its_place_among_top_level_entries() { + let mut d = doc("fridge = { milk = \"1%l\" }\nshelf = { rice = \"1%kg\" }\n"); + rename_section(&mut d, "fridge", "cold"); + assert_eq!( + d.to_string(), + "cold = { milk = \"1%l\" }\nshelf = { rice = \"1%kg\" }\n" + ); + } + + #[test] + fn a_name_in_use_is_found_ignoring_case_but_not_the_section_itself() { + let d = doc("salt = \"1%kg\"\n[fridge]\nmilk = \"1%l\"\n[freezer]\npeas = \"1\"\n"); + assert_eq!( + root_key_like(&d, "FREEZER", "fridge").as_deref(), + Some("freezer") + ); + assert_eq!(root_key_like(&d, "Salt", "fridge").as_deref(), Some("salt")); + assert_eq!(root_key_like(&d, "Fridge", "fridge"), None); + assert_eq!(root_key_like(&d, "pantry", "fridge"), None); + } } diff --git a/crates/core/src/pantry/mod.rs b/crates/core/src/pantry/mod.rs index d54c6321..af5eb60d 100644 --- a/crates/core/src/pantry/mod.rs +++ b/crates/core/src/pantry/mod.rs @@ -9,7 +9,8 @@ //! [`plan`] is the odd one out: it answers "what should I stock?" by looking at //! the recipe collection alone, and never reads the pantry at all. //! -//! [`add`], [`remove`] and [`update`] change the pantry and write it back. +//! [`add`], [`remove`], [`update`] and [`rename_section`] change the pantry and +//! write it back. //! They are the only functions in this crate that write to a file the user //! owns, so read [`write_atomically`] and **[what a write //! touches](#what-a-write-touches)** before calling them. @@ -986,6 +987,107 @@ pub fn update(ctx: &Context, req: UpdateRequest) -> Result Result, CoreError> { + let edit_error = |message: String| CoreError::PantryEdit { message }; + let section = req.section; + let new_name = req.new_name.trim().to_string(); + + if new_name.is_empty() { + return Err(edit_error("the section name cannot be empty".to_string())); + } + if new_name == section { + return Err(edit_error(format!( + "section '{section}' is already called that" + ))); + } + if [section.as_str(), new_name.as_str()].contains(&edit::GENERAL) { + return Err(edit_error(format!( + "'{}' is the name for the items above the first section header and cannot be renamed", + edit::GENERAL + ))); + } + + let path = path_to_edit(ctx)?; + let (mut doc, mut diagnostics) = read_document(&path)?; + + diagnostics.extend(normalise_array_section(&mut doc, §ion, &path)); + if !edit::section_exists(&doc, §ion) { + return Err(section_not_found(§ion)); + } + if let Some(taken) = edit::root_key_like(&doc, &new_name, §ion) { + return Err(edit_error(format!( + "there is already a section or item called '{taken}'" + ))); + } + + edit::rename_section(&mut doc, §ion, &new_name); + + save(&path, &doc, diagnostics) +} + +/// Replace the whole pantry file with `text`, as someone editing it by hand +/// would, once it reads as a pantry. +/// +/// `text` is written exactly as given — this is the one write that touches +/// everything, because everything is what was asked for. It is checked the way +/// every edit reads a file first, so a pantry the other functions here could not +/// edit afterwards is never written, and it is written the same way they write, +/// atomically and through a symlink. +/// +/// Returns the pantry `text` describes, and the warnings from parsing it. +/// +/// # Errors +/// +/// - [`CoreError::Config`] if `text` is not a pantry, quoting the parser's +/// complaint and the line it is on. Nothing is written. +/// - [`CoreError::MissingConfig`] if the context carries no pantry, and +/// [`CoreError::ReadOnlyConfig`] if it carries one inline. +/// - [`CoreError::Io`] if the file cannot be written. +pub fn replace(ctx: &Context, text: &str) -> Result, CoreError> { + let path = path_to_edit(ctx)?; + let (_, diagnostics) = parse_document(&path, text)?; + write_atomically(&path, text)?; + let (conf, _) = parse_conf(&path, text)?; + Ok(Outcome::with_diagnostics( + PantryContents::from_conf(&conf), + diagnostics, + )) +} + fn section_not_found(section: &str) -> CoreError { CoreError::PantryEdit { message: format!("section '{section}' not found"), diff --git a/crates/core/src/pantry/tests.rs b/crates/core/src/pantry/tests.rs index c6e895f9..799c75aa 100644 --- a/crates/core/src/pantry/tests.rs +++ b/crates/core/src/pantry/tests.rs @@ -2396,3 +2396,113 @@ fn a_general_add_with_attributes_is_refused() { ); assert_eq!(read_back(&ctx), original, "nothing may be written"); } + +// --------------------------------------------------------------------------- +// rename_section +// --------------------------------------------------------------------------- + +fn rename( + ctx: &Context, + section: &str, + new_name: &str, +) -> Result, CoreError> { + rename_section( + ctx, + RenameSectionRequest { + section: section.to_string(), + new_name: new_name.to_string(), + }, + ) +} + +/// Only the header changes: the comments, the items and the other sections are +/// the file as it was, and what comes back is the pantry under its new name. +#[test] +fn rename_section_changes_only_the_header() { + let original = "# my pantry\n[dairy]\nmilk = \"1%l\" # semi\n\n[produce]\napple = \"5\"\n"; + let (_dir, ctx) = planted(original); + + let contents = rename(&ctx, "dairy", " Fridge ") + .expect("renames") + .into_value(); + + assert_eq!( + contents + .sections + .iter() + .map(|s| s.name.as_str()) + .collect::>(), + ["Fridge", "produce"] + ); + assert_eq!(read_back(&ctx), original.replace("[dairy]", "[Fridge]")); +} + +#[test] +fn rename_section_may_change_only_the_case_of_its_own_name() { + let (_dir, ctx) = planted("[dairy]\nmilk = \"1%l\"\n"); + rename(&ctx, "dairy", "Dairy").expect("renames"); + assert_eq!(read_back(&ctx), "[Dairy]\nmilk = \"1%l\"\n"); +} + +#[test] +fn rename_section_refuses_and_writes_nothing() { + let original = "salt = \"1%kg\"\n[dairy]\nmilk = \"1%l\"\n[produce]\napple = \"5\"\n"; + for (section, new_name, expected) in [ + ("dairy", " ", "the section name cannot be empty"), + ("dairy", "dairy", "section 'dairy' is already called that"), + ("freezer", "cold", "section 'freezer' not found"), + ( + "dairy", + "Produce", + "there is already a section or item called 'produce'", + ), + ( + "dairy", + "SALT", + "there is already a section or item called 'salt'", + ), + ("general", "top", "cannot be renamed"), + ("dairy", "general", "cannot be renamed"), + ] { + let (_dir, ctx) = planted(original); + match rename(&ctx, section, new_name) { + Err(CoreError::PantryEdit { message }) => assert!( + message.contains(expected), + "{section} → {new_name}: {message}" + ), + other => panic!( + "{section} → {new_name}: expected PantryEdit, got {:?}", + other.map(|o| o.value) + ), + } + assert_eq!(read_back(&ctx), original, "nothing may be written"); + } +} + +// --------------------------------------------------------------------------- +// replace +// --------------------------------------------------------------------------- + +#[test] +fn replace_writes_the_text_exactly() { + let (_dir, ctx) = planted(SMALL); + let text = "# hand-written\n[fridge]\nmilk = \"1%l\" # odd spacing\n"; + + let contents = replace(&ctx, text).expect("replaces").into_value(); + + assert_eq!(read_back(&ctx), text); + assert_eq!(names(&contents.sections[0].items), ["milk"]); +} + +#[test] +fn replace_refuses_text_that_is_not_a_pantry_and_writes_nothing() { + let (_dir, ctx) = planted(SMALL); + + match replace(&ctx, "[fridge]\nmilk = \"1%l\"\nmilk = \"2%l\"\n") { + Err(CoreError::Config { message, .. }) => { + assert!(message.contains("line 3"), "{message}") + } + other => panic!("expected Config, got {:?}", other.map(|o| o.value)), + } + assert_eq!(read_back(&ctx), SMALL, "nothing may be written"); +} diff --git a/docs/api.md b/docs/api.md index 38098820..ef989792 100644 --- a/docs/api.md +++ b/docs/api.md @@ -861,7 +861,7 @@ Re-aggregates the current list and removes checks for ingredients that are no lo ## Pantry -Reads and writes `pantry.conf`, a TOML file of what you already have at home. Quantities are typically `VALUE%UNIT`, e.g. `250%g`, but the field is just a string — `unlim` and plain counts like `12` appear untouched in the seed data. Every endpoint here returns 404 when no pantry file is configured. +Reads and writes `pantry.conf`, a TOML file of what you already have at home. Quantities are typically `VALUE%UNIT`, e.g. `250%g`, but the field is just a string — `unlim` and plain counts like `12` appear untouched in the seed data. Every endpoint here returns 404 when no pantry file is configured. Changes edit only the entry concerned, so comments, blank lines and formatting elsewhere in the file are kept; they need the `shopper` role when sign-in is on. ### `GET /api/pantry` @@ -895,7 +895,7 @@ Response: Add an item -Creates the section if it does not exist. The response claims success and reports a distinct item was appended even when the section already has an item of that name — but every write round-trips through a TOML serializer that keys each section's items by name, so a second item sharing a name with an existing one in the same section silently replaces it on disk rather than coexisting. Verified: adding `dup` twice with different quantities to a fresh section leaves exactly one `dup` item, with the second call's quantity. +Creates the section if it does not exist. Spaces around each field are dropped. `400` when the section or name is blank, when the section already has an item of that name (use `PUT` to change it), or when the pantry file cannot be read as a pantry — it is never replaced by a new one. | Name | In | Type | Required | Description | |------|----|------|----------|-------------| @@ -930,7 +930,7 @@ Response: Update an item -Only the fields present in the body are changed; omitted fields keep their current values. Returns 404 if the section does not exist. A name that matches nothing inside a valid section does not 404 — that case still rewrites the file (a no-op) and responds `200` with a success message naming the item that was never found. If more than one item in the section shares the target name, only the first one is updated. +Only the fields present in the body are changed; omitted fields keep their current values, and a body with none of them writes nothing. `404` if the section or the item does not exist. | Name | In | Type | Required | Description | |------|----|------|----------|-------------| @@ -960,7 +960,7 @@ Response: Remove an item -The section is deleted too if it becomes empty. Returns 404 if the section does not exist, but — like `PUT` — responds `200` with a success message even when no item in the section actually has that name; nothing is removed and the file is rewritten unchanged. +The section is deleted too if it becomes empty. `404` if the section or the item does not exist. | Name | In | Type | Required | Description | |------|----|------|----------|-------------| @@ -976,6 +976,74 @@ Response: } ``` +### `POST /api/pantry/rename` + +Rename a section + +The section keeps its place in the file, its items and the comments around it. Changing only the case of its own name is allowed. `400` when the new name is blank, is the current one, is already used by another section or by an item above the first section header (ignoring case), or when either name is `general`; `404` if the section does not exist. + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| `section` | body | `string` | yes | The section to rename. | +| `new_name` | body | `string` | yes | Its new name. | + +Request body: + +```json +{ "section": "fridge", "new_name": "Fridge" } +``` + +Response: + +```json +{ + "success": true, + "message": "Renamed fridge to Fridge" +} +``` + +### `GET /api/pantry/raw` + +Read the pantry file as text + +The file exactly as written, with its `revision`. The `content` below is cut short. + +Response: + +```json +{ + "content": "water = \"unlim\"\n\n[fridge]\nmilk = \"2%l\"\n", + "revision": "5f0c2b7d9a41e863" +} +``` + +### `PUT /api/pantry/raw` + +Replace the pantry file + +Writes `content` as the whole file once it reads as a pantry, and answers as `GET /api/pantry/raw` does. Otherwise `400` quoting the parser, with the line, and the file is left alone. Send the `revision` the text was edited from and the change is refused with `409` and the current `revision` if the file has changed since; leave it out to replace the file regardless. + +| 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": "[fridge]\nmilk = \"1%l\"\nmilk = \"2%l\"\n" +} +``` + +Response: + +```json +{ + "error": "Not a valid pantry: Error parsing input: TOML parse error: TOML parse error at line 3, column 1 | 3 | milk = \"2%l\" | ^ duplicate key `milk` in table `fridge`" +} +``` + ### `GET /api/pantry/expiring` List items expiring soon diff --git a/locales/de-DE/pantry.ftl b/locales/de-DE/pantry.ftl index bd846bb7..9e78d45f 100644 --- a/locales/de-DE/pantry.ftl +++ b/locales/de-DE/pantry.ftl @@ -8,19 +8,22 @@ pantry-configure = Vorratskammer konfigurieren → pantry-no-items-section = Keine Artikel in diesem Bereich pantry-show-out-of-stock = Nur nicht vorrätige Artikel anzeigen pantry-no-out-of-stock = Keine nicht vorrätigen Artikel in diesem Bereich +pantry-out-of-stock-count = Nicht vorrätig: %o von %t pantry-total-sections = Gesamte Bereiche: -pantry-edit-config = Konfiguration bearbeiten +pantry-manage = Vorrat verwalten # Pantry Item Fields pantry-item-name = Artikelname pantry-item-quantity = Menge -pantry-item-quantity-short = Mge: +pantry-item-quantity-label = Menge: pantry-item-bought = Gekauft: pantry-item-bought-date = Kaufdatum pantry-item-expire = Verfällt: pantry-item-expire-date = Verfallsdatum pantry-item-low = Niedrig bei: pantry-item-low-threshold = Mindestbestandsschwelle +pantry-placeholder-quantity = z. B. 500%g oder 2%L +pantry-placeholder-low = z. B. 100%g oder 2 # Pantry Actions pantry-add-item = Artikel hinzufügen @@ -40,7 +43,23 @@ pantry-section-fridge = Kühlschrank pantry-section-pantry = Vorratskammer pantry-section-spices = Gewürze pantry-section-other = Sonstiges +pantry-section-general = Allgemein +pantry-section-new = Neuer Bereich… +pantry-section-new-name = Name des neuen Bereichs +pantry-tab-items = Artikel +pantry-tab-text = Text +pantry-text-intro = Bearbeite die Datei direkt: eine [Abschnitt]-Zeile, dann ein Artikel pro Zeile, geschrieben name = "menge" oder name = {"{"} quantity = "…", bought = "…", expire = "…", low = "…" {"}"}. Zeilen, die mit # beginnen, sind Kommentare. +pantry-text-save = Datei speichern +pantry-text-saved = Gespeichert +pantry-text-conflict = Die Vorratsdatei wurde während deiner Bearbeitung anderswo geändert. Dein Text bleibt erhalten; speichere erneut, um diese Änderung damit zu ersetzen. +pantry-rename-section = Abschnitt umbenennen +pantry-section-name = Name des Abschnitts +pantry-failed-rename = Abschnitt konnte nicht umbenannt werden +pantry-failed-save-file = Datei konnte nicht gespeichert werden pantry-optional = (optional) pantry-failed-add = Artikel konnte nicht hinzugefügt werden +pantry-item-name-required = Gib dem Artikel einen Namen +pantry-item-exists = %s ist schon in %s +pantry-section-name-taken = Es gibt schon einen Bereich oder Artikel namens %s pantry-failed-update = Artikel konnte nicht aktualisiert werden pantry-failed-remove = Artikel konnte nicht entfernt werden diff --git a/locales/en-US/pantry.ftl b/locales/en-US/pantry.ftl index df26a8de..09b2e481 100644 --- a/locales/en-US/pantry.ftl +++ b/locales/en-US/pantry.ftl @@ -8,19 +8,22 @@ pantry-configure = Configure pantry → pantry-no-items-section = No items in this section pantry-show-out-of-stock = Show only out of stock pantry-no-out-of-stock = No out-of-stock items in this section +pantry-out-of-stock-count = Out of stock: %o of %t pantry-total-sections = Total sections: -pantry-edit-config = Edit Configuration +pantry-manage = Manage pantry # Pantry Item Fields pantry-item-name = Item Name pantry-item-quantity = Quantity -pantry-item-quantity-short = Qty: +pantry-item-quantity-label = Quantity: pantry-item-bought = Bought: pantry-item-bought-date = Bought Date pantry-item-expire = Expires: pantry-item-expire-date = Expiry Date pantry-item-low = Low at: pantry-item-low-threshold = Low Stock Threshold +pantry-placeholder-quantity = e.g. 500%g or 2%L +pantry-placeholder-low = e.g. 100%g or 2 # Pantry Actions pantry-add-item = Add Item @@ -40,7 +43,23 @@ pantry-section-fridge = Fridge pantry-section-pantry = Pantry pantry-section-spices = Spices pantry-section-other = Other +pantry-section-general = General +pantry-section-new = New section… +pantry-section-new-name = New section name +pantry-tab-items = Items +pantry-tab-text = Text +pantry-text-intro = Edit the file directly: a [section] line, then one item per line, written name = "quantity" or name = {"{"} quantity = "…", bought = "…", expire = "…", low = "…" {"}"}. Lines starting with # are comments. +pantry-text-save = Save file +pantry-text-saved = Saved +pantry-text-conflict = The pantry file was changed elsewhere while you were editing. Your text is kept; save again to replace that change with it. +pantry-rename-section = Rename section +pantry-section-name = Section name +pantry-failed-rename = Failed to rename section +pantry-failed-save-file = Could not save the file pantry-optional = (optional) pantry-failed-add = Failed to add item +pantry-item-name-required = Give the item a name +pantry-item-exists = %s is already in %s +pantry-section-name-taken = There is already a section or item called %s pantry-failed-update = Failed to update item pantry-failed-remove = Failed to remove item diff --git a/locales/es-ES/pantry.ftl b/locales/es-ES/pantry.ftl index 160db175..0bf25e50 100644 --- a/locales/es-ES/pantry.ftl +++ b/locales/es-ES/pantry.ftl @@ -8,19 +8,22 @@ pantry-configure = Configurar despensa → pantry-no-items-section = No hay artículos en esta sección pantry-show-out-of-stock = Mostrar solo artículos agotados pantry-no-out-of-stock = No hay artículos agotados en esta sección +pantry-out-of-stock-count = Agotados: %o de %t pantry-total-sections = Total de secciones: -pantry-edit-config = Editar configuración +pantry-manage = Gestionar despensa # Pantry Item Fields pantry-item-name = Nombre del artículo pantry-item-quantity = Cantidad -pantry-item-quantity-short = Cant: +pantry-item-quantity-label = Cantidad: pantry-item-bought = Comprado: pantry-item-bought-date = Fecha de compra pantry-item-expire = Expira: pantry-item-expire-date = Fecha de expiración pantry-item-low = Bajo en: pantry-item-low-threshold = Umbral de stock bajo +pantry-placeholder-quantity = p. ej. 500%g o 2%L +pantry-placeholder-low = p. ej. 100%g o 2 # Pantry Actions pantry-add-item = Agregar artículo @@ -40,7 +43,23 @@ pantry-section-fridge = Refrigerador pantry-section-pantry = Despensa pantry-section-spices = Especias pantry-section-other = Otro +pantry-section-general = General +pantry-section-new = Nueva sección… +pantry-section-new-name = Nombre de la nueva sección +pantry-tab-items = Artículos +pantry-tab-text = Texto +pantry-text-intro = Edita el archivo directamente: una línea [sección] y después un artículo por línea, escrito nombre = "cantidad" o nombre = {"{"} quantity = "…", bought = "…", expire = "…", low = "…" {"}"}. Las líneas que empiezan por # son comentarios. +pantry-text-save = Guardar archivo +pantry-text-saved = Guardado +pantry-text-conflict = El archivo de la despensa se modificó en otro lugar mientras editabas. Tu texto se conserva; guarda de nuevo para reemplazar ese cambio. +pantry-rename-section = Renombrar sección +pantry-section-name = Nombre de la sección +pantry-failed-rename = No se pudo renombrar la sección +pantry-failed-save-file = No se pudo guardar el archivo pantry-optional = (opcional) pantry-failed-add = Error al agregar artículo +pantry-item-name-required = Ponle un nombre al artículo +pantry-item-exists = %s ya está en %s +pantry-section-name-taken = Ya hay una sección o un artículo llamado %s pantry-failed-update = Error al actualizar artículo pantry-failed-remove = Error al eliminar artículo diff --git a/locales/eu-ES/pantry.ftl b/locales/eu-ES/pantry.ftl index 9c93124e..44b06b77 100644 --- a/locales/eu-ES/pantry.ftl +++ b/locales/eu-ES/pantry.ftl @@ -8,19 +8,22 @@ pantry-configure = Konfiguratu jaki-tokia → pantry-no-items-section = Ez dago elementurik sail honetan pantry-show-out-of-stock = Erakutsi bukatutako elementuak bakarrik pantry-no-out-of-stock = Ez dago bukatutako elementurik sail honetan +pantry-out-of-stock-count = Bukatuta: %o / %t pantry-total-sections = Sailak guztira: -pantry-edit-config = Editatu konfigurazioa +pantry-manage = Kudeatu jaki-tokia # Pantry Item Fields pantry-item-name = Elementuaren izena pantry-item-quantity = Kopurua -pantry-item-quantity-short = Kop: +pantry-item-quantity-label = Kopurua: pantry-item-bought = Erosita: pantry-item-bought-date = Erosketa data pantry-item-expire = Iraungi: pantry-item-expire-date = Iraungitze data pantry-item-low = Gutxienez: pantry-item-low-threshold = Stock baxuaren atalasea +pantry-placeholder-quantity = adib. 500%g edo 2%L +pantry-placeholder-low = adib. 100%g edo 2 # Pantry Actions pantry-add-item = Gehitu elementua @@ -40,7 +43,23 @@ pantry-section-fridge = Hozkailua pantry-section-pantry = Jaki-tokia pantry-section-spices = Espeziak pantry-section-other = Besteak +pantry-section-general = Orokorra +pantry-section-new = Sail berria… +pantry-section-new-name = Sail berriaren izena +pantry-tab-items = Elementuak +pantry-tab-text = Testua +pantry-text-intro = Editatu fitxategia zuzenean: [atala] lerro bat, eta gero elementu bat lerro bakoitzeko, izena = "kantitatea" edo izena = {"{"} quantity = "…", bought = "…", expire = "…", low = "…" {"}"} idatzita. # ikurrarekin hasten diren lerroak iruzkinak dira. +pantry-text-save = Gorde fitxategia +pantry-text-saved = Gordeta +pantry-text-conflict = Jakitegiko fitxategia beste nonbait aldatu da editatzen ari zinen bitartean. Zure testua gorde da; gorde berriro aldaketa hori ordezkatzeko. +pantry-rename-section = Aldatu atalaren izena +pantry-section-name = Atalaren izena +pantry-failed-rename = Ezin izan da atalaren izena aldatu +pantry-failed-save-file = Ezin izan da fitxategia gorde pantry-optional = (aukerakoa) pantry-failed-add = Errorea elementua gehitzerakoan +pantry-item-name-required = Eman izen bat elementuari +pantry-item-exists = %s %s sailean dago jada +pantry-section-name-taken = Badago jada %s izeneko sail edo elementu bat pantry-failed-update = Errorea elementua eguneratzerakoan pantry-failed-remove = Errorea elementua ezabatzerakoan diff --git a/locales/fr-FR/pantry.ftl b/locales/fr-FR/pantry.ftl index f5f53173..0a459d18 100644 --- a/locales/fr-FR/pantry.ftl +++ b/locales/fr-FR/pantry.ftl @@ -8,19 +8,22 @@ pantry-configure = Configurer le garde-manger → pantry-no-items-section = Aucun article dans cette section pantry-show-out-of-stock = Afficher uniquement les articles en rupture de stock pantry-no-out-of-stock = Aucun article en rupture de stock dans cette section +pantry-out-of-stock-count = En rupture de stock : %o sur %t pantry-total-sections = Total des sections : -pantry-edit-config = Modifier la configuration +pantry-manage = Gérer le garde-manger # Pantry Item Fields pantry-item-name = Nom de l'article pantry-item-quantity = Quantité -pantry-item-quantity-short = Qté : +pantry-item-quantity-label = Quantité : pantry-item-bought = Acheté : pantry-item-bought-date = Date d'achat pantry-item-expire = Expire : pantry-item-expire-date = Date d'expiration pantry-item-low = Bas à : pantry-item-low-threshold = Seuil de stock bas +pantry-placeholder-quantity = ex. 500%g ou 2%L +pantry-placeholder-low = ex. 100%g ou 2 # Pantry Actions pantry-add-item = Ajouter un article @@ -40,7 +43,23 @@ pantry-section-fridge = Réfrigérateur pantry-section-pantry = Garde-manger pantry-section-spices = Épices pantry-section-other = Autre +pantry-section-general = Général +pantry-section-new = Nouvelle section… +pantry-section-new-name = Nom de la nouvelle section +pantry-tab-items = Articles +pantry-tab-text = Texte +pantry-text-intro = Modifiez le fichier directement : une ligne [section], puis un article par ligne, écrit nom = "quantité" ou nom = {"{"} quantity = "…", bought = "…", expire = "…", low = "…" {"}"}. Les lignes commençant par # sont des commentaires. +pantry-text-save = Enregistrer le fichier +pantry-text-saved = Enregistré +pantry-text-conflict = Le fichier du garde-manger a été modifié ailleurs pendant votre saisie. Votre texte est conservé ; enregistrez à nouveau pour remplacer cette modification. +pantry-rename-section = Renommer la section +pantry-section-name = Nom de la section +pantry-failed-rename = Impossible de renommer la section +pantry-failed-save-file = Impossible d'enregistrer le fichier pantry-optional = (optionnel) pantry-failed-add = Échec de l'ajout de l'article +pantry-item-name-required = Donnez un nom à l'article +pantry-item-exists = %s est déjà dans %s +pantry-section-name-taken = Il existe déjà une section ou un article nommé %s pantry-failed-update = Échec de la mise à jour de l'article pantry-failed-remove = Échec de la suppression de l'article diff --git a/locales/it-IT/pantry.ftl b/locales/it-IT/pantry.ftl index a2d013ee..2ef44eec 100644 --- a/locales/it-IT/pantry.ftl +++ b/locales/it-IT/pantry.ftl @@ -8,19 +8,22 @@ pantry-configure = Configura la dispensa → pantry-no-items-section = Nessun articolo in questa sezione pantry-show-out-of-stock = Mostra solo gli esauriti pantry-no-out-of-stock = Nessun articolo esaurito in questa sezione +pantry-out-of-stock-count = Esauriti: %o su %t pantry-total-sections = Sezioni totali: -pantry-edit-config = Modifica configurazione +pantry-manage = Gestisci dispensa # Pantry Item Fields pantry-item-name = Nome dell'articolo pantry-item-quantity = Quantità -pantry-item-quantity-short = Qtà: +pantry-item-quantity-label = Quantità: pantry-item-bought = Acquistato: pantry-item-bought-date = Data di acquisto pantry-item-expire = Scade: pantry-item-expire-date = Data di scadenza pantry-item-low = In esaurimento a: pantry-item-low-threshold = Soglia di scorta minima +pantry-placeholder-quantity = es. 500%g o 2%L +pantry-placeholder-low = es. 100%g o 2 # Pantry Actions pantry-add-item = Aggiungi articolo @@ -40,7 +43,23 @@ pantry-section-fridge = Frigorifero pantry-section-pantry = Dispensa pantry-section-spices = Spezie pantry-section-other = Altro +pantry-section-general = Generale +pantry-section-new = Nuova sezione… +pantry-section-new-name = Nome della nuova sezione +pantry-tab-items = Articoli +pantry-tab-text = Testo +pantry-text-intro = Modifica direttamente il file: una riga [sezione], poi un articolo per riga, scritto nome = "quantità" oppure nome = {"{"} quantity = "…", bought = "…", expire = "…", low = "…" {"}"}. Le righe che iniziano con # sono commenti. +pantry-text-save = Salva file +pantry-text-saved = Salvato +pantry-text-conflict = Il file della dispensa è stato modificato altrove mentre lo stavi modificando. Il tuo testo è conservato; salva di nuovo per sostituire quella modifica. +pantry-rename-section = Rinomina sezione +pantry-section-name = Nome della sezione +pantry-failed-rename = Impossibile rinominare la sezione +pantry-failed-save-file = Impossibile salvare il file pantry-optional = (facoltativo) pantry-failed-add = Impossibile aggiungere l'articolo +pantry-item-name-required = Dai un nome all'articolo +pantry-item-exists = %s è già in %s +pantry-section-name-taken = Esiste già una sezione o un articolo chiamato %s pantry-failed-update = Impossibile aggiornare l'articolo pantry-failed-remove = Impossibile rimuovere l'articolo diff --git a/locales/ja-JP/pantry.ftl b/locales/ja-JP/pantry.ftl index c79888d0..3bfc33de 100644 --- a/locales/ja-JP/pantry.ftl +++ b/locales/ja-JP/pantry.ftl @@ -8,19 +8,22 @@ pantry-configure = 在庫を設定する → pantry-no-items-section = この保管場所には品目がありません pantry-show-out-of-stock = 在庫切れだけを表示 pantry-no-out-of-stock = この保管場所に在庫切れの品目はありません +pantry-out-of-stock-count = 在庫切れ: %t 品中 %o 品 pantry-total-sections = 保管場所の数: -pantry-edit-config = 設定を編集 +pantry-manage = 在庫を管理 # Pantry Item Fields pantry-item-name = 品名 pantry-item-quantity = 数量 -pantry-item-quantity-short = 数量: +pantry-item-quantity-label = 数量: pantry-item-bought = 購入: pantry-item-bought-date = 購入日 pantry-item-expire = 期限: pantry-item-expire-date = 賞味・消費期限 pantry-item-low = 残り少ない目安: pantry-item-low-threshold = 残り少ないとみなす量 +pantry-placeholder-quantity = 例: 500%g、2%L +pantry-placeholder-low = 例: 100%g、2 # Pantry Actions pantry-add-item = 品目を追加 @@ -40,7 +43,23 @@ pantry-section-fridge = 冷蔵庫 pantry-section-pantry = 常温 pantry-section-spices = スパイス pantry-section-other = その他 +pantry-section-general = 全般 +pantry-section-new = 新しい保管場所… +pantry-section-new-name = 新しい保管場所の名前 +pantry-tab-items = アイテム +pantry-tab-text = テキスト +pantry-text-intro = ファイルを直接編集します:[セクション] の行のあとに、1 行に 1 アイテムを 名前 = "数量" または 名前 = {"{"} quantity = "…", bought = "…", expire = "…", low = "…" {"}"} の形で書きます。# で始まる行はコメントです。 +pantry-text-save = ファイルを保存 +pantry-text-saved = 保存しました +pantry-text-conflict = 編集中にパントリーファイルが別の場所で変更されました。入力したテキストは残っています。もう一度保存すると、その変更を置き換えます。 +pantry-rename-section = セクション名を変更 +pantry-section-name = セクション名 +pantry-failed-rename = セクション名を変更できませんでした +pantry-failed-save-file = ファイルを保存できませんでした pantry-optional = 任意 pantry-failed-add = 品目を追加できませんでした +pantry-item-name-required = 品名を入力してください +pantry-item-exists = %s はすでに %s にあります +pantry-section-name-taken = %s という名前の保管場所または品目がすでにあります pantry-failed-update = 品目を更新できませんでした pantry-failed-remove = 品目を削除できませんでした diff --git a/locales/nl-NL/pantry.ftl b/locales/nl-NL/pantry.ftl index 0c954c00..2a9d2526 100644 --- a/locales/nl-NL/pantry.ftl +++ b/locales/nl-NL/pantry.ftl @@ -8,19 +8,22 @@ pantry-configure = Voorraadkast configureren → pantry-no-items-section = Geen artikelen in deze sectie pantry-show-out-of-stock = Alleen niet-voorradige artikelen tonen pantry-no-out-of-stock = Geen niet-voorradige artikelen in deze sectie +pantry-out-of-stock-count = Niet op voorraad: %o van %t pantry-total-sections = Totaal aantal secties: -pantry-edit-config = Configuratie bewerken +pantry-manage = Voorraadkast beheren # Pantry Item Fields pantry-item-name = Artikelnaam pantry-item-quantity = Hoeveelheid -pantry-item-quantity-short = Hvh: +pantry-item-quantity-label = Hoeveelheid: pantry-item-bought = Gekocht: pantry-item-bought-date = Aankoopdatum pantry-item-expire = Verloopt: pantry-item-expire-date = Vervaldatum pantry-item-low = Laag bij: pantry-item-low-threshold = Lage voorraaddrempel +pantry-placeholder-quantity = bijv. 500%g of 2%L +pantry-placeholder-low = bijv. 100%g of 2 # Pantry Actions pantry-add-item = Artikel toevoegen @@ -40,7 +43,23 @@ pantry-section-fridge = Koelkast pantry-section-pantry = Voorraadkast pantry-section-spices = Kruiden pantry-section-other = Overig +pantry-section-general = Algemeen +pantry-section-new = Nieuwe sectie… +pantry-section-new-name = Naam van de nieuwe sectie +pantry-tab-items = Artikelen +pantry-tab-text = Tekst +pantry-text-intro = Bewerk het bestand direct: een [sectie]-regel, daarna één artikel per regel, geschreven als naam = "hoeveelheid" of naam = {"{"} quantity = "…", bought = "…", expire = "…", low = "…" {"}"}. Regels die met # beginnen zijn opmerkingen. +pantry-text-save = Bestand opslaan +pantry-text-saved = Opgeslagen +pantry-text-conflict = Het voorraadbestand is elders gewijzigd terwijl je aan het bewerken was. Je tekst blijft bewaard; sla opnieuw op om die wijziging te vervangen. +pantry-rename-section = Sectie hernoemen +pantry-section-name = Naam van de sectie +pantry-failed-rename = Sectie hernoemen mislukt +pantry-failed-save-file = Bestand opslaan mislukt pantry-optional = (optioneel) pantry-failed-add = Artikel kon niet worden toegevoegd +pantry-item-name-required = Geef het artikel een naam +pantry-item-exists = %s staat al in %s +pantry-section-name-taken = Er is al een sectie of artikel met de naam %s pantry-failed-update = Artikel kon niet worden bijgewerkt pantry-failed-remove = Artikel kon niet worden verwijderd diff --git a/locales/sv-SE/pantry.ftl b/locales/sv-SE/pantry.ftl index f9ae734d..890fa6bb 100644 --- a/locales/sv-SE/pantry.ftl +++ b/locales/sv-SE/pantry.ftl @@ -8,19 +8,22 @@ pantry-configure = Konfigurera skafferi → pantry-no-items-section = Inga produkter i denna sektion pantry-show-out-of-stock = Visa bara utgångna pantry-no-out-of-stock = Inga utgångna produkter i denna sektion +pantry-out-of-stock-count = Slut i lager: %o av %t pantry-total-sections = Totala sektioner: -pantry-edit-config = Redigera konfiguration +pantry-manage = Hantera skafferi # Pantry Item Fields pantry-item-name = Produkt namn pantry-item-quantity = Antal -pantry-item-quantity-short = Ant: +pantry-item-quantity-label = Antal: pantry-item-bought = Köpt: pantry-item-bought-date = Köpt Datum pantry-item-expire = Går ut: pantry-item-expire-date = Utgångsdatum pantry-item-low = Lite vid: pantry-item-low-threshold = Litet Lager Tröskel +pantry-placeholder-quantity = t.ex. 500%g eller 2%L +pantry-placeholder-low = t.ex. 100%g eller 2 # Pantry Actions pantry-add-item = Lägg till produkt @@ -40,7 +43,23 @@ pantry-section-fridge = Kyl pantry-section-pantry = Skafferi pantry-section-spices = Kryddor pantry-section-other = Annat +pantry-section-general = Allmänt +pantry-section-new = Ny sektion… +pantry-section-new-name = Namn på den nya sektionen +pantry-tab-items = Varor +pantry-tab-text = Text +pantry-text-intro = Redigera filen direkt: en [sektion]-rad, sedan en vara per rad, skriven namn = "mängd" eller namn = {"{"} quantity = "…", bought = "…", expire = "…", low = "…" {"}"}. Rader som börjar med # är kommentarer. +pantry-text-save = Spara fil +pantry-text-saved = Sparat +pantry-text-conflict = Skafferifilen ändrades någon annanstans medan du redigerade. Din text finns kvar; spara igen för att ersätta den ändringen. +pantry-rename-section = Byt namn på sektion +pantry-section-name = Sektionens namn +pantry-failed-rename = Det gick inte att byta namn på sektionen +pantry-failed-save-file = Det gick inte att spara filen pantry-optional = (valfritt) pantry-failed-add = Kunde inte lägga till produkt +pantry-item-name-required = Ge produkten ett namn +pantry-item-exists = %s finns redan i %s +pantry-section-name-taken = Det finns redan en sektion eller produkt som heter %s pantry-failed-update = Kunde inte uppdatera produkt pantry-failed-remove = Kunde inte radera produkt diff --git a/src/server/auth/middleware.rs b/src/server/auth/middleware.rs index b8331557..a655fc5d 100644 --- a/src/server/auth/middleware.rs +++ b/src/server/auth/middleware.rs @@ -266,6 +266,8 @@ mod tests { (Method::POST, "/api/pantry/add"), (Method::PUT, "/api/pantry/dairy/milk"), (Method::DELETE, "/api/pantry/dairy/milk"), + (Method::POST, "/api/pantry/rename"), + (Method::PUT, "/api/pantry/raw"), (Method::POST, "/api/aisles"), (Method::POST, "/api/aisles/changes"), (Method::PUT, "/api/aisles/raw"), @@ -355,6 +357,7 @@ mod tests { "/api/recipes/Soup.cook", "/api/shopping_list/items", "/api/pantry", + "/api/pantry/raw", "/api/aisles", "/api/aisles/raw", "/api/aisles/uncategorized", diff --git a/src/server/handlers/mod.rs b/src/server/handlers/mod.rs index f81af297..06b530ac 100644 --- a/src/server/handlers/mod.rs +++ b/src/server/handlers/mod.rs @@ -17,8 +17,9 @@ pub use aisles::{ }; pub use menus::{get_menu, list_menus}; pub use pantry::{ - add_item as add_pantry_item, get_depleted, get_expiring, get_pantry, - remove_item as remove_pantry_item, update_item as update_pantry_item, + add_item as add_pantry_item, get_depleted, get_expiring, get_pantry, get_raw_pantry, + put_raw_pantry, remove_item as remove_pantry_item, rename_section as rename_pantry_section, + update_item as update_pantry_item, }; pub use plans::plan_edit; pub use recipe_image::{recipe_image_delete, recipe_image_get, recipe_image_put}; diff --git a/src/server/handlers/pantry.rs b/src/server/handlers/pantry.rs index f9156475..78e0c0f1 100644 --- a/src/server/handlers/pantry.rs +++ b/src/server/handlers/pantry.rs @@ -1,4 +1,4 @@ -use super::common::json_error; +use super::common::{json_error, ApiError}; use axum::{ extract::{Extension, Json, Path, Query, State}, http::StatusCode, @@ -6,10 +6,15 @@ use axum::{ }; use camino::Utf8PathBuf; use chrono::prelude::*; +use cookcli_core::{ + pantry::{self as core_pantry, PantryContents}, + ConfigSource, CoreError, Outcome, +}; use serde::{Deserialize, Serialize}; use serde_json; use std::sync::Arc; +use crate::server::aisle_file::revision; use crate::server::{activity, AppState}; use crate::web::viewer::Viewer; @@ -110,70 +115,111 @@ pub struct ApiResponse { pub message: String, } +/// Apply a `cookcli-core` change to the server's pantry file. +/// +/// Core edits the file as a TOML document, so the comments and layout written +/// by hand — or on the page's Text tab — survive a change made from the page, +/// and it writes atomically and through a symlink. Changes are made one at a +/// time: two requests that each read the file and write back their own edit +/// would otherwise lose one of them. +async fn change_pantry(state: &AppState, change: F) -> Result<(), ApiError> +where + F: FnOnce(&cookcli_core::Context) -> Result, CoreError> + + Send + + 'static, +{ + let path = get_pantry_path(state)?.clone(); + let _guard = state.pantry_lock.lock().await; + let ctx = + cookcli_core::Context::new(state.base_path.clone()).with_pantry(ConfigSource::Path(path)); + tokio::task::spawn_blocking(move || change(&ctx)) + .await + .map_err(|e| { + tracing::error!("Pantry change did not finish: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json_error("The pantry change did not finish"), + ) + })? + .map(drop) + .map_err(core_error) +} + +/// A core error as the API reports it, without the absolute path some of them +/// carry. +fn core_error(error: CoreError) -> ApiError { + match error { + CoreError::PantryEdit { message } => { + let status = if message.contains(" not found") { + StatusCode::NOT_FOUND + } else { + StatusCode::BAD_REQUEST + }; + (status, json_error(sentence(&message))) + } + CoreError::Config { message, .. } => ( + StatusCode::BAD_REQUEST, + json_error(format!("Not a valid pantry: {message}")), + ), + CoreError::MissingConfig { .. } => ( + StatusCode::NOT_FOUND, + json_error("Pantry configuration not found"), + ), + CoreError::Io { source, .. } if source.kind() == std::io::ErrorKind::NotFound => ( + StatusCode::NOT_FOUND, + json_error("Pantry configuration not found"), + ), + other => { + tracing::error!("Pantry change failed: {other}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json_error("Failed to write the pantry file"), + ) + } + } +} + +/// `message` with its first letter capitalised, for display as it stands. +fn sentence(message: &str) -> String { + let mut chars = message.chars(); + match chars.next() { + Some(first) => first.to_uppercase().chain(chars).collect(), + None => String::new(), + } +} + pub async fn add_item( State(state): State>, Extension(viewer): Extension, Json(item): Json, -) -> Result)> { +) -> Result { let item = item .trimmed() .map_err(|e| (StatusCode::BAD_REQUEST, json_error(e)))?; - let pantry_path = get_pantry_path(&state)?; - let mut pantry_conf = load_pantry(&state).await.unwrap_or_default(); - - // Create new item - let new_item = if item.quantity.is_some() - || item.bought.is_some() - || item.expire.is_some() - || item.low.is_some() - { - cooklang::pantry::PantryItem::WithAttributes(cooklang::pantry::ItemWithAttributes { - name: item.name.clone(), - quantity: item.quantity, - bought: item.bought, - expire: item.expire, - low: item.low, - }) - } else { - cooklang::pantry::PantryItem::Simple(item.name.clone()) + let (section, name) = (item.section.clone(), item.name.clone()); + + let request = core_pantry::AddRequest { + section: item.section, + name: item.name, + quantity: item.quantity, + bought: item.bought, + expire: item.expire, + low: item.low, }; - - // Add item to the specified section - pantry_conf - .sections - .entry(item.section.clone()) - .or_insert_with(Vec::new) - .push(new_item); - - // Rebuild index - pantry_conf.rebuild_index(); - - // Serialize back to regular TOML format (not array format) - let new_content = serialize_pantry_to_regular_toml(&pantry_conf); - - // Write back to file - tokio::fs::write(pantry_path.as_std_path(), new_content) - .await - .map_err(|e| { - tracing::error!("Failed to write pantry file: {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - json_error(format!("Failed to write pantry file: {e}")), - ) - })?; + change_pantry(&state, move |ctx| core_pantry::add(ctx, request)).await?; activity::record( &viewer, format_args!( "added {} to the {} section of the pantry", - activity::quoted(&item.name), - activity::quoted(&item.section) + activity::quoted(&name), + activity::quoted(§ion) ), ); Ok(Json(ApiResponse { success: true, - message: format!("Added {} to {}", item.name, item.section), + message: format!("Added {name} to {section}"), })) } @@ -181,41 +227,12 @@ pub async fn remove_item( State(state): State>, Extension(viewer): Extension, Path((section, name)): Path<(String, String)>, -) -> Result)> { - let pantry_path = get_pantry_path(&state)?; - let mut pantry_conf = load_pantry(&state).await?; - - // Remove item from the specified section - if let Some(items) = pantry_conf.sections.get_mut(§ion) { - items.retain(|item| item.name() != name); - - // Remove section if empty - if items.is_empty() { - pantry_conf.sections.shift_remove(§ion); - } - } else { - return Err(( - StatusCode::NOT_FOUND, - json_error(format!("Section not found: {section}")), - )); - } - - // Rebuild index - pantry_conf.rebuild_index(); - - // Serialize back to regular TOML format (not array format) - let new_content = serialize_pantry_to_regular_toml(&pantry_conf); - - // Write back to file - tokio::fs::write(pantry_path.as_std_path(), new_content) - .await - .map_err(|e| { - tracing::error!("Failed to write pantry file: {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - json_error(format!("Failed to write pantry file: {e}")), - ) - })?; +) -> Result { + let request = core_pantry::RemoveRequest { + section: section.clone(), + name: name.clone(), + }; + change_pantry(&state, move |ctx| core_pantry::remove(ctx, request)).await?; activity::record( &viewer, @@ -237,88 +254,176 @@ pub async fn update_item( Extension(viewer): Extension, Path((section, name)): Path<(String, String)>, Json(update): Json, -) -> Result)> { - let pantry_path = get_pantry_path(&state)?; - let mut pantry_conf = load_pantry(&state).await?; - - // Find and update the item - if let Some(items) = pantry_conf.sections.get_mut(§ion) { - for item in items.iter_mut() { - if item.name() == name { - // Convert to WithAttributes if needed - let updated_item = match item { - cooklang::pantry::PantryItem::Simple(item_name) => { - if update.quantity.is_some() - || update.bought.is_some() - || update.expire.is_some() - || update.low.is_some() - { - cooklang::pantry::PantryItem::WithAttributes( - cooklang::pantry::ItemWithAttributes { - name: item_name.clone(), - quantity: update.quantity.clone(), - bought: update.bought.clone(), - expire: update.expire.clone(), - low: update.low, - }, - ) - } else { - cooklang::pantry::PantryItem::Simple(item_name.clone()) - } - } - cooklang::pantry::PantryItem::WithAttributes(attrs) => { - cooklang::pantry::PantryItem::WithAttributes( - cooklang::pantry::ItemWithAttributes { - name: attrs.name.clone(), - quantity: update.quantity.clone().or(attrs.quantity.clone()), - bought: update.bought.clone().or(attrs.bought.clone()), - expire: update.expire.clone().or(attrs.expire.clone()), - low: update.low.clone().or(attrs.low.clone()), - }, - ) - } - }; - *item = updated_item; - break; - } +) -> Result { + // The edit dialog sends only the fields it changed, so saving it untouched + // sends nothing — which is not worth an error, nor a write. + let unchanged = update.quantity.is_none() + && update.bought.is_none() + && update.expire.is_none() + && update.low.is_none(); + if !unchanged { + let request = core_pantry::UpdateRequest { + section: section.clone(), + name: name.clone(), + quantity: update.quantity, + bought: update.bought, + expire: update.expire, + low: update.low, + }; + change_pantry(&state, move |ctx| core_pantry::update(ctx, request)).await?; + + activity::record( + &viewer, + format_args!( + "updated {} in the {} section of the pantry", + activity::quoted(&name), + activity::quoted(§ion) + ), + ); + } + + Ok(Json(ApiResponse { + success: true, + message: format!("Updated {name} in {section}"), + })) +} + +#[derive(Debug, Deserialize)] +pub struct RenamePantrySection { + pub section: String, + pub new_name: String, +} + +/// `POST /api/pantry/rename`: gives a section a new name, keeping its place, +/// its items and the comments around it. +pub async fn rename_section( + State(state): State>, + Extension(viewer): Extension, + Json(rename): Json, +) -> Result, ApiError> { + let section = rename.section; + let new_name = rename.new_name.trim().to_string(); + + let request = core_pantry::RenameSectionRequest { + section: section.clone(), + new_name: new_name.clone(), + }; + change_pantry(&state, move |ctx| core_pantry::rename_section(ctx, request)).await?; + + activity::record( + &viewer, + format_args!( + "renamed the pantry section {} to {}", + activity::quoted(§ion), + activity::quoted(&new_name) + ), + ); + + Ok(Json(ApiResponse { + success: true, + message: format!("Renamed {section} to {new_name}"), + })) +} + +/// The pantry file as written, and the revision a change to it must name. +#[derive(Debug, Serialize)] +pub struct RawPantry { + pub content: String, + pub revision: String, +} + +impl RawPantry { + fn new(content: String) -> Self { + Self { + revision: revision(&content), + content, } - } else { - return Err(( - StatusCode::NOT_FOUND, - json_error(format!("Section not found: {section}")), - )); } +} + +async fn read_raw(state: &AppState) -> Result { + let path = get_pantry_path(state)?; + tokio::fs::read_to_string(path.as_std_path()) + .await + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + return ( + StatusCode::NOT_FOUND, + json_error("Pantry configuration not found"), + ); + } + tracing::error!("Failed to read pantry file {path}: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json_error("Failed to read the pantry file"), + ) + }) +} + +/// `GET /api/pantry/raw`: the file as written. +pub async fn get_raw_pantry( + State(state): State>, +) -> Result, ApiError> { + Ok(Json(RawPantry::new(read_raw(&state).await?))) +} - // Rebuild index - pantry_conf.rebuild_index(); +#[derive(Debug, Deserialize)] +pub struct RawPantryUpdate { + pub content: String, + pub revision: Option, +} - // Serialize back to regular TOML format (not array format) - let new_content = serialize_pantry_to_regular_toml(&pantry_conf); +/// `PUT /api/pantry/raw`: replaces the whole file, once it reads as a pantry. +/// +/// A change made against another version of the file is refused with `409` and +/// the current revision; one sent without a revision replaces whatever is +/// there. +pub async fn put_raw_pantry( + State(state): State>, + Extension(viewer): Extension, + Json(update): Json, +) -> Result, ApiError> { + let path = get_pantry_path(&state)?.clone(); + let _guard = state.pantry_lock.lock().await; + + let current = revision(&read_raw(&state).await?); + if update + .revision + .as_deref() + .is_some_and(|sent| sent != current) + { + return Err(( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": "The pantry file changed since this page loaded it", + "revision": current, + })), + )); + } - // Write back to file - tokio::fs::write(pantry_path.as_std_path(), new_content) + let ctx = cookcli_core::Context::new(state.base_path.clone()) + .with_pantry(ConfigSource::Path(path.clone())); + let content = update.content; + let written = content.clone(); + tokio::task::spawn_blocking(move || core_pantry::replace(&ctx, &written)) .await .map_err(|e| { - tracing::error!("Failed to write pantry file: {e}"); + tracing::error!("Pantry file write did not finish: {e}"); ( StatusCode::INTERNAL_SERVER_ERROR, - json_error(format!("Failed to write pantry file: {e}")), + json_error("The pantry change did not finish"), ) - })?; + })? + .map_err(core_error)?; activity::record( &viewer, format_args!( - "updated {} in the {} section of the pantry", - activity::quoted(&name), - activity::quoted(§ion) + "rewrote the pantry file {}", + activity::file(&state.base_path, &path) ), ); - - Ok(Json(ApiResponse { - success: true, - message: format!("Updated {name} in {section}"), - })) + Ok(Json(RawPantry::new(content))) } pub async fn get_pantry( @@ -432,10 +537,6 @@ pub fn parse_date(date_str: &str) -> Option { None } -fn serialize_pantry_to_regular_toml(pantry_conf: &cooklang::pantry::PantryConf) -> String { - cooklang::pantry::to_toml_string(pantry_conf) -} - #[cfg(test)] mod tests { use super::AddPantryItem; diff --git a/src/server/mod.rs b/src/server/mod.rs index 2d3dd0c9..f440f4f3 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -549,6 +549,7 @@ fn build_state( 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(()), + pantry_lock: tokio::sync::Mutex::new(()), shopping_list_events, #[cfg(feature = "sync")] sync_session: Arc::new(Mutex::new(session)), @@ -622,6 +623,8 @@ pub struct AppState { /// 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<()>, + /// The same for the pantry file. + pub pantry_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. @@ -729,6 +732,11 @@ fn api(_state: &AppState) -> Result>> { .route("/shopping_list/events", get(handlers::shopping_list_events)) .route("/pantry", get(handlers::get_pantry)) .route("/pantry/add", post(handlers::add_pantry_item)) + .route("/pantry/rename", post(handlers::rename_pantry_section)) + .route( + "/pantry/raw", + get(handlers::get_raw_pantry).put(handlers::put_raw_pantry), + ) .route("/pantry/expiring", get(handlers::get_expiring)) .route("/pantry/depleted", get(handlers::get_depleted)) .route( diff --git a/src/web/api_docs.rs b/src/web/api_docs.rs index c4dd2203..23d4d865 100644 --- a/src/web/api_docs.rs +++ b/src/web/api_docs.rs @@ -1601,7 +1601,9 @@ fn pantry() -> ApiSection { "Reads and writes `pantry.conf`, a TOML file of what you already have at home. \ Quantities are typically `VALUE%UNIT`, e.g. `250%g`, but the field is just a string — \ `unlim` and plain counts like `12` appear untouched in the seed data. Every endpoint \ - here returns 404 when no pantry file is configured.", + here returns 404 when no pantry file is configured. Changes edit only the entry \ + concerned, so comments, blank lines and formatting elsewhere in the file are kept; \ + they need the `shopper` role when sign-in is on.", vec![ ep( "GET", @@ -1642,13 +1644,10 @@ fn pantry() -> ApiSection { "POST", "/api/pantry/add", "Add an item", - "Creates the section if it does not exist. The response claims success and \ - reports a distinct item was appended even when the section already has an \ - item of that name — but every write round-trips through a TOML serializer \ - that keys each section's items by name, so a second item sharing a name with \ - an existing one in the same section silently replaces it on disk rather than \ - coexisting. Verified: adding `dup` twice with different quantities to a fresh \ - section leaves exactly one `dup` item, with the second call's quantity.", + "Creates the section if it does not exist. Spaces around each field are \ + dropped. `400` when the section or name is blank, when the section already \ + has an item of that name (use `PUT` to change it), or when the pantry file \ + cannot be read as a pantry — it is never replaced by a new one.", ) .params(vec![ param( @@ -1714,11 +1713,8 @@ fn pantry() -> ApiSection { "/api/pantry/{section}/{name}", "Update an item", "Only the fields present in the body are changed; omitted fields keep their \ - current values. Returns 404 if the section does not exist. A name that \ - matches nothing inside a valid section does not 404 — that case still \ - rewrites the file (a no-op) and responds `200` with a success message \ - naming the item that was never found. If more than one item in the section \ - shares the target name, only the first one is updated.", + current values, and a body with none of them writes nothing. `404` if the \ + section or the item does not exist.", ) .params(vec![ path_param("section", "Section containing the item."), @@ -1745,10 +1741,8 @@ fn pantry() -> ApiSection { "DELETE", "/api/pantry/{section}/{name}", "Remove an item", - "The section is deleted too if it becomes empty. Returns 404 if the section \ - does not exist, but — like `PUT` — responds `200` with a success message even \ - when no item in the section actually has that name; nothing is removed and the \ - file is rewritten unchanged.", + "The section is deleted too if it becomes empty. `404` if the section or the \ + item does not exist.", ) .params(vec![ path_param("section", "Section containing the item."), @@ -1760,6 +1754,82 @@ fn pantry() -> ApiSection { "success": true, "message": "Removed butter from fridge" } +"#, + ), + ep( + "POST", + "/api/pantry/rename", + "Rename a section", + "The section keeps its place in the file, its items and the comments around \ + it. Changing only the case of its own name is allowed. `400` when the new name \ + is blank, is the current one, is already used by another section or by an \ + item above the first section header (ignoring case), or when either name is \ + `general`; `404` if the section does not exist.", + ) + .params(vec![ + param("section", "body", "string", true, "The section to rename."), + param("new_name", "body", "string", true, "Its new name."), + ]) + .request( + r#" +{ "section": "fridge", "new_name": "Fridge" } +"#, + ) + .response( + r#" +{ + "success": true, + "message": "Renamed fridge to Fridge" +} +"#, + ), + ep( + "GET", + "/api/pantry/raw", + "Read the pantry file as text", + "The file exactly as written, with its `revision`. The `content` below is cut \ + short.", + ) + .response( + r#" +{ + "content": "water = \"unlim\"\n\n[fridge]\nmilk = \"2%l\"\n", + "revision": "5f0c2b7d9a41e863" +} +"#, + ), + ep( + "PUT", + "/api/pantry/raw", + "Replace the pantry file", + "Writes `content` as the whole file once it reads as a pantry, and answers as \ + `GET /api/pantry/raw` does. Otherwise `400` quoting the parser, with the line, \ + and the file is left alone. Send the `revision` the text was edited from and \ + the change is refused with `409` and the current `revision` if the file has \ + changed since; leave it out to replace the file regardless.", + ) + .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": "[fridge]\nmilk = \"1%l\"\nmilk = \"2%l\"\n" +} +"#, + ) + .response( + r#" +{ + "error": "Not a valid pantry: Error parsing input: TOML parse error: TOML parse error at line 3, column 1 | 3 | milk = \"2%l\" | ^ duplicate key `milk` in table `fridge`" +} "#, ), ep( diff --git a/templates/pantry.html b/templates/pantry.html index 0c35b366..6a28b250 100644 --- a/templates/pantry.html +++ b/templates/pantry.html @@ -47,11 +47,27 @@

{{ tr.t("pantry-no-config") }} {% else %} +
+ + +
+ +
{% for section in sections %}
-

{{ section.name }}

+
+

{% if section.name == "general" %}{{ tr.t("pantry-section-general") }}{% else %}{{ section.name }}{% endif %}

+ {% if viewer.can_edit_lists() && section.name != "general" %} + + {% endif %} +
{% if section.items.is_empty() %}

{{ tr.t("pantry-no-items-section") }}

{% else %} @@ -72,7 +88,7 @@

{{ item.name }}

- {{ tr.t("pantry-item-quantity-short") }} + {{ tr.t("pantry-item-quantity-label") }} {% if let Some(quantity) = item.quantity %}{{ quantity }}{% else %}-{% endif %}

{{ item.name }}

+ + {% endif %} @@ -181,30 +218,35 @@

{{ tr.t("pantry-add-pantry-item" {% else %} {% for section in sections %} - + {% endfor %} {% endif %} + +
- +
- +
- +
- +
@@ -222,7 +264,8 @@

{{ tr.t("pantry-add-pantry-item" const outOfStockItems = document.querySelectorAll('.pantry-item.out-of-stock').length; const outOfStockCountSpan = document.getElementById('out-of-stock-count'); if (outOfStockCountSpan) { - outOfStockCountSpan.textContent = `(${outOfStockItems} out of stock / ${totalItems} total)`; + outOfStockCountSpan.textContent = {{ tr.t("pantry-out-of-stock-count")|json|safe }} + .replace('%o', outOfStockItems).replace('%t', totalItems); } } @@ -354,13 +397,39 @@

{{ tr.t("pantry-add-pantry-item" }); } + const addSection = document.getElementById('add-section'); + if (addSection) { + addSection.addEventListener('change', toggleNewSectionField); + } + if (addForm) { addForm.addEventListener('submit', async function(e) { e.preventDefault(); - + + const section = addSection.value === '__new__' + ? document.getElementById('add-new-section').value.trim() + : addSection.value.trim(); + if (!section) { + showPantryError({{ tr.t("pantry-section-new-name")|json|safe }}); + return; + } + const itemName = document.getElementById('add-name').value.trim(); + if (!itemName) { + showPantryError({{ tr.t("pantry-item-name-required")|json|safe }}); + return; + } + // The server refuses a second item of the same name in a section; + // say so here, in the page's language. + const taken = [...document.querySelectorAll('.pantry-item')] + .some(item => item.dataset.section === section && item.dataset.name === itemName); + if (taken) { + showPantryError({{ tr.t("pantry-item-exists")|json|safe }}.replace('%s', itemName).replace('%s', sectionLabel(section))); + return; + } + const data = { - section: document.getElementById('add-section').value.trim(), - name: document.getElementById('add-name').value.trim(), + section: section, + name: itemName, quantity: document.getElementById('add-quantity').value.trim() || null, bought: document.getElementById('add-bought').value.trim() || null, expire: document.getElementById('add-expire').value.trim() || null, @@ -383,7 +452,7 @@

{{ tr.t("pantry-add-pantry-item" showPantryError(data.error || {{ tr.t("pantry-failed-add")|json|safe }}); } } catch (error) { - showPantryError('Error adding item: ' + error); + showPantryError({{ tr.t("pantry-failed-add")|json|safe }} + ': ' + error); } }); } @@ -403,6 +472,7 @@

{{ tr.t("pantry-add-pantry-item" if (editBtn) { editBtn.addEventListener('click', function(e) { e.stopPropagation(); + item.querySelectorAll('.pantry-date').forEach(showDate); displayDiv.classList.add('hidden'); editDiv.classList.remove('hidden'); }); @@ -428,8 +498,6 @@

{{ tr.t("pantry-add-pantry-item" // Get original values from display const originalQuantity = item.querySelector('.item-quantity').textContent; - const originalBought = item.querySelector('.item-bought').textContent; - const originalExpire = item.querySelector('.item-expire').textContent; const originalLow = item.querySelector('.item-low').textContent; // Only include fields that have changed @@ -441,16 +509,11 @@

{{ tr.t("pantry-add-pantry-item" data.quantity = newQuantity || null; } - const newBought = boughtInput.value.trim(); - const displayBought = originalBought === '-' ? '' : originalBought; - if (newBought !== displayBought) { - data.bought = newBought || null; + if (dateChanged(boughtInput)) { + data.bought = boughtInput.value.trim() || null; } - - const newExpire = expireInput.value.trim(); - const displayExpire = originalExpire === '-' ? '' : originalExpire; - if (newExpire !== displayExpire) { - data.expire = newExpire || null; + if (dateChanged(expireInput)) { + data.expire = expireInput.value.trim() || null; } const newLow = lowInput.value.trim(); @@ -460,7 +523,7 @@

{{ tr.t("pantry-add-pantry-item" } try { - const response = await fetch(`{{ prefix }}/api/pantry/${section}/${encodeURIComponent(name)}`, { + const response = await fetch(`{{ prefix }}/api/pantry/${encodeURIComponent(section)}/${encodeURIComponent(name)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' @@ -476,9 +539,11 @@

{{ tr.t("pantry-add-pantry-item" } if (data.hasOwnProperty('bought')) { item.querySelector('.item-bought').textContent = data.bought || '-'; + boughtInput.dataset.raw = data.bought || ''; } if (data.hasOwnProperty('expire')) { item.querySelector('.item-expire').textContent = data.expire || '-'; + expireInput.dataset.raw = data.expire || ''; } if (data.hasOwnProperty('low')) { item.querySelector('.item-low').textContent = data.low || '-'; @@ -495,7 +560,7 @@

{{ tr.t("pantry-add-pantry-item" showPantryError(data.error || {{ tr.t("pantry-failed-update")|json|safe }}); } } catch (error) { - showPantryError('Error updating item: ' + error); + showPantryError({{ tr.t("pantry-failed-update")|json|safe }} + ': ' + error); } } @@ -526,7 +591,7 @@

{{ tr.t("pantry-add-pantry-item" if (confirm({{ tr.t("pantry-confirm-remove-template")|json|safe }}.replace('%s', name).replace('%s', section))) { try { - const response = await fetch(`{{ prefix }}/api/pantry/${section}/${encodeURIComponent(name)}`, { + const response = await fetch(`{{ prefix }}/api/pantry/${encodeURIComponent(section)}/${encodeURIComponent(name)}`, { method: 'DELETE' }); @@ -537,8 +602,8 @@

{{ tr.t("pantry-add-pantry-item" setTimeout(() => { item.remove(); // Check if section is now empty and reload if needed - const sectionContainer = document.querySelector(`[data-section="${section}"]`); - if (sectionContainer && !document.querySelector(`.pantry-item[data-section="${section}"]`)) { + const sectionContainer = document.querySelector(`[data-section="${CSS.escape(section)}"]`); + if (sectionContainer && !document.querySelector(`.pantry-item[data-section="${CSS.escape(section)}"]`)) { window.location.reload(); } }, 200); @@ -547,7 +612,7 @@

{{ tr.t("pantry-add-pantry-item" showPantryError(data.error || {{ tr.t("pantry-failed-remove")|json|safe }}); } } catch (error) { - showPantryError('Error removing item: ' + error); + showPantryError({{ tr.t("pantry-failed-remove")|json|safe }} + ': ' + error); } } }); @@ -560,6 +625,70 @@

{{ tr.t("pantry-add-pantry-item" addModal.classList.add('hidden'); addModal.classList.remove('flex'); document.getElementById('add-form').reset(); + toggleNewSectionField(); +} + +// "New section…" in the section list reveals a field to name it. +function toggleNewSectionField() { + const isNew = document.getElementById('add-section').value === '__new__'; + const input = document.getElementById('add-new-section'); + document.getElementById('add-new-section-field').classList.toggle('hidden', !isNew); + input.required = isNew; + if (isNew) { + input.focus(); + } +} + +// How a section is named on the page: `general`, the items above the first +// [header], under its translated name, every other one as the file writes it. +function sectionLabel(section) { + return section === 'general' ? {{ tr.t("pantry-section-general")|json|safe }} : section; +} + +// ---- Dates ------------------------------------------------------------------- + +// The spellings the pantry reads as dates, tried in the same order as +// `DATE_FORMATS` in cookcli-core, so the picker shows the day the server sees. +const DATE_SPELLINGS = [ + [/^(\d{4})-(\d{1,2})-(\d{1,2})$/, 'ymd'], + [/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/, 'dmy'], + [/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, 'dmy'], + [/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, 'mdy'], + [/^(\d{4})\.(\d{1,2})\.(\d{1,2})$/, 'ymd'], + [/^(\d{1,2})-(\d{1,2})-(\d{4})$/, 'dmy'], +]; + +// `text` as the YYYY-MM-DD a date input takes, or null if it is no date. +function toIsoDate(text) { + for (const [pattern, order] of DATE_SPELLINGS) { + const m = pattern.exec(text.trim()); + if (!m) continue; + const [y, mo, d] = order === 'ymd' ? [m[1], m[2], m[3]] + : order === 'dmy' ? [m[3], m[2], m[1]] + : [m[3], m[1], m[2]]; + const date = new Date(Date.UTC(+y, +mo - 1, +d)); + if (date.getUTCFullYear() === +y && date.getUTCMonth() === +mo - 1 && date.getUTCDate() === +d) { + return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`; + } + } + return null; +} + +// Fill a date field from the value the file holds (`data-raw`). One that is +// not a date stays a text field showing it, so opening the editor loses nothing. +function showDate(input) { + const raw = input.dataset.raw || ''; + const iso = raw ? toIsoDate(raw) : ''; + input.type = iso === null ? 'text' : 'date'; + input.value = iso === null ? raw : iso; +} + +// Whether a date field now says something other than the file, so an item +// whose date is untouched keeps the spelling the file uses. +function dateChanged(input) { + const raw = input.dataset.raw || ''; + const before = input.type === 'date' ? (toIsoDate(raw) || '') : raw; + return input.value.trim() !== before; } function showPantryError(message) { @@ -568,5 +697,168 @@

{{ tr.t("pantry-add-pantry-item" banner.classList.remove('hidden'); banner.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } + +// ---- Section rename --------------------------------------------------------- + +// The pencil beside a section's name swaps the heading for a field holding +// its name as written in the file. +document.addEventListener('click', function(e) { + const button = e.target.closest('.rename-section-btn'); + if (!button) return; + const head = button.closest('.pantry-section-head'); + const section = head.dataset.section; + const shown = [...head.children]; + + const input = document.createElement('input'); + input.type = 'text'; + input.value = section; + input.setAttribute('aria-label', {{ tr.t("pantry-section-name")|json|safe }}); + input.className = 'flex-1 min-w-[10rem] h-8 px-2 text-sm border border-line-strong rounded-[var(--radius-control)] bg-surface text-text focus:outline-none focus:border-accent'; + + const cancel = document.createElement('button'); + cancel.type = 'button'; + cancel.className = 'btn btn-sm'; + cancel.textContent = {{ tr.t("pantry-cancel")|json|safe }}; + + const save = document.createElement('button'); + save.type = 'button'; + save.className = 'btn btn-primary btn-sm'; + save.textContent = {{ tr.t("pantry-save")|json|safe }}; + + const form = document.createElement('div'); + form.className = 'flex flex-wrap items-center gap-2 w-full'; + form.append(input, cancel, save); + + const restore = () => head.replaceChildren(...shown); + const submit = async () => { + const newName = input.value.trim(); + if (newName === section) { + restore(); + return; + } + // Another section, or an item above the first [header], already + // answering to this name ignoring case: the server would refuse it. + const lower = newName.toLowerCase(); + const taken = [...document.querySelectorAll('.pantry-section-head')] + .map(other => other.dataset.section) + .filter(name => name !== 'general') + .concat([...document.querySelectorAll('.pantry-item[data-section="general"]')].map(item => item.dataset.name)) + .find(name => name !== section && name.toLowerCase() === lower); + if (taken !== undefined) { + showPantryError({{ tr.t("pantry-section-name-taken")|json|safe }}.replace('%s', taken)); + return; + } + try { + const response = await fetch('{{ prefix }}/api/pantry/rename', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ section, new_name: input.value }), + }); + if (response.ok) { + window.location.reload(); + return; + } + const data = await response.json().catch(() => ({})); + showPantryError(data.error || {{ tr.t("pantry-failed-rename")|json|safe }}); + } catch (error) { + showPantryError({{ tr.t("pantry-failed-rename")|json|safe }} + ': ' + error); + } + }; + cancel.addEventListener('click', restore); + save.addEventListener('click', submit); + input.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { e.preventDefault(); submit(); } + if (e.key === 'Escape') restore(); + }); + + head.replaceChildren(form); + input.focus(); + input.select(); +}); + +// ---- Items and Text tabs ----------------------------------------------------- + +(function() { + const textarea = document.getElementById('pantry-text'); + if (!textarea) return; + const T = { + saved: {{ tr.t("pantry-text-saved")|json|safe }}, + conflict: {{ tr.t("pantry-text-conflict")|json|safe }}, + failed: {{ tr.t("pantry-failed-save-file")|json|safe }}, + }; + // Set once the file is saved from the Text tab, so going back to Items + // shows the pantry the file now describes. + let itemsStale = false; + + async function loadText() { + try { + const response = await fetch('{{ prefix }}/api/pantry/raw'); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || T.failed); + textarea.value = data.content; + textarea.dataset.revision = data.revision; + delete textarea.dataset.dirty; + } catch (error) { + showPantryError(error.message); + } + } + + function selectTab(tab) { + if (tab === 'items' && itemsStale) { + window.location.href = window.location.pathname; + return; + } + for (const button of document.querySelectorAll('[role="tab"]')) { + const selected = button.dataset.tab === tab; + button.classList.toggle('active', selected); + button.setAttribute('aria-selected', selected); + button.tabIndex = selected ? 0 : -1; + document.getElementById(button.getAttribute('aria-controls')).classList.toggle('hidden', !selected); + } + if (tab === 'text' && !textarea.dataset.revision) loadText(); + try { history.replaceState(null, '', tab === 'items' ? location.pathname : '#' + tab); } catch (_) { /* ignore */ } + } + + for (const button of document.querySelectorAll('[role="tab"]')) { + button.addEventListener('click', () => selectTab(button.dataset.tab)); + } + + textarea.addEventListener('input', () => { textarea.dataset.dirty = '1'; }); + + const saveText = document.getElementById('pantry-text-save'); + if (saveText) { + saveText.addEventListener('click', async () => { + const status = document.getElementById('pantry-text-status'); + document.getElementById('pantry-error-banner').classList.add('hidden'); + status.textContent = ''; + try { + const response = await fetch('{{ prefix }}/api/pantry/raw', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: textarea.value, revision: textarea.dataset.revision }), + }); + const data = await response.json().catch(() => ({})); + if (response.status === 409 && data.revision) { + // Their text stays; saving again replaces the other change. + textarea.dataset.revision = data.revision; + showPantryError(T.conflict); + return; + } + if (!response.ok) throw new Error(data.error || T.failed); + textarea.dataset.revision = data.revision; + delete textarea.dataset.dirty; + itemsStale = true; + status.textContent = T.saved; + } catch (error) { + showPantryError(error.message); + } + }); + } + + // `#text` opens the Text tab, on load or when followed from the page. + const tabFromHash = () => selectTab(location.hash === '#text' ? 'text' : 'items'); + window.addEventListener('hashchange', tabFromHash); + tabFromHash(); +})(); {% endblock %} diff --git a/templates/preferences.html b/templates/preferences.html index e145017d..c85f44c3 100644 --- a/templates/preferences.html +++ b/templates/preferences.html @@ -132,6 +132,9 @@

Configuration Files

{{ tr.t("pref-pantry-path") }}: {{ pantry_path }} + {% if !viewer.recipes_only() %} + {{ tr.t("pantry-manage") }} → + {% endif %}
diff --git a/tests/cors_test.rs b/tests/cors_test.rs index 47b0fee0..abb4b5b2 100644 --- a/tests/cors_test.rs +++ b/tests/cors_test.rs @@ -197,10 +197,20 @@ fn header(headers: &HeaderMap, name: &str) -> Option { /// Sends a real (non-preflight) `POST /api/pantry/add`, optionally with an /// `Origin` header, and returns the response. This is what actually exercises /// the write guard — see the module doc comment for why a preflight cannot. +/// A name no other add in this run has used: the pantry refuses a second item +/// of the same name, and these tests are about who may write, not what. +fn unique_item() -> String { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + format!( + "Test Item {}", + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ) +} + async fn post_pantry_add(server: &ServerGuard, origin: Option<&str>) -> Response { let mut req = Client::new() .post(server.url("/api/pantry/add")) - .json(&serde_json::json!({ "section": "Test", "name": "Test Item" })); + .json(&serde_json::json!({ "section": "Test", "name": unique_item() })); if let Some(origin) = origin { req = req.header(ORIGIN, origin); } @@ -368,7 +378,7 @@ async fn a_forwarded_host_header_cannot_fake_same_origin() { ] { let resp = Client::new() .post(server.url("/api/pantry/add")) - .json(&serde_json::json!({ "section": "Test", "name": "Test Item" })) + .json(&serde_json::json!({ "section": "Test", "name": unique_item() })) .header(ORIGIN, "http://evil.test") .header(spoof.0, spoof.1) .send() @@ -831,7 +841,7 @@ impl ServerGuard { async fn post_pantry_add_as(server: &ServerGuard, host: &str, origin: &str) -> Response { Client::new() .post(server.url("/api/pantry/add")) - .json(&serde_json::json!({ "section": "Test", "name": "Test Item" })) + .json(&serde_json::json!({ "section": "Test", "name": unique_item() })) .header(reqwest::header::HOST, host) .header(ORIGIN, origin) .send() diff --git a/tests/e2e/pantry-file.spec.ts b/tests/e2e/pantry-file.spec.ts new file mode 100644 index 00000000..dac0fac4 --- /dev/null +++ b/tests/e2e/pantry-file.spec.ts @@ -0,0 +1,132 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// The seed's pantry file, which the dev server started by Playwright's +// `webServer` reads. Each test changes it through the page and the file is put +// back afterwards. +const PANTRY_FILE = path.resolve(__dirname, '../../seed/config/pantry.conf'); + +test.describe('Pantry file', () => { + test.describe.configure({ mode: 'serial' }); + + let original: string; + + test.beforeAll(() => { + original = fs.readFileSync(PANTRY_FILE, 'utf8'); + }); + + test.afterEach(() => { + fs.writeFileSync(PANTRY_FILE, original); + }); + + const head = (page: Page, section: string) => + page.locator(`.pantry-section-head[data-section="${section}"]`); + + test('renames a section in place', async ({ page }) => { + await page.goto('/pantry'); + + await head(page, 'garden').getByRole('button', { name: 'Rename section' }).click(); + const input = page.getByRole('textbox', { name: 'Section name' }); + await expect(input).toHaveValue('garden'); + await input.fill('herb garden'); + await input.press('Enter'); + + await expect(head(page, 'herb garden')).toHaveCount(1); + await expect(head(page, 'garden')).toHaveCount(0); + const written = fs.readFileSync(PANTRY_FILE, 'utf8'); + expect(written).toBe(original.replace('[garden]', '["herb garden"]')); + }); + + test('refuses a name another section has', async ({ page }) => { + await page.goto('/pantry'); + + await head(page, 'garden').getByRole('button', { name: 'Rename section' }).click(); + const input = page.getByRole('textbox', { name: 'Section name' }); + await input.fill('Fridge'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + + await expect(page.locator('#pantry-error-message')).toContainText('fridge'); + expect(fs.readFileSync(PANTRY_FILE, 'utf8')).toBe(original); + }); + + test('closes a rename that keeps the name, without asking the server', async ({ page }) => { + await page.goto('/pantry'); + + await head(page, 'garden').getByRole('button', { name: 'Rename section' }).click(); + const input = page.getByRole('textbox', { name: 'Section name' }); + await input.fill(' garden '); + await input.press('Enter'); + + await expect(input).toHaveCount(0); + await expect(head(page, 'garden').locator('.pantry-section-title')).toHaveText('garden'); + await expect(page.locator('#pantry-error-banner')).toBeHidden(); + expect(fs.readFileSync(PANTRY_FILE, 'utf8')).toBe(original); + }); + + test('writes a date picked in the Add item dialog', async ({ page }) => { + await page.goto('/pantry'); + + await page.getByRole('button', { name: 'Add Item' }).click(); + await page.locator('#add-section').selectOption('fridge'); + await page.locator('#add-name').fill('cream'); + await page.locator('#add-expire').fill('2026-11-02'); + await page.locator('#add-form').getByRole('button', { name: 'Save' }).click(); + + await expect(page.locator('.pantry-item[data-name="cream"]')).toHaveCount(1); + expect(fs.readFileSync(PANTRY_FILE, 'utf8')).toMatch(/cream = \{[^}]*expire = "2026-11-02"/); + }); + + test('shows a date in the picker and keeps its spelling unless it changes', async ({ page }) => { + fs.writeFileSync(PANTRY_FILE, original.replace('bought = "2026-03-07"', 'bought = "07.03.2026"')); + await page.goto('/pantry'); + + const eggs = page.locator('.pantry-item[data-section="fridge"][data-name="eggs"]'); + await eggs.locator('.edit-btn').click(); + await expect(eggs.locator('.edit-bought')).toHaveValue('2026-03-07'); + await eggs.locator('.edit-quantity').fill('10'); + await eggs.locator('.save-btn').click(); + + await expect(eggs.locator('.item-quantity')).toHaveText('10'); + const written = fs.readFileSync(PANTRY_FILE, 'utf8'); + expect(written).toContain('eggs = { quantity = "10", bought = "07.03.2026" }'); + }); + + test('speaks the page language for its own words', async ({ context, page }) => { + await context.addCookies([{ name: 'lang', value: 'fr-FR', url: 'http://localhost:9080' }]); + await page.goto('/pantry'); + + await expect(head(page, 'general').locator('.pantry-section-title')).toHaveText('Général'); + await expect(head(page, 'garden').locator('.pantry-section-title')).toHaveText('garden'); + await expect(page.locator('#out-of-stock-count')).toHaveText(/^En rupture de stock : \d+ sur \d+$/); + await expect(page.getByText('Qté')).toHaveCount(0); + }); + + test('shows the file as text and refuses text that is not a pantry', async ({ page }) => { + await page.goto('/pantry#text'); + + const text = page.locator('#pantry-text'); + await expect(text).toHaveValue(original); + + await text.fill('[fridge]\nmilk = "1%l"\nmilk = "2%l"\n'); + await page.getByRole('button', { name: 'Save file' }).click(); + await expect(page.locator('#pantry-error-message')).toContainText('line 3'); + expect(fs.readFileSync(PANTRY_FILE, 'utf8')).toBe(original); + }); + + test('saves the text, and the items follow', async ({ page }) => { + await page.goto('/pantry'); + await page.getByRole('tab', { name: 'Text' }).click(); + + const text = page.locator('#pantry-text'); + await expect(text).toHaveValue(original); + const changed = `${original}\n# by hand\n[freezer]\npeas = "1%kg"\n`; + await text.fill(changed); + await page.getByRole('button', { name: 'Save file' }).click(); + await expect(page.locator('#pantry-text-status')).toHaveText('Saved'); + expect(fs.readFileSync(PANTRY_FILE, 'utf8')).toBe(changed); + + await page.getByRole('tab', { name: 'Items' }).click(); + await expect(head(page, 'freezer')).toHaveCount(1); + }); +}); diff --git a/tests/e2e/pantry.spec.ts b/tests/e2e/pantry.spec.ts index a458d563..89bb348d 100644 --- a/tests/e2e/pantry.spec.ts +++ b/tests/e2e/pantry.spec.ts @@ -68,7 +68,7 @@ test.describe('Pantry Management', () => { // Look for add item form const addButton = page.getByRole('button', { name: /Add/i }); - const itemNameInput = page.getByLabel(/Item|Name|Ingredient/i); + const itemNameInput = page.locator('#add-modal').getByLabel(/Item|Name|Ingredient/i); const quantityInput = page.getByLabel(/Quantity|Amount/i); const unitInput = page.getByLabel(/Unit/i); @@ -114,14 +114,14 @@ test.describe('Pantry Management', () => { await editButton.click(); // Edit form should appear - const quantityInput = page.getByLabel(/Quantity|Amount/i); + const quantityInput = firstItem.getByLabel(/Quantity|Amount/i); if (await quantityInput.isVisible()) { await quantityInput.clear(); await quantityInput.fill('200'); // Save changes - const saveButton = page.getByRole('button', { name: /Save/i }); + const saveButton = firstItem.getByRole('button', { name: /Save/i }); if (await saveButton.isVisible()) { await saveButton.click(); diff --git a/tests/pantry_api_test.rs b/tests/pantry_api_test.rs new file mode 100644 index 00000000..8ab9bb70 --- /dev/null +++ b/tests/pantry_api_test.rs @@ -0,0 +1,372 @@ +//! End-to-end tests for changing `pantry.conf` from the web UI. +//! +//! Every change edits only the entry concerned, so the comments and layout +//! someone wrote by hand — or on the page's Text tab — survive it. A section +//! can be renamed in place, and the whole file can be read and replaced as +//! text, once it reads as a pantry and was not changed in the meantime. + +#![cfg(feature = "server")] + +use reqwest::StatusCode; +use serde_json::{json, Value}; +use std::net::TcpListener; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; +use tempfile::TempDir; + +#[path = "common/mod.rs"] +mod common; + +const PANTRY: &str = "\ +# What is at home +water = \"unlim\" + +# cold things +[fridge] +milk = \"1%l\" # semi-skimmed +eggs = { quantity = \"6\", expire = \"2027-01-01\" } + +[shelf] +rice = \"1%kg\" +"; + +/// Kills the spawned server when the test ends, pass or panic. +struct ServerGuard { + child: Child, + port: u16, + dir: TempDir, +} + +impl ServerGuard { + fn url(&self, path: &str) -> String { + format!("http://127.0.0.1:{}{}", self.port, path) + } + + fn pantry_file(&self) -> PathBuf { + self.dir.path().join("recipes/config/pantry.conf") + } + + fn pantry_text(&self) -> String { + std::fs::read_to_string(self.pantry_file()).expect("read pantry.conf") + } + + async fn get(&self, path: &str) -> (StatusCode, Value) { + let resp = reqwest::get(self.url(path)).await.expect("GET"); + let status = resp.status(); + (status, resp.json().await.expect("JSON body")) + } + + async fn send(&self, method: reqwest::Method, path: &str, body: Value) -> (StatusCode, Value) { + let resp = reqwest::Client::new() + .request(method, self.url(path)) + .json(&body) + .send() + .await + .expect("request"); + let status = resp.status(); + (status, resp.json().await.unwrap_or(Value::Null)) + } + + async fn rename(&self, section: &str, new_name: &str) -> (StatusCode, Value) { + self.send( + reqwest::Method::POST, + "/api/pantry/rename", + json!({ "section": section, "new_name": new_name }), + ) + .await + } +} + +impl Drop for ServerGuard { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn free_port() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port"); + listener.local_addr().expect("local addr").port() +} + +/// `free_port` only reserves a port long enough to learn its number, so with +/// several tests booting servers at once another one can claim it first. The +/// server exits 1 on a bound port, so retry with a fresh one. +async fn start_server(pantry: &str) -> ServerGuard { + for _ in 0..5 { + if let Some(server) = try_start_server(pantry).await { + return server; + } + } + panic!("could not start cook server on a free port after 5 attempts"); +} + +async fn try_start_server(pantry: &str) -> Option { + let dir = TempDir::new().expect("temp dir"); + let recipes = dir.path().join("recipes"); + std::fs::create_dir_all(recipes.join("config")).unwrap(); + std::fs::write(recipes.join("config/pantry.conf"), pantry).unwrap(); + + let port = free_port(); + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin("cook")); + cmd.arg("server") + .arg(&recipes) + .arg("--port") + .arg(port.to_string()); + let child = common::with_isolated_config(&mut cmd, dir.path()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn cook server"); + + let mut guard = ServerGuard { child, port, dir }; + + let client = reqwest::Client::new(); + let url = guard.url("/api/recipes"); + for _ in 0..200 { + if guard.child.try_wait().expect("poll server").is_some() { + // Port was taken between reserving and binding it. + return None; + } + if let Ok(resp) = client.get(&url).send().await { + if resp.status().is_success() { + return Some(guard); + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + panic!("cook server on port {port} never became ready"); +} + +#[tokio::test] +async fn a_renamed_section_keeps_its_place_items_and_comments() { + let server = start_server(PANTRY).await; + + let (status, body) = server.rename("fridge", " Fridge door ").await; + assert_eq!(status, StatusCode::OK, "{body}"); + + assert_eq!( + server.pantry_text(), + PANTRY.replace("[fridge]", "[\"Fridge door\"]") + ); + let (_, pantry) = server.get("/api/pantry").await; + assert_eq!(pantry["Fridge door"][0]["name"], "milk", "{pantry}"); +} + +#[tokio::test] +async fn a_rename_that_cannot_be_made_is_refused_and_writes_nothing() { + let server = start_server(PANTRY).await; + + for (section, new_name, expected) in [ + ("fridge", "Shelf", StatusCode::BAD_REQUEST), + ("fridge", "WATER", StatusCode::BAD_REQUEST), + ("fridge", " ", StatusCode::BAD_REQUEST), + ("general", "top", StatusCode::BAD_REQUEST), + ("fridge", "general", StatusCode::BAD_REQUEST), + ("freezer", "cold", StatusCode::NOT_FOUND), + ] { + let (status, body) = server.rename(section, new_name).await; + assert_eq!(status, expected, "{section} → {new_name}: {body}"); + assert!(body["error"].is_string(), "{body}"); + } + assert_eq!(server.pantry_text(), PANTRY, "nothing may be written"); +} + +/// What the page changes item by item leaves the rest of the file as it was: +/// the comments, the top-level item, the short and long forms. +#[tokio::test] +async fn item_changes_keep_the_rest_of_the_file() { + let server = start_server(PANTRY).await; + + let (status, body) = server + .send( + reqwest::Method::POST, + "/api/pantry/add", + json!({ "section": "shelf", "name": "pasta", "quantity": "500%g" }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let (status, body) = server + .send( + reqwest::Method::PUT, + "/api/pantry/fridge/milk", + json!({ "quantity": "2%l" }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let (status, body) = server + .send(reqwest::Method::DELETE, "/api/pantry/shelf/rice", json!({})) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + + assert_eq!( + server.pantry_text(), + PANTRY + .replace("milk = \"1%l\"", "milk = \"2%l\"") + .replace("rice = \"1%kg\"\n", "pasta = \"500%g\"\n") + ); +} + +#[tokio::test] +async fn an_item_change_that_cannot_be_made_is_refused() { + let server = start_server(PANTRY).await; + + let (status, body) = server + .send( + reqwest::Method::POST, + "/api/pantry/add", + json!({ "section": "fridge", "name": "milk" }), + ) + .await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "an item already there: {body}" + ); + let (status, body) = server + .send( + reqwest::Method::PUT, + "/api/pantry/fridge/butter", + json!({ "quantity": "1" }), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); + let (status, body) = server + .send( + reqwest::Method::DELETE, + "/api/pantry/fridge/butter", + json!({}), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); + + // Saving the edit dialog untouched sends nothing to change. + let (status, body) = server + .send(reqwest::Method::PUT, "/api/pantry/fridge/milk", json!({})) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + + assert_eq!(server.pantry_text(), PANTRY, "nothing may be written"); +} + +/// A pantry the parser cannot read is reported, not replaced by a pantry +/// holding only the new item. +#[tokio::test] +async fn an_unreadable_pantry_is_not_replaced_by_an_add() { + let broken = "[fridge\nmilk = \"1%l\"\n"; + let server = start_server(broken).await; + + let (status, body) = server + .send( + reqwest::Method::POST, + "/api/pantry/add", + json!({ "section": "fridge", "name": "eggs" }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert_eq!(server.pantry_text(), broken); +} + +#[tokio::test] +async fn the_text_round_trips_and_names_no_path() { + let server = start_server(PANTRY).await; + + let (status, raw) = server.get("/api/pantry/raw").await; + assert_eq!(status, StatusCode::OK, "{raw}"); + assert_eq!(raw["content"], PANTRY); + assert!(raw.get("path").is_none(), "no absolute path: {raw}"); + + let text = format!("{PANTRY}\n# added by hand\n[freezer]\npeas = \"1%kg\"\n"); + let (status, saved) = server + .send( + reqwest::Method::PUT, + "/api/pantry/raw", + json!({ "content": text, "revision": raw["revision"] }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{saved}"); + assert_eq!(server.pantry_text(), text); + assert_eq!(saved["content"], text.as_str()); + assert_ne!(saved["revision"], raw["revision"]); + + let (_, pantry) = server.get("/api/pantry").await; + assert_eq!(pantry["freezer"][0]["name"], "peas", "{pantry}"); +} + +#[tokio::test] +async fn the_text_is_saved_only_when_it_reads_as_a_pantry() { + let server = start_server(PANTRY).await; + + let (status, body) = server + .send( + reqwest::Method::PUT, + "/api/pantry/raw", + json!({ "content": "[fridge]\nmilk = \"1%l\"\nmilk = \"2%l\"\n" }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + let error = body["error"].as_str().unwrap(); + assert!(error.contains("line 3"), "{error}"); + assert!( + !error.contains(&*server.dir.path().to_string_lossy()), + "no absolute path: {error}" + ); + assert_eq!(server.pantry_text(), PANTRY); +} + +#[tokio::test] +async fn text_edited_from_an_old_revision_is_refused() { + let server = start_server(PANTRY).await; + let (_, raw) = server.get("/api/pantry/raw").await; + + // Someone else changes the file in the meantime. + let (status, _) = server.rename("shelf", "cupboard").await; + assert_eq!(status, StatusCode::OK); + let changed = server.pantry_text(); + + let (status, body) = server + .send( + reqwest::Method::PUT, + "/api/pantry/raw", + json!({ "content": "[x]\ny = \"1\"\n", "revision": raw["revision"] }), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{body}"); + let (_, now) = server.get("/api/pantry/raw").await; + assert_eq!(body["revision"], now["revision"], "the current revision"); + assert_eq!(server.pantry_text(), changed, "nothing may be written"); +} + +#[tokio::test] +async fn preferences_link_to_the_pantry_page() { + let server = start_server(PANTRY).await; + + let page = reqwest::get(server.url("/preferences")).await.unwrap(); + assert_eq!(page.status(), StatusCode::OK); + assert!(page.text().await.unwrap().contains("href=\"/pantry\"")); +} + +#[tokio::test] +async fn the_items_above_the_first_section_are_listed_under_a_translated_name() { + let server = start_server(PANTRY).await; + let page = |lang: &'static str| { + let url = server.url("/pantry"); + async move { + reqwest::Client::new() + .get(url) + .header("Cookie", format!("lang={lang}")) + .send() + .await + .unwrap() + .text() + .await + .unwrap() + } + }; + + let english = page("en-US").await; + assert!(english.contains(">General

"), "{english}"); + assert!(english.contains(">fridge"), "names stay as written"); + assert!(page("fr-FR").await.contains(">Général")); +}