From e92e28bd88b94687a6abfecf8bd6a69b07c4d18d Mon Sep 17 00:00:00 2001 From: Myst <1592048+LeMyst@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:02:09 +0200 Subject: [PATCH 1/5] fix(pantry): let the Add item form create a new section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since 08233b7 the Section list in the Add item dialog only offers the sections already in pantry.conf once the pantry has an item, so a second section could never be created from the web UI. Add a "New section…" choice that reveals a field for its name. The server already creates a missing section on add. Typed section names can now hold spaces, slashes or accents, so encode the section in the item update/remove URLs and escape it in the selector used after a removal. Closes #628 Co-Authored-By: Claude Opus 5.5 --- locales/de-DE/pantry.ftl | 2 ++ locales/en-US/pantry.ftl | 2 ++ locales/es-ES/pantry.ftl | 2 ++ locales/eu-ES/pantry.ftl | 2 ++ locales/fr-FR/pantry.ftl | 2 ++ locales/it-IT/pantry.ftl | 2 ++ locales/ja-JP/pantry.ftl | 2 ++ locales/nl-NL/pantry.ftl | 2 ++ locales/sv-SE/pantry.ftl | 2 ++ templates/pantry.html | 42 ++++++++++++++++++++++++++++++++++------ 10 files changed, 54 insertions(+), 6 deletions(-) diff --git a/locales/de-DE/pantry.ftl b/locales/de-DE/pantry.ftl index bd846bb7..85eeae76 100644 --- a/locales/de-DE/pantry.ftl +++ b/locales/de-DE/pantry.ftl @@ -40,6 +40,8 @@ pantry-section-fridge = Kühlschrank pantry-section-pantry = Vorratskammer pantry-section-spices = Gewürze pantry-section-other = Sonstiges +pantry-section-new = Neuer Bereich… +pantry-section-new-name = Name des neuen Bereichs pantry-optional = (optional) pantry-failed-add = Artikel konnte nicht hinzugefügt werden pantry-failed-update = Artikel konnte nicht aktualisiert werden diff --git a/locales/en-US/pantry.ftl b/locales/en-US/pantry.ftl index df26a8de..f990fac9 100644 --- a/locales/en-US/pantry.ftl +++ b/locales/en-US/pantry.ftl @@ -40,6 +40,8 @@ pantry-section-fridge = Fridge pantry-section-pantry = Pantry pantry-section-spices = Spices pantry-section-other = Other +pantry-section-new = New section… +pantry-section-new-name = New section name pantry-optional = (optional) pantry-failed-add = Failed to add item pantry-failed-update = Failed to update item diff --git a/locales/es-ES/pantry.ftl b/locales/es-ES/pantry.ftl index 160db175..04b6ccec 100644 --- a/locales/es-ES/pantry.ftl +++ b/locales/es-ES/pantry.ftl @@ -40,6 +40,8 @@ pantry-section-fridge = Refrigerador pantry-section-pantry = Despensa pantry-section-spices = Especias pantry-section-other = Otro +pantry-section-new = Nueva sección… +pantry-section-new-name = Nombre de la nueva sección pantry-optional = (opcional) pantry-failed-add = Error al agregar artículo pantry-failed-update = Error al actualizar artículo diff --git a/locales/eu-ES/pantry.ftl b/locales/eu-ES/pantry.ftl index 9c93124e..4cec3d6a 100644 --- a/locales/eu-ES/pantry.ftl +++ b/locales/eu-ES/pantry.ftl @@ -40,6 +40,8 @@ pantry-section-fridge = Hozkailua pantry-section-pantry = Jaki-tokia pantry-section-spices = Espeziak pantry-section-other = Besteak +pantry-section-new = Sail berria… +pantry-section-new-name = Sail berriaren izena pantry-optional = (aukerakoa) pantry-failed-add = Errorea elementua gehitzerakoan pantry-failed-update = Errorea elementua eguneratzerakoan diff --git a/locales/fr-FR/pantry.ftl b/locales/fr-FR/pantry.ftl index f5f53173..a537a208 100644 --- a/locales/fr-FR/pantry.ftl +++ b/locales/fr-FR/pantry.ftl @@ -40,6 +40,8 @@ pantry-section-fridge = Réfrigérateur pantry-section-pantry = Garde-manger pantry-section-spices = Épices pantry-section-other = Autre +pantry-section-new = Nouvelle section… +pantry-section-new-name = Nom de la nouvelle section pantry-optional = (optionnel) pantry-failed-add = Échec de l'ajout de l'article pantry-failed-update = Échec de la mise à jour de l'article diff --git a/locales/it-IT/pantry.ftl b/locales/it-IT/pantry.ftl index a2d013ee..6482541d 100644 --- a/locales/it-IT/pantry.ftl +++ b/locales/it-IT/pantry.ftl @@ -40,6 +40,8 @@ pantry-section-fridge = Frigorifero pantry-section-pantry = Dispensa pantry-section-spices = Spezie pantry-section-other = Altro +pantry-section-new = Nuova sezione… +pantry-section-new-name = Nome della nuova sezione pantry-optional = (facoltativo) pantry-failed-add = Impossibile aggiungere l'articolo pantry-failed-update = Impossibile aggiornare l'articolo diff --git a/locales/ja-JP/pantry.ftl b/locales/ja-JP/pantry.ftl index c79888d0..885a2c66 100644 --- a/locales/ja-JP/pantry.ftl +++ b/locales/ja-JP/pantry.ftl @@ -40,6 +40,8 @@ pantry-section-fridge = 冷蔵庫 pantry-section-pantry = 常温 pantry-section-spices = スパイス pantry-section-other = その他 +pantry-section-new = 新しい保管場所… +pantry-section-new-name = 新しい保管場所の名前 pantry-optional = 任意 pantry-failed-add = 品目を追加できませんでした pantry-failed-update = 品目を更新できませんでした diff --git a/locales/nl-NL/pantry.ftl b/locales/nl-NL/pantry.ftl index 0c954c00..12c44e69 100644 --- a/locales/nl-NL/pantry.ftl +++ b/locales/nl-NL/pantry.ftl @@ -40,6 +40,8 @@ pantry-section-fridge = Koelkast pantry-section-pantry = Voorraadkast pantry-section-spices = Kruiden pantry-section-other = Overig +pantry-section-new = Nieuwe sectie… +pantry-section-new-name = Naam van de nieuwe sectie pantry-optional = (optioneel) pantry-failed-add = Artikel kon niet worden toegevoegd pantry-failed-update = Artikel kon niet worden bijgewerkt diff --git a/locales/sv-SE/pantry.ftl b/locales/sv-SE/pantry.ftl index f9ae734d..64e5b503 100644 --- a/locales/sv-SE/pantry.ftl +++ b/locales/sv-SE/pantry.ftl @@ -40,6 +40,8 @@ pantry-section-fridge = Kyl pantry-section-pantry = Skafferi pantry-section-spices = Kryddor pantry-section-other = Annat +pantry-section-new = Ny sektion… +pantry-section-new-name = Namn på den nya sektionen pantry-optional = (valfritt) pantry-failed-add = Kunde inte lägga till produkt pantry-failed-update = Kunde inte uppdatera produkt diff --git a/templates/pantry.html b/templates/pantry.html index 0c35b366..0b82dac5 100644 --- a/templates/pantry.html +++ b/templates/pantry.html @@ -184,8 +184,13 @@

