diff --git a/CloudronManifest.json b/CloudronManifest.json index 92c439a..893cc5b 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -4,8 +4,8 @@ "author": "Florian Heinz", "description": "Private Flask app for meals, shopping and gentle food planning", "tagline": "einfach essen planen", - "version": "1.3.2", - "upstreamVersion": "1.3.2", + "version": "1.3.3", + "upstreamVersion": "1.3.3", "healthCheckPath": "/", "httpPort": 8000, "manifestVersion": 2, diff --git a/RELEASE_NOTES_1.3.3.md b/RELEASE_NOTES_1.3.3.md new file mode 100644 index 0000000..4cace55 --- /dev/null +++ b/RELEASE_NOTES_1.3.3.md @@ -0,0 +1,38 @@ +# Nouri 1.3.3 + +Nouri 1.3.3 erweitert die Einkaufsliste um freie Einkaufsartikel, ohne die bestehende Lebensmittel- und Rezeptlogik umzubauen. Der Fokus liegt darauf, Alltagsdinge wie Drogerie, Haushalt oder Garten genauso schnell auf die Liste setzen zu können wie Lebensmittel. + +## Neu in 1.3.3 + +- Die Einkaufssuche kann jetzt Lebensmittel und allgemeine Artikel finden. +- Neue Artikel können direkt aus dem Suchbegriff angelegt werden: + - `Als Lebensmittel anlegen` + - `Als Einkaufsartikel anlegen` +- Einkaufsartikel wie `Blumenerde`, `Deo`, `Insektenschutz` oder `Sonnencreme` werden intern gespeichert. +- Reine Einkaufsartikel bleiben aus Mahlzeiten, Rezeptvorschlägen und Lebensmittel-Details heraus. +- Nicht vorhandene Lebensmittel können aus der Einkaufsliste heraus schnell als unsortiertes Lebensmittel angelegt werden. + +## Einkaufsliste + +- Bereits angelegte Einkaufsartikel erscheinen bei späteren Suchen wieder als Treffer. +- Einkaufshinweise wie `TK`, `Dose`, `frisch` oder andere kurze Notizen funktionieren weiterhin. +- Derselbe Artikel kann mit unterschiedlichen Einkaufshinweisen mehrfach auf der Liste stehen. +- Einkaufsartikel werden auf der Liste als `Einkaufsartikel` markiert und nutzen ein Einkaufswagen-Symbol. +- Beim Abhaken eines Einkaufsartikels wird er als eingekauft markiert, ohne ihn als zuhause vorhandenes Lebensmittel zu behandeln. + +## Daten und Migration + +- Das Items-Schema unterstützt jetzt zusätzlich den internen Typ `shopping`. +- Bestehende Datenbanken werden beim Start migriert, damit der neue Typ auch bei Updates funktioniert. +- Der Index für Items nach Typ und Name wird bei Schema-Upgrades sauber wieder angelegt. + +## Betrieb + +- Cloudron-Version und Upstream-Version stehen jetzt auf `1.3.3`. +- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.3.3` angehoben. +- Der Service Worker nutzt einen neuen Cache-Namen für `1.3.3`. + +## Upgrade-Hinweis + +- Bestehende Lebensmittel, Mahlzeitenideen und Einkaufseinträge bleiben erhalten. +- Nach dem Update können freie Einkaufsartikel direkt unter `Einkauf` über das Suchfeld angelegt werden. diff --git a/nouri/__init__.py b/nouri/__init__.py index 09ca8f6..f2b6321 100644 --- a/nouri/__init__.py +++ b/nouri/__init__.py @@ -93,7 +93,7 @@ def load_app_version(root_dir: Path) -> str: ).strip() if manifest_version: return manifest_version - return "1.3.2" + return "1.3.3" def load_release_url() -> str: diff --git a/nouri/db.py b/nouri/db.py index 339e540..7d6675b 100644 --- a/nouri/db.py +++ b/nouri/db.py @@ -15,7 +15,7 @@ from .constants import ( DEFAULT_CATEGORY_BUILDERS, ) -CURRENT_SCHEMA_VERSION = "1.3.2" +CURRENT_SCHEMA_VERSION = "1.3.3" ANIMAL_HINTS = ( "huhn", @@ -423,6 +423,109 @@ def set_meta(database: sqlite3.Connection, key: str, value: str) -> None: ) +def item_kind_constraint_supports_shopping(database: sqlite3.Connection) -> bool: + row = database.execute( + """ + SELECT sql + FROM sqlite_master + WHERE type = 'table' AND name = 'items' + """ + ).fetchone() + return bool(row and row["sql"] and "'shopping'" in row["sql"]) + + +def migrate_items_kind_constraint(database: sqlite3.Connection) -> None: + if not table_exists(database, "items") or item_kind_constraint_supports_shopping(database): + return + + columns = table_columns(database, "items") + if "kind" not in columns: + return + + foreign_keys_enabled = bool(database.execute("PRAGMA foreign_keys").fetchone()[0]) + if foreign_keys_enabled: + database.execute("PRAGMA foreign_keys = OFF") + try: + database.execute("DROP TABLE IF EXISTS items_new") + database.execute( + """ + CREATE TABLE items_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + household_id INTEGER, + owner_user_id INTEGER, + target_user_id INTEGER, + visibility TEXT NOT NULL DEFAULT 'shared', + kind TEXT NOT NULL CHECK (kind IN ('food', 'meal', 'shopping')), + name TEXT NOT NULL, + category TEXT, + base_type TEXT NOT NULL DEFAULT 'neutral', + flavor_profile TEXT NOT NULL DEFAULT 'neutral', + suggestion_role TEXT NOT NULL DEFAULT 'base', + suggestion_priority TEXT NOT NULL DEFAULT 'normal', + can_be_meal_core INTEGER NOT NULL DEFAULT 0, + meal_type TEXT, + meal_tags TEXT NOT NULL DEFAULT '', + energy_density TEXT NOT NULL DEFAULT 'neutral', + note TEXT, + photo_filename TEXT, + availability_state TEXT NOT NULL DEFAULT 'idea' CHECK (availability_state IN ('idea', 'home', 'archived')), + is_archived INTEGER NOT NULL DEFAULT 0, + is_quick_added INTEGER NOT NULL DEFAULT 0, + created_by INTEGER, + updated_by INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL + ) + """ + ) + target_columns = [ + "id", + "household_id", + "owner_user_id", + "target_user_id", + "visibility", + "kind", + "name", + "category", + "base_type", + "flavor_profile", + "suggestion_role", + "suggestion_priority", + "can_be_meal_core", + "meal_type", + "meal_tags", + "energy_density", + "note", + "photo_filename", + "availability_state", + "is_archived", + "is_quick_added", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + copy_columns = [column for column in target_columns if column in columns] + quoted_columns = ", ".join(copy_columns) + database.execute( + f""" + INSERT INTO items_new ({quoted_columns}) + SELECT {quoted_columns} + FROM items + """ + ) + database.execute("DROP TABLE items") + database.execute("ALTER TABLE items_new RENAME TO items") + finally: + if foreign_keys_enabled: + database.execute("PRAGMA foreign_keys = ON") + + def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: ensure_meta_table(database) database.execute( @@ -739,6 +842,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'") add_column_if_missing(database, "items", "is_archived INTEGER NOT NULL DEFAULT 0") add_column_if_missing(database, "items", "is_quick_added INTEGER NOT NULL DEFAULT 0") + migrate_items_kind_constraint(database) add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT") add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER") add_column_if_missing(database, "shopping_entries", "shopping_note TEXT NOT NULL DEFAULT ''") @@ -821,6 +925,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: WHERE email IS NOT NULL AND email != '' """ ) + database.execute( + """ + CREATE INDEX IF NOT EXISTS idx_items_kind_name + ON items (kind, name) + """ + ) database.execute( """ CREATE INDEX IF NOT EXISTS idx_items_household_visibility diff --git a/nouri/main.py b/nouri/main.py index af8f295..999d2e0 100644 --- a/nouri/main.py +++ b/nouri/main.py @@ -57,7 +57,7 @@ from .constants import ( WEEKDAY_OPTIONS, WEEK_TEMPLATE_NAME_SUGGESTIONS, ) -from .db import get_db +from .db import get_db, infer_food_flavor_profile, infer_food_profile from .images import ( allowed_image_file, save_photo_with_variants, @@ -1005,6 +1005,10 @@ def normalize_shopping_note(value: str | None) -> str: return " ".join((value or "").strip().split())[:80] +def normalize_new_item_name(value: str | None) -> str: + return " ".join((value or "").strip().split())[:120] + + def schedule_shopping_need( *, item_id: int, @@ -1298,8 +1302,8 @@ def fetch_items_by_ids(item_ids: list[int]) -> list[dict]: return [items_by_id[item_id] for item_id in normalized_ids if item_id in items_by_id] -def find_shopping_food_by_name(name: str) -> dict | None: - normalized_name = name.strip().lower() +def find_shopping_item_by_name(name: str) -> dict | None: + normalized_name = normalize_new_item_name(name).lower() if not normalized_name: return None row = get_db().execute( @@ -1317,11 +1321,11 @@ def find_shopping_food_by_name(name: str) -> dict | None: FROM items LEFT JOIN users AS owner ON owner.id = items.owner_user_id LEFT JOIN users AS target ON target.id = items.target_user_id - WHERE items.kind = 'food' + WHERE items.kind IN ('food', 'shopping') AND items.is_archived = 0 AND LOWER(items.name) = ? AND {visible_clause('items')} - ORDER BY LOWER(items.name), items.id + ORDER BY CASE items.kind WHEN 'food' THEN 0 ELSE 1 END, LOWER(items.name), items.id LIMIT 1 """, [normalized_name, *visible_params()], @@ -1331,6 +1335,64 @@ def find_shopping_food_by_name(name: str) -> dict | None: return attach_builder_keys(attach_dayparts(describe_records([row])))[0] +def create_shopping_search_item(name: str, kind: str) -> dict: + normalized_name = normalize_new_item_name(name) + if not normalized_name: + raise ValueError("Bitte gib zuerst einen Namen ein.") + if kind not in {"food", "shopping"}: + raise ValueError("Bitte wähle aus, ob es ein Lebensmittel oder ein Einkaufsartikel ist.") + + existing = find_shopping_item_by_name(normalized_name) + if existing is not None: + return existing + + if kind == "food": + profile = infer_food_profile(normalized_name, "Unsortiert", "neutral") + category = "Unsortiert" + note = "Aus der Einkaufssuche angelegt. Details später ergänzen." + is_quick_added = 1 + else: + profile = { + "base_type": "neutral", + "suggestion_role": "cooking", + "suggestion_priority": "never", + "can_be_meal_core": 0, + } + category = "Einkaufsartikel" + note = "Einkaufsartikel ohne Rezeptlogik." + is_quick_added = 0 + + cursor = get_db().execute( + """ + INSERT INTO items ( + household_id, owner_user_id, visibility, kind, name, category, + base_type, flavor_profile, suggestion_role, suggestion_priority, + can_be_meal_core, energy_density, availability_state, note, + is_quick_added, created_by, updated_by + ) + VALUES (?, ?, 'shared', ?, ?, ?, ?, ?, ?, ?, ?, 'neutral', 'idea', ?, ?, ?, ?) + """, + ( + current_household_id(), + g.user["id"], + kind, + normalized_name, + category, + profile["base_type"], + infer_food_flavor_profile(normalized_name, category, profile["base_type"], profile["suggestion_role"]), + profile["suggestion_role"], + profile["suggestion_priority"], + profile["can_be_meal_core"], + note, + is_quick_added, + g.user["id"], + g.user["id"], + ), + ) + get_db().commit() + return get_item(int(cursor.lastrowid)) + + def fetch_shopping_entries(): rows = get_db().execute( f""" @@ -4390,18 +4452,19 @@ def mark_shopping_entry_checked(entry_id: int) -> dict: "UPDATE shopping_entries SET is_checked = 1, checked_at = CURRENT_TIMESTAMP, checked_by = ? WHERE id = ?", (g.user["id"], entry_id), ) - get_db().execute( - """ - UPDATE items - SET availability_state = 'home', - is_archived = 0, - is_quick_added = 0, - updated_by = ?, - updated_at = CURRENT_TIMESTAMP - WHERE id = ? - """, - (g.user["id"], item["id"]), - ) + if item["kind"] != "shopping": + get_db().execute( + """ + UPDATE items + SET availability_state = 'home', + is_archived = 0, + is_quick_added = 0, + updated_by = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (g.user["id"], item["id"]), + ) get_db().commit() return item @@ -4495,7 +4558,10 @@ def item_mark_bought(item_id: int): except (ValueError, PermissionError) as exc: flash(str(exc), "error") return redirect(request.referrer or url_for("main.shopping_list")) - flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success") + if item["kind"] == "shopping": + flash(f"{item['name']} wurde als eingekauft markiert.", "success") + else: + flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success") return redirect(request.referrer or url_for("main.shopping_list")) @@ -4528,17 +4594,24 @@ def shopping_list(): if request.method == "POST": selected_item_id = request.form.get("item_id", "").strip() item_search = request.form.get("item_search", "").strip() + create_as = request.form.get("create_as", "").strip() + create_item_name = normalize_new_item_name(request.form.get("create_item_name") or item_search) shopping_note = normalize_shopping_note(request.form.get("shopping_note")) item = None - if selected_item_id.isdigit(): + if create_as in {"food", "shopping"}: + try: + item = create_shopping_search_item(create_item_name, create_as) + except ValueError as exc: + flash(str(exc), "error") + elif selected_item_id.isdigit(): try: item = get_item(int(selected_item_id)) except ValueError as exc: flash(str(exc), "error") elif item_search: - item = find_shopping_food_by_name(item_search) + item = find_shopping_item_by_name(item_search) if item is None: - flash("Bitte ein Lebensmittel aus der Suche auswählen.", "error") + flash("Bitte einen Treffer auswählen oder den Begriff als Lebensmittel bzw. Einkaufsartikel anlegen.", "error") else: flash("Bitte zuerst etwas auswählen.", "error") @@ -4560,7 +4633,10 @@ def shopping_list(): entries = fetch_shopping_entries() upcoming_entries = fetch_upcoming_shopping_needs() - addable_items = fetch_items(kind="food", include_archived=False, include_quick_added=True) + addable_items = [ + item for item in fetch_items(include_archived=False, include_quick_added=True) + if item["kind"] in {"food", "shopping"} + ] household_settings = get_household_settings() shopping_weekday_label = dict(WEEKDAY_OPTIONS).get(household_settings["shopping_weekday"], "gesetzt") return render_template( @@ -4581,7 +4657,10 @@ def shopping_check(entry_id: int): except (ValueError, PermissionError) as exc: flash(str(exc), "error") return redirect(url_with_scroll_position(url_for("main.shopping_list"))) - flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success") + if item["kind"] == "shopping": + flash(f"{item['name']} wurde als eingekauft markiert.", "success") + else: + flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success") return redirect(url_with_scroll_position(url_for("main.shopping_list"))) diff --git a/nouri/schema.sql b/nouri/schema.sql index a7652ab..675ae36 100644 --- a/nouri/schema.sql +++ b/nouri/schema.sql @@ -118,7 +118,7 @@ CREATE TABLE IF NOT EXISTS items ( owner_user_id INTEGER, target_user_id INTEGER, visibility TEXT NOT NULL DEFAULT 'shared', - kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')), + kind TEXT NOT NULL CHECK (kind IN ('food', 'meal', 'shopping')), name TEXT NOT NULL, category TEXT, base_type TEXT NOT NULL DEFAULT 'neutral', diff --git a/nouri/static/css/styles.css b/nouri/static/css/styles.css index a4ce89a..1dd3507 100644 --- a/nouri/static/css/styles.css +++ b/nouri/static/css/styles.css @@ -1491,6 +1491,31 @@ h3 { grid-column: 1 / -1; } +.shopping-create-actions { + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; + padding: 0.65rem; + border-radius: 18px; + background: color-mix(in srgb, var(--surface-soft) 76%, transparent 24%); + border: 1px solid color-mix(in srgb, var(--line) 78%, transparent 22%); +} + +.shopping-create-actions[hidden] { + display: none; +} + +.shopping-create-actions p { + flex: 1 1 16rem; + margin: 0; +} + +.shopping-create-actions button { + flex: 0 0 auto; +} + .shopping-add-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); @@ -1746,6 +1771,11 @@ h3 { width: 100%; } + .shopping-create-actions { + display: grid; + grid-template-columns: 1fr; + } + .shopping-entry-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; diff --git a/nouri/static/js/ui.js b/nouri/static/js/ui.js index 5bb3c9f..c88bcb0 100644 --- a/nouri/static/js/ui.js +++ b/nouri/static/js/ui.js @@ -219,6 +219,25 @@ }); }; + const initCreateFromSearch = () => { + document.querySelectorAll("[data-create-from]").forEach((container) => { + const inputSelector = container.getAttribute("data-create-from"); + if (!inputSelector) return; + const input = document.querySelector(inputSelector); + const hiddenName = container.querySelector("[data-create-name]"); + if (!(input instanceof HTMLInputElement) || !(hiddenName instanceof HTMLInputElement)) return; + + const sync = () => { + const value = input.value.trim().replace(/\s+/g, " "); + hiddenName.value = value; + container.hidden = value.length === 0; + }; + + input.addEventListener("input", sync); + sync(); + }); + }; + const initIosPullToRefresh = () => { const isAppleTouchDevice = /iP(ad|hone|od)/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); @@ -323,6 +342,7 @@ initMobileSheet(); initFilterInputs(); initSelectedPreviews(); + initCreateFromSearch(); initIosPullToRefresh(); initDialogs(); }); diff --git a/nouri/static/pwa/service-worker.js b/nouri/static/pwa/service-worker.js index 9974711..c409809 100644 --- a/nouri/static/pwa/service-worker.js +++ b/nouri/static/pwa/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_NAME = "nouri-v1-3-2"; +const CACHE_NAME = "nouri-v1-3-3"; const OFFLINE_URL = "/static/pwa/offline.html"; const STATIC_ASSETS = [ "/static/css/styles.css", diff --git a/nouri/templates/shopping/list.html b/nouri/templates/shopping/list.html index ec4235a..37f6f33 100644 --- a/nouri/templates/shopping/list.html +++ b/nouri/templates/shopping/list.html @@ -13,11 +13,12 @@
{{ csrf_input() }}