{{ tr.t("pantry-add-pantry-item" {% endfor %} {% endif %} + +
@@ -354,12 +359,25 @@

{{ 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 data = { - section: document.getElementById('add-section').value.trim(), + section: section, name: document.getElementById('add-name').value.trim(), quantity: document.getElementById('add-quantity').value.trim() || null, bought: document.getElementById('add-bought').value.trim() || null, @@ -460,7 +478,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' @@ -526,7 +544,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 +555,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); @@ -560,6 +578,18 @@

{{ 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(); + } } function showPantryError(message) { From 06b53ba0efdd0d874bf5c81ecb773d23a91e776b Mon Sep 17 00:00:00 2001 From: Myst <1592048+LeMyst@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:34:19 +0200 Subject: [PATCH 2/5] feat(pantry): rename sections and edit pantry.conf as text The pantry page gets the two things the aisle page has: each section's name can be changed in place (pencil beside it), and an Items / Text tab pair shows pantry.conf as written. Readers see the text read-only; shoppers and up can save it, once it reads as a pantry and was not changed since it was loaded (409 with the current revision otherwise). - core: pantry::rename_section keeps the section's place, items and comments; refuses a blank or unchanged name, `general` on either side, and a name another section or top-level item has (ignoring case). pantry::replace writes a whole file after the same checks every edit reads it with, atomically and through a symlink. - core: an update keeps the comment after the item it changes. - server: add/update/remove now go through cookcli-core's in-place editor instead of re-serialising cooklang's model, so comments and layout written by hand (or on the Text tab) survive changes made from the page, writes are atomic, an unreadable pantry is reported instead of being replaced by a one-item file, a duplicate add is refused and a missing item is a 404. Writes are serialised by a pantry lock. - API: POST /api/pantry/rename, GET/PUT /api/pantry/raw (no absolute path in the response), documented in api_docs and docs/api.md. Co-Authored-By: Claude Opus 5.5 --- crates/core/src/pantry/edit.rs | 128 +++++++++- crates/core/src/pantry/mod.rs | 104 +++++++- crates/core/src/pantry/tests.rs | 110 +++++++++ docs/api.md | 76 +++++- locales/de-DE/pantry.ftl | 10 + locales/en-US/pantry.ftl | 10 + locales/es-ES/pantry.ftl | 10 + locales/eu-ES/pantry.ftl | 10 + locales/fr-FR/pantry.ftl | 10 + locales/it-IT/pantry.ftl | 10 + locales/ja-JP/pantry.ftl | 10 + locales/nl-NL/pantry.ftl | 10 + locales/sv-SE/pantry.ftl | 10 + src/server/auth/middleware.rs | 3 + src/server/handlers/mod.rs | 5 +- src/server/handlers/pantry.rs | 405 ++++++++++++++++++++------------ src/server/mod.rs | 8 + src/web/api_docs.rs | 104 ++++++-- templates/pantry.html | 178 +++++++++++++- tests/cors_test.rs | 16 +- tests/e2e/pantry-file.spec.ts | 80 +++++++ tests/e2e/pantry.spec.ts | 2 +- tests/pantry_api_test.rs | 339 ++++++++++++++++++++++++++ 23 files changed, 1463 insertions(+), 185 deletions(-) create mode 100644 tests/e2e/pantry-file.spec.ts create mode 100644 tests/pantry_api_test.rs diff --git a/crates/core/src/pantry/edit.rs b/crates/core/src/pantry/edit.rs index a1bf32ce..84f185b6 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(()) } @@ -421,6 +468,26 @@ mod tests { /// 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 +587,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 85eeae76..ad3e9129 100644 --- a/locales/de-DE/pantry.ftl +++ b/locales/de-DE/pantry.ftl @@ -42,6 +42,16 @@ pantry-section-spices = Gewürze pantry-section-other = Sonstiges 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-failed-update = Artikel konnte nicht aktualisiert werden diff --git a/locales/en-US/pantry.ftl b/locales/en-US/pantry.ftl index f990fac9..30631b1d 100644 --- a/locales/en-US/pantry.ftl +++ b/locales/en-US/pantry.ftl @@ -42,6 +42,16 @@ pantry-section-spices = Spices pantry-section-other = Other 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-failed-update = Failed to update item diff --git a/locales/es-ES/pantry.ftl b/locales/es-ES/pantry.ftl index 04b6ccec..eaa39306 100644 --- a/locales/es-ES/pantry.ftl +++ b/locales/es-ES/pantry.ftl @@ -42,6 +42,16 @@ pantry-section-spices = Especias pantry-section-other = Otro 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-failed-update = Error al actualizar artículo diff --git a/locales/eu-ES/pantry.ftl b/locales/eu-ES/pantry.ftl index 4cec3d6a..f1c4a23e 100644 --- a/locales/eu-ES/pantry.ftl +++ b/locales/eu-ES/pantry.ftl @@ -42,6 +42,16 @@ pantry-section-spices = Espeziak pantry-section-other = Besteak 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-failed-update = Errorea elementua eguneratzerakoan diff --git a/locales/fr-FR/pantry.ftl b/locales/fr-FR/pantry.ftl index a537a208..a586f3ec 100644 --- a/locales/fr-FR/pantry.ftl +++ b/locales/fr-FR/pantry.ftl @@ -42,6 +42,16 @@ pantry-section-spices = Épices pantry-section-other = Autre 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-failed-update = Échec de la mise à jour de l'article diff --git a/locales/it-IT/pantry.ftl b/locales/it-IT/pantry.ftl index 6482541d..3f84af5b 100644 --- a/locales/it-IT/pantry.ftl +++ b/locales/it-IT/pantry.ftl @@ -42,6 +42,16 @@ pantry-section-spices = Spezie pantry-section-other = Altro 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-failed-update = Impossibile aggiornare l'articolo diff --git a/locales/ja-JP/pantry.ftl b/locales/ja-JP/pantry.ftl index 885a2c66..13d11233 100644 --- a/locales/ja-JP/pantry.ftl +++ b/locales/ja-JP/pantry.ftl @@ -42,6 +42,16 @@ pantry-section-spices = スパイス pantry-section-other = その他 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-failed-update = 品目を更新できませんでした diff --git a/locales/nl-NL/pantry.ftl b/locales/nl-NL/pantry.ftl index 12c44e69..2cf95b6f 100644 --- a/locales/nl-NL/pantry.ftl +++ b/locales/nl-NL/pantry.ftl @@ -42,6 +42,16 @@ pantry-section-spices = Kruiden pantry-section-other = Overig 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-failed-update = Artikel kon niet worden bijgewerkt diff --git a/locales/sv-SE/pantry.ftl b/locales/sv-SE/pantry.ftl index 64e5b503..1dbf19e6 100644 --- a/locales/sv-SE/pantry.ftl +++ b/locales/sv-SE/pantry.ftl @@ -42,6 +42,16 @@ pantry-section-spices = Kryddor pantry-section-other = Annat 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-failed-update = Kunde inte uppdatera 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 0b82dac5..9d8e87e6 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 }}

+
+

{{ section.name }}

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

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

{% else %} @@ -161,6 +177,20 @@

{{ item.name }}

+
+ + {% endif %} @@ -598,5 +628,151 @@

{{ 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 — the heading itself is capitalised. +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 () => { + 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/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..9f12d5b6 --- /dev/null +++ b/tests/e2e/pantry-file.spec.ts @@ -0,0 +1,80 @@ +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('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..04dfd9f0 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); diff --git a/tests/pantry_api_test.rs b/tests/pantry_api_test.rs new file mode 100644 index 00000000..ecb3ed72 --- /dev/null +++ b/tests/pantry_api_test.rs @@ -0,0 +1,339 @@ +//! 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"); +} From 800b6c2abeb6d885b466dd7b81c409b647eca44f Mon Sep 17 00:00:00 2001 From: myst Date: Fri, 9 Oct 2026 13:01:20 +0200 Subject: [PATCH 3/5] feat(pantry): link to the pantry page from Preferences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preferences already offer "Manage aisles →" next to the aisle file; the pantry file now gets the same "Manage pantry →" link, hidden in recipes-only mode like its neighbour. Co-Authored-By: Claude Opus 5.5 --- locales/de-DE/pantry.ftl | 1 + locales/en-US/pantry.ftl | 1 + locales/es-ES/pantry.ftl | 1 + locales/eu-ES/pantry.ftl | 1 + locales/fr-FR/pantry.ftl | 1 + locales/it-IT/pantry.ftl | 1 + locales/ja-JP/pantry.ftl | 1 + locales/nl-NL/pantry.ftl | 1 + locales/sv-SE/pantry.ftl | 1 + templates/preferences.html | 3 +++ tests/pantry_api_test.rs | 9 +++++++++ 11 files changed, 21 insertions(+) diff --git a/locales/de-DE/pantry.ftl b/locales/de-DE/pantry.ftl index ad3e9129..bf7a4861 100644 --- a/locales/de-DE/pantry.ftl +++ b/locales/de-DE/pantry.ftl @@ -10,6 +10,7 @@ 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-total-sections = Gesamte Bereiche: pantry-edit-config = Konfiguration bearbeiten +pantry-manage = Vorrat verwalten # Pantry Item Fields pantry-item-name = Artikelname diff --git a/locales/en-US/pantry.ftl b/locales/en-US/pantry.ftl index 30631b1d..d162bd34 100644 --- a/locales/en-US/pantry.ftl +++ b/locales/en-US/pantry.ftl @@ -10,6 +10,7 @@ pantry-show-out-of-stock = Show only out of stock pantry-no-out-of-stock = No out-of-stock items in this section pantry-total-sections = Total sections: pantry-edit-config = Edit Configuration +pantry-manage = Manage pantry # Pantry Item Fields pantry-item-name = Item Name diff --git a/locales/es-ES/pantry.ftl b/locales/es-ES/pantry.ftl index eaa39306..8d8cff97 100644 --- a/locales/es-ES/pantry.ftl +++ b/locales/es-ES/pantry.ftl @@ -10,6 +10,7 @@ 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-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 diff --git a/locales/eu-ES/pantry.ftl b/locales/eu-ES/pantry.ftl index f1c4a23e..c0d0034a 100644 --- a/locales/eu-ES/pantry.ftl +++ b/locales/eu-ES/pantry.ftl @@ -10,6 +10,7 @@ pantry-show-out-of-stock = Erakutsi bukatutako elementuak bakarrik pantry-no-out-of-stock = Ez dago bukatutako elementurik sail honetan pantry-total-sections = Sailak guztira: pantry-edit-config = Editatu konfigurazioa +pantry-manage = Kudeatu jaki-tokia # Pantry Item Fields pantry-item-name = Elementuaren izena diff --git a/locales/fr-FR/pantry.ftl b/locales/fr-FR/pantry.ftl index a586f3ec..0fcc756a 100644 --- a/locales/fr-FR/pantry.ftl +++ b/locales/fr-FR/pantry.ftl @@ -10,6 +10,7 @@ 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-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 diff --git a/locales/it-IT/pantry.ftl b/locales/it-IT/pantry.ftl index 3f84af5b..f76bf8c2 100644 --- a/locales/it-IT/pantry.ftl +++ b/locales/it-IT/pantry.ftl @@ -10,6 +10,7 @@ pantry-show-out-of-stock = Mostra solo gli esauriti pantry-no-out-of-stock = Nessun articolo esaurito in questa sezione pantry-total-sections = Sezioni totali: pantry-edit-config = Modifica configurazione +pantry-manage = Gestisci dispensa # Pantry Item Fields pantry-item-name = Nome dell'articolo diff --git a/locales/ja-JP/pantry.ftl b/locales/ja-JP/pantry.ftl index 13d11233..9873f3c5 100644 --- a/locales/ja-JP/pantry.ftl +++ b/locales/ja-JP/pantry.ftl @@ -10,6 +10,7 @@ pantry-show-out-of-stock = 在庫切れだけを表示 pantry-no-out-of-stock = この保管場所に在庫切れの品目はありません pantry-total-sections = 保管場所の数: pantry-edit-config = 設定を編集 +pantry-manage = 在庫を管理 # Pantry Item Fields pantry-item-name = 品名 diff --git a/locales/nl-NL/pantry.ftl b/locales/nl-NL/pantry.ftl index 2cf95b6f..6fc6aa17 100644 --- a/locales/nl-NL/pantry.ftl +++ b/locales/nl-NL/pantry.ftl @@ -10,6 +10,7 @@ pantry-show-out-of-stock = Alleen niet-voorradige artikelen tonen pantry-no-out-of-stock = Geen niet-voorradige artikelen in deze sectie pantry-total-sections = Totaal aantal secties: pantry-edit-config = Configuratie bewerken +pantry-manage = Voorraadkast beheren # Pantry Item Fields pantry-item-name = Artikelnaam diff --git a/locales/sv-SE/pantry.ftl b/locales/sv-SE/pantry.ftl index 1dbf19e6..552658aa 100644 --- a/locales/sv-SE/pantry.ftl +++ b/locales/sv-SE/pantry.ftl @@ -10,6 +10,7 @@ pantry-show-out-of-stock = Visa bara utgångna pantry-no-out-of-stock = Inga utgångna produkter i denna sektion pantry-total-sections = Totala sektioner: pantry-edit-config = Redigera konfiguration +pantry-manage = Hantera skafferi # Pantry Item Fields pantry-item-name = Produkt namn 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/pantry_api_test.rs b/tests/pantry_api_test.rs index ecb3ed72..d88a5636 100644 --- a/tests/pantry_api_test.rs +++ b/tests/pantry_api_test.rs @@ -337,3 +337,12 @@ async fn text_edited_from_an_old_revision_is_refused() { 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\"")); +} From faeabe6c97f1f8115ecabdcddc5479cd02ddfed8 Mon Sep 17 00:00:00 2001 From: Myst <1592048+LeMyst@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:35:08 +0200 Subject: [PATCH 4/5] fix(pantry): date pickers, translated words, quieter section rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The items written above the first [header] are listed as "General", translated, and other section names are shown as the file writes them instead of with a forced capital. - Renaming a section to its own name closes the field instead of showing the server's refusal. A name another section or top-level item has is refused by the page, in its language, like an item already in a section. - Bought and Expires are date pickers, in the item editor and the Add item dialog. A date the file spells another way (07.03.2026) opens as that day, is sent only when changed, and one that is no date stays a text field holding it. The editor's fields get labels, since a date field shows no placeholder. - The placeholders, the out-of-stock counter and the error prefixes are translated; "Qty:"/"Qté :" is written in full. - The "Edit Configuration" button, which only led to Preferences, is gone. pantry.spec.ts's edit test looked the quantity field up across the page; with labels it now finds one per item, so it is scoped to the item. Co-Authored-By: Claude Opus 5.5 --- locales/de-DE/pantry.ftl | 10 +- locales/en-US/pantry.ftl | 10 +- locales/es-ES/pantry.ftl | 10 +- locales/eu-ES/pantry.ftl | 10 +- locales/fr-FR/pantry.ftl | 10 +- locales/it-IT/pantry.ftl | 10 +- locales/ja-JP/pantry.ftl | 10 +- locales/nl-NL/pantry.ftl | 10 +- locales/sv-SE/pantry.ftl | 10 +- templates/pantry.html | 166 ++++++++++++++++++++++++++-------- tests/e2e/pantry-file.spec.ts | 52 +++++++++++ tests/e2e/pantry.spec.ts | 4 +- tests/pantry_api_test.rs | 24 +++++ 13 files changed, 276 insertions(+), 60 deletions(-) diff --git a/locales/de-DE/pantry.ftl b/locales/de-DE/pantry.ftl index bf7a4861..9e78d45f 100644 --- a/locales/de-DE/pantry.ftl +++ b/locales/de-DE/pantry.ftl @@ -8,20 +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 @@ -41,6 +43,7 @@ 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 @@ -55,5 +58,8 @@ 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 d162bd34..09b2e481 100644 --- a/locales/en-US/pantry.ftl +++ b/locales/en-US/pantry.ftl @@ -8,20 +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 @@ -41,6 +43,7 @@ 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 @@ -55,5 +58,8 @@ 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 8d8cff97..0bf25e50 100644 --- a/locales/es-ES/pantry.ftl +++ b/locales/es-ES/pantry.ftl @@ -8,20 +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 @@ -41,6 +43,7 @@ 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 @@ -55,5 +58,8 @@ 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 c0d0034a..44b06b77 100644 --- a/locales/eu-ES/pantry.ftl +++ b/locales/eu-ES/pantry.ftl @@ -8,20 +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 @@ -41,6 +43,7 @@ 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 @@ -55,5 +58,8 @@ 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 0fcc756a..0a459d18 100644 --- a/locales/fr-FR/pantry.ftl +++ b/locales/fr-FR/pantry.ftl @@ -8,20 +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 @@ -41,6 +43,7 @@ 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 @@ -55,5 +58,8 @@ 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 f76bf8c2..2ef44eec 100644 --- a/locales/it-IT/pantry.ftl +++ b/locales/it-IT/pantry.ftl @@ -8,20 +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 @@ -41,6 +43,7 @@ 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 @@ -55,5 +58,8 @@ 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 9873f3c5..3bfc33de 100644 --- a/locales/ja-JP/pantry.ftl +++ b/locales/ja-JP/pantry.ftl @@ -8,20 +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 = 品目を追加 @@ -41,6 +43,7 @@ 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 = アイテム @@ -55,5 +58,8 @@ 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 6fc6aa17..2a9d2526 100644 --- a/locales/nl-NL/pantry.ftl +++ b/locales/nl-NL/pantry.ftl @@ -8,20 +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 @@ -41,6 +43,7 @@ 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 @@ -55,5 +58,8 @@ 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 552658aa..890fa6bb 100644 --- a/locales/sv-SE/pantry.ftl +++ b/locales/sv-SE/pantry.ftl @@ -8,20 +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 @@ -41,6 +43,7 @@ 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 @@ -55,5 +58,8 @@ 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/templates/pantry.html b/templates/pantry.html index 9d8e87e6..6a28b250 100644 --- a/templates/pantry.html +++ b/templates/pantry.html @@ -58,7 +58,7 @@

{{ tr.t("pantry-no-config") }}
-

{{ section.name }}

+

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

{% if viewer.can_edit_lists() && section.name != "general" %} @@ -172,9 +182,6 @@

{{ item.name }}

{{ tr.t("pantry-add-item") }} {% endif %} - - {{ tr.t("pantry-edit-config") }} -
@@ -211,7 +218,7 @@

{{ tr.t("pantry-add-pantry-item" {% else %} {% for section in sections %} - + {% endfor %} {% endif %} @@ -227,19 +234,19 @@

{{ tr.t("pantry-add-pantry-item"

- +
- +
- +
- +
@@ -257,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); } } @@ -405,10 +413,23 @@

{{ tr.t("pantry-add-pantry-item" 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: section, - name: document.getElementById('add-name').value.trim(), + 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, @@ -431,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); } }); } @@ -451,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'); }); @@ -476,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 @@ -489,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(); @@ -524,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 || '-'; @@ -543,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); } } @@ -595,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); } } }); @@ -622,6 +639,58 @@

{{ tr.t("pantry-add-pantry-item" } } +// 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) { const banner = document.getElementById('pantry-error-banner'); document.getElementById('pantry-error-message').textContent = message; @@ -632,7 +701,7 @@

{{ tr.t("pantry-add-pantry-item" // ---- Section rename --------------------------------------------------------- // The pencil beside a section's name swaps the heading for a field holding -// its name as written in the file — the heading itself is capitalised. +// its name as written in the file. document.addEventListener('click', function(e) { const button = e.target.closest('.rename-section-btn'); if (!button) return; @@ -662,6 +731,23 @@

{{ tr.t("pantry-add-pantry-item" 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', diff --git a/tests/e2e/pantry-file.spec.ts b/tests/e2e/pantry-file.spec.ts index 9f12d5b6..dac0fac4 100644 --- a/tests/e2e/pantry-file.spec.ts +++ b/tests/e2e/pantry-file.spec.ts @@ -50,6 +50,58 @@ test.describe('Pantry file', () => { 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'); diff --git a/tests/e2e/pantry.spec.ts b/tests/e2e/pantry.spec.ts index 04dfd9f0..89bb348d 100644 --- a/tests/e2e/pantry.spec.ts +++ b/tests/e2e/pantry.spec.ts @@ -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 index d88a5636..8ab9bb70 100644 --- a/tests/pantry_api_test.rs +++ b/tests/pantry_api_test.rs @@ -346,3 +346,27 @@ async fn preferences_link_to_the_pantry_page() { 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

")); +} From d24de07176bdb5c2f2f37bb3959370f57114b823 Mon Sep 17 00:00:00 2001 From: Myst <1592048+LeMyst@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:59:41 +0200 Subject: [PATCH 5/5] fix(pantry): space an added attribute like the ones before it Giving an inline item a new attribute left the space that closed the table on the old last value, so milk = { quantity = "2%l" } became { quantity = "2%l" , expire = "2026-11-02" }. The new key now takes that closing space over, in whichever style the file uses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014AwvwwDUwuzuzbPx3Jzcqx --- crates/core/src/pantry/edit.rs | 41 +++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/core/src/pantry/edit.rs b/crates/core/src/pantry/edit.rs index 84f185b6..fbc4025d 100644 --- a/crates/core/src/pantry/edit.rs +++ b/crates/core/src/pantry/edit.rs @@ -379,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); } } } @@ -466,6 +482,29 @@ 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]