From 555fddab80cd7b6753e5c1cf24016fd3bdb700d5 Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Sun, 12 Apr 2026 17:46:18 +0200 Subject: [PATCH] release nouri 0.6.0 polish backup and pwa --- CloudronManifest.json | 4 +- RELEASE_NOTES_0.6.0.md | 55 ++++ nouri/__init__.py | 61 ++++- nouri/backup.py | 154 +++++++++++ nouri/constants.py | 3 +- nouri/db.py | 74 +++++- nouri/images.py | 163 ++++++++++++ nouri/main.py | 325 +++++++++++++++++++----- nouri/schema.sql | 6 + nouri/static/brand/favicon.svg | 20 +- nouri/static/brand/nouri-icon.svg | 31 ++- nouri/static/brand/pwa-180.png | Bin 0 -> 3006 bytes nouri/static/brand/pwa-192.png | Bin 10148 -> 3157 bytes nouri/static/brand/pwa-512.png | Bin 34400 -> 7733 bytes nouri/static/brand/pwa-badge.png | Bin 4615 -> 805 bytes nouri/static/brand/pwa-maskable-512.png | Bin 0 -> 8139 bytes nouri/static/css/styles.css | 62 ++++- nouri/static/js/ui.js | 49 +++- nouri/static/pwa/app.webmanifest | 15 +- nouri/static/pwa/offline.html | 79 ++++++ nouri/static/pwa/service-worker.js | 63 +++-- nouri/templates/archive/list.html | 7 +- nouri/templates/auth/setup.html | 17 +- nouri/templates/base.html | 20 +- nouri/templates/dashboard.html | 17 ++ nouri/templates/home/list.html | 7 +- nouri/templates/items/form.html | 9 +- nouri/templates/items/list.html | 7 +- nouri/templates/settings.html | 66 ++++- requirements.txt | 1 + scripts/generate_brand_assets.py | 106 ++++++++ 31 files changed, 1257 insertions(+), 164 deletions(-) create mode 100644 RELEASE_NOTES_0.6.0.md create mode 100644 nouri/backup.py create mode 100644 nouri/images.py create mode 100644 nouri/static/brand/pwa-180.png create mode 100644 nouri/static/brand/pwa-maskable-512.png create mode 100644 nouri/static/pwa/offline.html create mode 100644 scripts/generate_brand_assets.py diff --git a/CloudronManifest.json b/CloudronManifest.json index b20cd3a..43aa619 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": "0.5.1", - "upstreamVersion": "0.5.1", + "version": "0.6.0", + "upstreamVersion": "0.6.0", "healthCheckPath": "/", "httpPort": 8000, "manifestVersion": 2, diff --git a/RELEASE_NOTES_0.6.0.md b/RELEASE_NOTES_0.6.0.md new file mode 100644 index 0000000..c10358d --- /dev/null +++ b/RELEASE_NOTES_0.6.0.md @@ -0,0 +1,55 @@ +# Nouri 0.6.0 + +Nouri 0.6.0 bringt die App näher an einen stabilen 1.0-Stand. Der Schwerpunkt liegt auf Reife, Alltagstauglichkeit und einem ruhigeren, verlässlicheren Gesamteindruck statt auf großen neuen Kernfunktionen. + +## Highlights + +### Design und Brand + +- Neues Nouri-App-Icon für Header, Favicon und PWA +- Überarbeitete PWA-Icons inklusive zusätzlicher Größen und maskierbarer Variante +- Ruhigeres visuelles Finish bei Karten, Fokuszuständen, Setup und leeren Bereichen +- Mobile Header-Logik bereinigt, damit der obere Bereich auf Smartphones nicht fest mitscrollt + +### Bilder und Performance + +- Bild-Uploads werden jetzt in mehrere sinnvolle Größen abgeleitet +- Listen und Formulare nutzen responsive Bildauslieferung statt immer die volle Originalgröße +- Statische Assets bekommen sauberes Cache-Busting über Versions-URLs +- Uploads und statische Marken-Assets werden vorsichtig browserfreundlich gecacht + +### PWA und iPhone-Nutzung + +- Überarbeitetes Web App Manifest +- Verbesserter Service Worker für eine stabilere App-Shell +- Kleine Offline-Seite für kurze Verbindungsabbrüche +- Bessere Einbindung für Homescreen-Nutzung auf iPhones + +### Backup und Stabilität + +- Admin-Bereich in den Optionen für Backup-Export und Restore +- Backup als ZIP mit App-Daten und Uploads +- Restore mit klarer Bestätigung statt versehentlicher Überschreibung +- Freundlichere Behandlung von zu großen Bild-Uploads +- Robusterer Umgang mit vorhandenen älteren Datenstrukturen + +### Planung und Vorschläge + +- Automatische Mahlzeitenvorschläge robuster aufgebaut +- Bausteinlogik für Protein, Kohlenhydrate und Gemüse verbessert +- Bestehende Kombinationen werden beim Übernehmen besser wiederverwendet +- Suche beim Zusammenstellen von Mahlzeiten zeigt nur die drei passendsten Treffer +- Kategorie-Richtung von „Brot & Getreide“ in Richtung „Kohlenhydrate“ weitergeführt + +## Technische Änderungen + +- Neue Hilfsmodule für Bildverarbeitung und Backup/Restore +- App-Metadaten-Tabelle für robustere Schema-Verwaltung vorbereitet +- Cloudron-Version auf 0.6.0 angehoben +- Pillow als zusätzliche Abhängigkeit für lokale Bildverarbeitung ergänzt + +## Hinweise zum Update + +- Nach dem Update kann ein harter Reload im Browser sinnvoll sein, damit neue CSS-, JS- und PWA-Dateien sicher geladen werden. +- Vor einem Restore empfiehlt sich immer zuerst ein frischer Backup-Export aus der laufenden App. +- Vorhandene Kategorien und ältere Daten werden beim Start weiter normalisiert, statt hart ersetzt zu werden. diff --git a/nouri/__init__.py b/nouri/__init__.py index dedfb8f..9d22d90 100644 --- a/nouri/__init__.py +++ b/nouri/__init__.py @@ -5,7 +5,7 @@ import secrets from datetime import date, timedelta from pathlib import Path -from flask import Flask, g, send_from_directory +from flask import Flask, flash, g, redirect, request, send_from_directory, url_for from . import db from .admin import admin_bp @@ -24,6 +24,7 @@ from .constants import ( VISIBILITY_LABELS, WEEKDAY_OPTIONS, ) +from .images import ensure_upload_structure, image_sizes, image_srcset, image_url from .main import main_bp @@ -56,7 +57,7 @@ def create_app() -> Flask: db_path = data_dir / "nouri.sqlite3" data_dir.mkdir(parents=True, exist_ok=True) - upload_dir.mkdir(parents=True, exist_ok=True) + ensure_upload_structure(upload_dir) app = Flask(__name__, instance_relative_config=False) app.config.update( @@ -68,7 +69,7 @@ def create_app() -> Flask: PERMANENT_SESSION_LIFETIME=timedelta(days=30), SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", - APP_VERSION="0.5.1", + APP_VERSION="0.6.0", VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""), VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""), VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"), @@ -83,6 +84,11 @@ def create_app() -> Flask: @app.context_processor def inject_globals() -> dict[str, object]: + def asset_url(filename: str) -> str: + file_path = root_dir / "nouri" / "static" / filename + version = int(file_path.stat().st_mtime) if file_path.exists() else app.config["APP_VERSION"] + return url_for("static", filename=filename, v=version) + return { "item_kind_labels": ITEM_KIND_LABELS, "item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS, @@ -103,18 +109,61 @@ def create_app() -> Flask: "weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()], "weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()], "is_admin": lambda: bool(getattr(g, "user", None)) and g.user["role"] == "admin", + "asset_url": asset_url, + "image_url": lambda filename, variant="md": image_url( + filename, + url_for, + variant, + upload_folder=app.config["UPLOAD_FOLDER"], + ), + "image_srcset": lambda filename: image_srcset( + filename, + url_for, + upload_folder=app.config["UPLOAD_FOLDER"], + ), + "image_sizes": image_sizes, } @app.get("/uploads/") def uploaded_file(filename: str): - return send_from_directory(app.config["UPLOAD_FOLDER"], filename) + response = send_from_directory(app.config["UPLOAD_FOLDER"], filename, max_age=60 * 60 * 24 * 30) + response.headers["Cache-Control"] = "public, max-age=2592000, immutable" + return response @app.get("/app.webmanifest") def webmanifest(): - return send_from_directory(root_dir / "nouri" / "static" / "pwa", "app.webmanifest", mimetype="application/manifest+json") + response = send_from_directory( + root_dir / "nouri" / "static" / "pwa", + "app.webmanifest", + mimetype="application/manifest+json", + max_age=60 * 30, + ) + response.headers["Cache-Control"] = "public, max-age=1800" + return response @app.get("/service-worker.js") def service_worker(): - return send_from_directory(root_dir / "nouri" / "static" / "pwa", "service-worker.js", mimetype="application/javascript") + response = send_from_directory( + root_dir / "nouri" / "static" / "pwa", + "service-worker.js", + mimetype="application/javascript", + max_age=0, + ) + response.headers["Cache-Control"] = "no-store" + return response + + @app.after_request + def apply_cache_policy(response): + if response.direct_passthrough: + return response + content_type = response.headers.get("Content-Type", "") + if content_type.startswith("text/html"): + response.headers.setdefault("Cache-Control", "no-store, max-age=0") + return response + + @app.errorhandler(413) + def upload_too_large(_error): + flash("Das hochgeladene Bild ist etwas zu groß. Eine kleinere Datei passt hier besser.", "error") + return redirect(request.referrer or url_for("main.dashboard")) return app diff --git a/nouri/backup.py b/nouri/backup.py new file mode 100644 index 0000000..a1a8bc0 --- /dev/null +++ b/nouri/backup.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import io +import json +import shutil +import sqlite3 +import tempfile +import zipfile +from datetime import datetime, timezone +from pathlib import Path + + +BACKUP_FILENAME_PREFIX = "nouri-backup" +RESTORE_CONFIRMATION_TEXT = "WIEDERHERSTELLEN" + + +def list_backup_tables(database: sqlite3.Connection) -> list[str]: + rows = database.execute( + """ + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + ORDER BY name + """ + ).fetchall() + return [row["name"] for row in rows] + + +def export_backup_archive( + database: sqlite3.Connection, + upload_folder: str | Path, + app_version: str, +) -> tuple[str, str]: + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + backup_name = f"{BACKUP_FILENAME_PREFIX}-{timestamp}.zip" + temp_handle = tempfile.NamedTemporaryFile(prefix="nouri-backup-", suffix=".zip", delete=False) + temp_handle.close() + archive_path = temp_handle.name + + tables = list_backup_tables(database) + payload = { + "meta": { + "created_at": datetime.now(timezone.utc).isoformat(), + "app_version": app_version, + "format_version": 1, + }, + "tables": {}, + } + + for table_name in tables: + rows = database.execute(f"SELECT * FROM {table_name}").fetchall() + payload["tables"][table_name] = [dict(row) for row in rows] + + uploads_root = Path(upload_folder) + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("backup.json", json.dumps(payload, ensure_ascii=False, indent=2)) + if uploads_root.exists(): + for file_path in uploads_root.rglob("*"): + if file_path.is_file(): + relative_path = file_path.relative_to(uploads_root) + archive.write(file_path, f"uploads/{relative_path.as_posix()}") + + return archive_path, backup_name + + +def _extract_uploads_to_temp(archive: zipfile.ZipFile) -> Path: + temp_dir = Path(tempfile.mkdtemp(prefix="nouri-restore-uploads-")) + for member in archive.infolist(): + if not member.filename.startswith("uploads/") or member.is_dir(): + continue + relative_target = member.filename.removeprefix("uploads/").lstrip("/") + if not relative_target: + continue + target_path = temp_dir / relative_target + target_path.parent.mkdir(parents=True, exist_ok=True) + with archive.open(member, "r") as source, target_path.open("wb") as destination: + shutil.copyfileobj(source, destination) + return temp_dir + + +def _replace_uploads(temp_dir: Path, upload_folder: str | Path) -> None: + upload_root = Path(upload_folder) + previous_root = upload_root.with_name(f"{upload_root.name}-previous") + if previous_root.exists(): + shutil.rmtree(previous_root) + if upload_root.exists(): + upload_root.rename(previous_root) + upload_root.mkdir(parents=True, exist_ok=True) + for file_path in temp_dir.rglob("*"): + if not file_path.is_file(): + continue + relative_path = file_path.relative_to(temp_dir) + target_path = upload_root / relative_path + target_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(file_path, target_path) + shutil.rmtree(temp_dir, ignore_errors=True) + shutil.rmtree(previous_root, ignore_errors=True) + + +def restore_backup_archive( + database: sqlite3.Connection, + upload_folder: str | Path, + backup_file, +) -> dict: + backup_bytes = backup_file.read() + if not backup_bytes: + raise ValueError("Bitte ein gültiges Backup auswählen.") + + with zipfile.ZipFile(io.BytesIO(backup_bytes)) as archive: + try: + backup_payload = json.loads(archive.read("backup.json").decode("utf-8")) + except KeyError as exc: + raise ValueError("Im Backup fehlt die Datei backup.json.") from exc + except json.JSONDecodeError as exc: + raise ValueError("Das Backup konnte nicht gelesen werden.") from exc + + tables = backup_payload.get("tables") + if not isinstance(tables, dict): + raise ValueError("Das Backup enthält keine gültigen Tabellen-Daten.") + + current_tables = list_backup_tables(database) + restore_tables = [table for table in current_tables if table in tables] + + upload_temp_dir = _extract_uploads_to_temp(archive) + + try: + database.execute("PRAGMA foreign_keys = OFF") + try: + for table_name in reversed(restore_tables): + database.execute(f"DELETE FROM {table_name}") + database.execute("DELETE FROM sqlite_sequence") + + for table_name in restore_tables: + rows = tables.get(table_name, []) + if not rows: + continue + columns = list(rows[0].keys()) + placeholders = ", ".join(["?"] * len(columns)) + column_list = ", ".join(columns) + for row in rows: + values = [row.get(column) for column in columns] + database.execute( + f"INSERT INTO {table_name} ({column_list}) VALUES ({placeholders})", + values, + ) + finally: + database.execute("PRAGMA foreign_keys = ON") + + _replace_uploads(upload_temp_dir, upload_folder) + except Exception: + shutil.rmtree(upload_temp_dir, ignore_errors=True) + raise + + return backup_payload.get("meta", {}) diff --git a/nouri/constants.py b/nouri/constants.py index 6779f28..039f349 100644 --- a/nouri/constants.py +++ b/nouri/constants.py @@ -8,7 +8,7 @@ DAYPARTS = [ ] DEFAULT_CATEGORIES = [ - "Brot & Getreide", + "Kohlenhydrate", "Milchprodukt", "Obst", "Gemüse", @@ -21,6 +21,7 @@ DEFAULT_CATEGORIES = [ ] DEFAULT_CATEGORY_BUILDERS = { + "Kohlenhydrate": "carb", "Brot & Getreide": "carb", "Milchprodukt": "dairy", "Obst": "fruit", diff --git a/nouri/db.py b/nouri/db.py index e7602e7..001055b 100644 --- a/nouri/db.py +++ b/nouri/db.py @@ -10,6 +10,8 @@ from werkzeug.security import generate_password_hash from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS +CURRENT_SCHEMA_VERSION = "0.6.0" + def get_db() -> sqlite3.Connection: if "db" not in g: @@ -47,7 +49,36 @@ def add_column_if_missing(database: sqlite3.Connection, table_name: str, definit database.execute(f"ALTER TABLE {table_name} ADD COLUMN {definition}") +def ensure_meta_table(database: sqlite3.Connection) -> None: + database.execute( + """ + CREATE TABLE IF NOT EXISTS app_meta ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + +def get_meta(database: sqlite3.Connection, key: str) -> str | None: + row = database.execute("SELECT value FROM app_meta WHERE key = ?", (key,)).fetchone() + return row["value"] if row else None + + +def set_meta(database: sqlite3.Connection, key: str, value: str) -> None: + database.execute( + """ + INSERT INTO app_meta (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP + """, + (key, value), + ) + + def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: + ensure_meta_table(database) database.execute( """ CREATE TABLE IF NOT EXISTS households ( @@ -211,6 +242,41 @@ def first_user_id(database: sqlite3.Connection) -> int | None: def sync_default_categories(database: sqlite3.Connection) -> None: for household_id in household_ids(database): + legacy = database.execute( + """ + SELECT id + FROM household_categories + WHERE household_id = ? AND name = 'Brot & Getreide' + LIMIT 1 + """, + (household_id,), + ).fetchone() + updated = database.execute( + """ + SELECT id + FROM household_categories + WHERE household_id = ? AND name = 'Kohlenhydrate' + LIMIT 1 + """, + (household_id,), + ).fetchone() + if legacy and not updated: + database.execute( + """ + UPDATE household_categories + SET name = 'Kohlenhydrate', builder_key = 'carb' + WHERE id = ? + """, + (legacy["id"],), + ) + database.execute( + """ + UPDATE items + SET category = 'Kohlenhydrate' + WHERE household_id = ? AND category = 'Brot & Getreide' + """, + (household_id,), + ) for sort_order, name in enumerate(DEFAULT_CATEGORIES, start=10): database.execute( """ @@ -230,6 +296,7 @@ def sync_default_categories(database: sqlite3.Connection) -> None: def ensure_schema_upgrades(database: sqlite3.Connection) -> None: + ensure_meta_table(database) add_column_if_missing(database, "users", "household_id INTEGER") add_column_if_missing(database, "users", "email TEXT") add_column_if_missing(database, "users", "role TEXT NOT NULL DEFAULT 'member'") @@ -237,10 +304,10 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: add_column_if_missing(database, "users", "updated_at TEXT") default_household_id = ensure_default_household(database) - database.execute("UPDATE households SET shopping_weekday = COALESCE(shopping_weekday, 5)") - database.execute("UPDATE households SET shopping_prep_days = COALESCE(shopping_prep_days, 1)") + database.execute("UPDATE households SET shopping_weekday = 5 WHERE shopping_weekday IS NULL") + database.execute("UPDATE households SET shopping_prep_days = 1 WHERE shopping_prep_days IS NULL") database.execute( - "UPDATE households SET shopping_reminder_time = COALESCE(NULLIF(shopping_reminder_time, ''), '18:00')" + "UPDATE households SET shopping_reminder_time = '18:00' WHERE shopping_reminder_time IS NULL OR shopping_reminder_time = ''" ) database.execute( "UPDATE users SET household_id = ? WHERE household_id IS NULL", @@ -349,6 +416,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: ON shopping_needs (household_id, activation_date, is_activated) """ ) + set_meta(database, "schema_version", CURRENT_SCHEMA_VERSION) def apply_schema(database: sqlite3.Connection) -> None: diff --git a/nouri/images.py b/nouri/images.py new file mode 100644 index 0000000..47dcb4f --- /dev/null +++ b/nouri/images.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import os +import uuid +from pathlib import Path + +from PIL import Image, ImageOps, UnidentifiedImageError +from werkzeug.datastructures import FileStorage +from werkzeug.utils import secure_filename + + +ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"} +IMAGE_VARIANTS = { + "sm": {"width": 320, "quality": 76}, + "md": {"width": 720, "quality": 82}, + "lg": {"width": 1280, "quality": 86}, +} +DEFAULT_RENDERED_FORMAT = "webp" +ORIGINAL_MAX_WIDTH = 1600 + + +def allowed_image_file(filename: str) -> bool: + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS + + +def ensure_upload_structure(upload_folder: str | Path) -> None: + upload_root = Path(upload_folder) + upload_root.mkdir(parents=True, exist_ok=True) + (upload_root / "variants").mkdir(parents=True, exist_ok=True) + for variant_name in IMAGE_VARIANTS: + (upload_root / "variants" / variant_name).mkdir(parents=True, exist_ok=True) + + +def build_variant_filename(filename: str, variant_name: str) -> str: + source = Path(filename) + return f"{source.stem}__{variant_name}.webp" + + +def build_variant_relative_path(filename: str, variant_name: str) -> str: + return f"variants/{variant_name}/{build_variant_filename(filename, variant_name)}" + + +def remove_photo_assets(upload_folder: str | Path, filename: str | None) -> None: + if not filename: + return + upload_root = Path(upload_folder) + original_path = upload_root / filename + if original_path.exists(): + original_path.unlink() + for variant_name in IMAGE_VARIANTS: + variant_path = upload_root / build_variant_relative_path(filename, variant_name) + if variant_path.exists(): + variant_path.unlink() + + +def _open_image(upload: FileStorage) -> Image.Image: + upload.stream.seek(0) + image = Image.open(upload.stream) + image.load() + return ImageOps.exif_transpose(image) + + +def _prepare_image(image: Image.Image) -> Image.Image: + if image.mode not in {"RGB", "RGBA"}: + image = image.convert("RGBA" if "A" in image.getbands() else "RGB") + return image + + +def _resize_copy(image: Image.Image, width: int) -> Image.Image: + resized = image.copy() + resized.thumbnail((width, width * 3), Image.Resampling.LANCZOS) + return resized + + +def _save_image(image: Image.Image, destination: Path, quality: int) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + image.save( + destination, + format=DEFAULT_RENDERED_FORMAT.upper(), + quality=quality, + method=6, + ) + + +def save_photo_with_variants( + upload: FileStorage | None, + upload_folder: str | Path, + current_filename: str | None = None, +) -> str | None: + if not upload or not upload.filename: + return current_filename + + if not allowed_image_file(upload.filename): + raise ValueError("Bitte ein Bild als PNG, JPG, GIF oder WEBP hochladen.") + + ensure_upload_structure(upload_folder) + + original_name = secure_filename(upload.filename) + extension = original_name.rsplit(".", 1)[1].lower() + + try: + image = _prepare_image(_open_image(upload)) + filename = f"{uuid.uuid4().hex}.webp" + original_path = Path(upload_folder) / filename + optimized = _resize_copy(image, ORIGINAL_MAX_WIDTH) + _save_image(optimized, original_path, quality=88) + for variant_name, config in IMAGE_VARIANTS.items(): + variant_image = _resize_copy(image, int(config["width"])) + variant_path = Path(upload_folder) / build_variant_relative_path(filename, variant_name) + _save_image(variant_image, variant_path, quality=int(config["quality"])) + except (UnidentifiedImageError, OSError, ValueError): + filename = f"{uuid.uuid4().hex}.{extension}" + original_path = Path(upload_folder) / filename + upload.stream.seek(0) + upload.save(original_path) + + if current_filename and current_filename != filename: + remove_photo_assets(upload_folder, current_filename) + + return filename + + +def image_url(filename: str | None, url_builder, variant: str = "md", upload_folder: str | Path | None = None) -> str | None: + if not filename: + return None + if variant in IMAGE_VARIANTS: + if upload_folder is not None: + variant_path = Path(upload_folder) / build_variant_relative_path(filename, variant) + if variant_path.exists(): + return url_builder("uploaded_file", filename=build_variant_relative_path(filename, variant)) + else: + return url_builder("uploaded_file", filename=build_variant_relative_path(filename, variant)) + return url_builder("uploaded_file", filename=filename) + + +def image_srcset(filename: str | None, url_builder, upload_folder: str | Path | None = None) -> str: + if not filename: + return "" + parts = [] + for variant_name, config in IMAGE_VARIANTS.items(): + variant_url = image_url(filename, url_builder, variant_name, upload_folder=upload_folder) + if variant_url and (not upload_folder or variant_url != url_builder("uploaded_file", filename=filename)): + parts.append(f"{variant_url} {config['width']}w") + original_width = max(config["width"] for config in IMAGE_VARIANTS.values()) + 320 + parts.append(f"{url_builder('uploaded_file', filename=filename)} {original_width}w") + return ", ".join(parts) + + +def image_sizes(card: str = "grid") -> str: + if card == "detail": + return "(max-width: 720px) 100vw, 720px" + return "(max-width: 720px) 42vw, (max-width: 1080px) 28vw, 180px" + + +def upload_file_size_ok(upload: FileStorage | None, max_bytes: int) -> bool: + if not upload or not upload.filename: + return True + stream = upload.stream + current_position = stream.tell() + stream.seek(0, os.SEEK_END) + size = stream.tell() + stream.seek(current_position) + return size <= max_bytes diff --git a/nouri/main.py b/nouri/main.py index c27fd45..88a3dd7 100644 --- a/nouri/main.py +++ b/nouri/main.py @@ -1,12 +1,13 @@ from __future__ import annotations -import uuid from collections import defaultdict from datetime import date, datetime, timedelta +from itertools import product from pathlib import Path from flask import ( Blueprint, + after_this_request, current_app, flash, g, @@ -14,11 +15,12 @@ from flask import ( redirect, render_template, request, + send_file, url_for, ) -from werkzeug.utils import secure_filename -from .auth import login_required +from .auth import admin_required, login_required +from .backup import RESTORE_CONFIRMATION_TEXT, export_backup_archive, restore_backup_archive from .constants import ( AVAILABILITY_LABELS, BUILDER_LABELS, @@ -35,12 +37,15 @@ from .constants import ( WEEK_TEMPLATE_NAME_SUGGESTIONS, ) from .db import get_db +from .images import ( + allowed_image_file, + save_photo_with_variants, + upload_file_size_ok, +) from .push import push_is_configured, push_public_key, send_push_message main_bp = Blueprint("main", __name__) - -ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"} ACTIVE_STATE_OPTIONS = [ ("", "Alle aktiven"), ("home", "Zuhause"), @@ -260,29 +265,14 @@ def normalize_target_user_id(raw: str | None) -> int | None: return target_id if target_id in allowed else None -def allowed_file(filename: str) -> bool: - return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS - - def save_photo(upload, current_filename: str | None = None) -> str | None: if not upload or not upload.filename: return current_filename - - if not allowed_file(upload.filename): + if not allowed_image_file(upload.filename): raise ValueError("Bitte ein Bild als PNG, JPG, GIF oder WEBP hochladen.") - - original_name = secure_filename(upload.filename) - extension = original_name.rsplit(".", 1)[1].lower() - filename = f"{uuid.uuid4().hex}.{extension}" - destination = Path(current_app.config["UPLOAD_FOLDER"]) / filename - upload.save(destination) - - if current_filename: - old_path = Path(current_app.config["UPLOAD_FOLDER"]) / current_filename - if old_path.exists(): - old_path.unlink() - - return filename + if not upload_file_size_ok(upload, current_app.config["MAX_CONTENT_LENGTH"]): + raise ValueError("Das Bild ist gerade zu groß. Ein etwas kleineres Foto hilft hier am besten.") + return save_photo_with_variants(upload, current_app.config["UPLOAD_FOLDER"], current_filename=current_filename) def user_display_name(display_name: str | None, username: str | None) -> str: @@ -1156,18 +1146,98 @@ def format_item_names(items: list[dict], limit: int = 3) -> str: return ", ".join(item["name"] for item in items[:limit]) -def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]: - home_foods = fetch_items(kind="food", availability="home", daypart_id=daypart_id) - home_food_ids = {item["id"] for item in home_foods} +def item_matches_daypart(item: dict, daypart_id: int | None) -> bool: + if daypart_id is None: + return True + dayparts_meta = item.get("dayparts_meta") or [] + if not dayparts_meta: + return True + return any(int(daypart["id"]) == int(daypart_id) for daypart in dayparts_meta) + + +def normalized_component_signature(component_ids: list[int]) -> tuple[int, ...]: + return tuple(sorted({int(component_id) for component_id in component_ids})) + + +def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str: + names = [item["name"] for item in combo] + if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} and len(names) >= 2: + return f"{names[0]} mit {', '.join(names[1:])}" + if len(names) >= 2: + return f"{names[0]} mit {', '.join(names[1:])}" + return names[0] + + +def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]: builder_groups: dict[str, list[dict]] = defaultdict(list) for food in home_foods: for builder_key in food.get("builder_keys", ["neutral"]): builder_groups[builder_key].append(food) + if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}: + target_patterns = [ + ("carb", "dairy", "fruit"), + ("carb", "dairy", "nuts"), + ("carb", "fruit", "dairy"), + ] + reasons = { + ("carb", "dairy", "fruit"): "Passt gut zu Frühstück oder Snack", + ("carb", "dairy", "nuts"): "Lässt sich gut für einen Snack vormerken", + ("carb", "fruit", "dairy"): "Zuhause gut kombinierbar", + } + else: + target_patterns = [ + ("protein", "carb", "veg"), + ("protein", "carb"), + ] + reasons = { + ("protein", "carb", "veg"): "Zuhause als vollständige Mahlzeit möglich", + ("protein", "carb"): "Lässt sich leicht ergänzen", + } + suggestions: list[dict] = [] - meals = fetch_items(kind="meal", daypart_id=daypart_id) + seen_signatures: set[tuple[int, ...]] = set() + + for pattern in target_patterns: + groups = [builder_groups.get(builder_key, []) for builder_key in pattern] + if any(not group for group in groups): + continue + for combo in product(*groups): + signature = normalized_component_signature([item["id"] for item in combo]) + if len(signature) != len(pattern) or signature in seen_signatures: + continue + seen_signatures.add(signature) + combo_items = list(combo) + suggestions.append( + { + "title": build_generated_meal_name(combo_items, daypart_slug), + "reason": reasons.get(pattern, "Zuhause gut kombinierbar"), + "component_ids": [item["id"] for item in combo_items], + "existing_item_id": None, + } + ) + if len(suggestions) >= limit: + return suggestions + return suggestions + + +def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]: + home_foods = [ + item + for item in fetch_items(kind="food", availability="home") + if item_matches_daypart(item, daypart_id) + ] + home_food_ids = {item["id"] for item in home_foods} + + suggestions: list[dict] = [] + seen_signatures: set[tuple[int, ...]] = set() + meals = [item for item in fetch_items(kind="meal") if item_matches_daypart(item, daypart_id)] for meal in meals: if meal["component_ids"] and all(component_id in home_food_ids for component_id in meal["component_ids"]): + signature = normalized_component_signature(meal["component_ids"]) + if signature in seen_signatures: + continue + seen_signatures.add(signature) suggestions.append( { "title": meal["name"], @@ -1178,34 +1248,12 @@ def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) ) daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "") - if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}: - if builder_groups["carb"] and builder_groups["dairy"]: - combo = [builder_groups["carb"][0], builder_groups["dairy"][0]] - if builder_groups["fruit"]: - combo.append(builder_groups["fruit"][0]) - if builder_groups["nuts"]: - combo.append(builder_groups["nuts"][0]) - suggestions.append( - { - "title": " mit ".join([combo[0]["name"], combo[1]["name"]]) if len(combo) == 2 else f"{combo[0]['name']} mit {', '.join(item['name'] for item in combo[1:])}", - "reason": "Lässt sich gut ergänzen", - "component_ids": [item["id"] for item in combo], - "existing_item_id": None, - } - ) - else: - if builder_groups["protein"] and builder_groups["carb"]: - combo = [builder_groups["protein"][0], builder_groups["carb"][0]] - if builder_groups["veg"]: - combo.append(builder_groups["veg"][0]) - suggestions.append( - { - "title": f"{combo[0]['name']} mit {', '.join(item['name'] for item in combo[1:])}", - "reason": "Aus Zuhause zusammengesetzt", - "component_ids": [item["id"] for item in combo], - "existing_item_id": None, - } - ) + for suggestion in build_dynamic_meal_suggestions(home_foods, daypart_slug, limit=limit * 2): + signature = normalized_component_signature(suggestion["component_ids"]) + if signature in seen_signatures: + continue + seen_signatures.add(signature) + suggestions.append(suggestion) deduped: list[dict] = [] seen = set() @@ -1336,6 +1384,77 @@ def build_dashboard_hints(today: date) -> list[str]: return hints[:4] +def build_setup_checklist(today: date) -> list[dict]: + total_items = int( + get_db().execute( + f"SELECT COUNT(*) AS count FROM items WHERE {visible_clause('items')}", + visible_params(), + ).fetchone()["count"] + ) + meal_count = int( + get_db().execute( + f"SELECT COUNT(*) AS count FROM items WHERE kind = 'meal' AND {visible_clause('items')}", + visible_params(), + ).fetchone()["count"] + ) + week_end = today + timedelta(days=6) + plan_count = int( + get_db().execute( + f""" + SELECT COUNT(*) AS count + FROM plan_entries + WHERE plan_date BETWEEN ? AND ? AND {visible_clause('plan_entries')} + """, + [today.isoformat(), week_end.isoformat(), *visible_params()], + ).fetchone()["count"] + ) + template_count = int( + get_db().execute( + f"SELECT COUNT(*) AS count FROM day_templates WHERE {visible_clause('day_templates')}", + visible_params(), + ).fetchone()["count"] + ) + + checklist = [] + if total_items == 0: + checklist.append( + { + "title": "Fang mit einem ersten Lebensmittel an", + "text": "Ein kleines Frühstück, ein Snack oder etwas für zuhause reicht völlig für den Start.", + "url": url_for("main.item_create", kind="food"), + "label": "Lebensmittel anlegen", + } + ) + if total_items > 0 and meal_count == 0: + checklist.append( + { + "title": "Lege eine erste Mahlzeitenidee an", + "text": "Einfach zwei oder drei vertraute Dinge zusammenklicken und für später merken.", + "url": url_for("main.item_create", kind="meal"), + "label": "Mahlzeit anlegen", + } + ) + if plan_count == 0: + checklist.append( + { + "title": "Plane einen ruhigen ersten Tag", + "text": "Mit einem kleinen Eintrag für Frühstück oder Abendessen fühlt sich die Woche sofort greifbarer an.", + "url": url_for("main.planner_day", date=today.isoformat()), + "label": "Tag öffnen", + } + ) + if total_items > 0 and template_count == 0: + checklist.append( + { + "title": "Merke dir einen gelungenen Tag als Vorlage", + "text": "So wird Wiederverwendung später noch leichter.", + "url": url_for("main.template_library"), + "label": "Vorlagen ansehen", + } + ) + return checklist[:3] + + def build_day_hints(selected_date: date) -> list[str]: settings = get_user_settings() if not settings.get("reminders_enabled"): @@ -1975,6 +2094,28 @@ def create_or_get_generated_meal( daypart_id: int, visibility: str, ) -> int: + normalized_ids = normalized_component_signature(component_ids) + existing_meals = [ + item + for item in fetch_items(kind="meal") + if normalized_component_signature(item.get("component_ids", [])) == normalized_ids + ] + if existing_meals: + meal_id = int(existing_meals[0]["id"]) + current_dayparts = get_item_daypart_ids(meal_id) + if daypart_id not in current_dayparts: + sync_item_dayparts(meal_id, current_dayparts + [daypart_id]) + get_db().execute( + """ + UPDATE items + SET updated_by = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (g.user["id"], meal_id), + ) + get_db().commit() + return meal_id + existing = get_db().execute( f""" SELECT items.id @@ -1986,7 +2127,21 @@ def create_or_get_generated_meal( [name, *visible_params()], ).fetchone() if existing: - return int(existing["id"]) + meal_id = int(existing["id"]) + sync_meal_components(meal_id, list(normalized_ids)) + current_dayparts = get_item_daypart_ids(meal_id) + if daypart_id not in current_dayparts: + sync_item_dayparts(meal_id, current_dayparts + [daypart_id]) + get_db().execute( + """ + UPDATE items + SET updated_by = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (g.user["id"], meal_id), + ) + get_db().commit() + return meal_id cursor = get_db().execute( """ @@ -2000,14 +2155,14 @@ def create_or_get_generated_meal( g.user["id"], visibility, name, - "Kleines Essen", + "Kleines Essen" if get_daypart_by_id(daypart_id)["slug"] in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} else "Warmes", g.user["id"], g.user["id"], ), ) meal_id = int(cursor.lastrowid) sync_item_dayparts(meal_id, [daypart_id]) - sync_meal_components(meal_id, component_ids) + sync_meal_components(meal_id, list(normalized_ids)) get_db().commit() return meal_id @@ -2074,6 +2229,7 @@ def dashboard(): upcoming_entries=fetch_upcoming_shopping_needs(limit=4), day_templates=fetch_day_templates()[:3], week_templates=fetch_week_templates()[:3], + setup_checklist=build_setup_checklist(today), ) @@ -2525,9 +2681,60 @@ def settings_view(): push_subscription_count=int(push_subscription["count"]), push_ready=push_is_configured(), push_public_key_value=push_public_key(), + restore_confirmation_text=RESTORE_CONFIRMATION_TEXT, ) +@main_bp.get("/settings/backup/export") +@login_required +@admin_required +def backup_export(): + archive_path, download_name = export_backup_archive( + get_db(), + current_app.config["UPLOAD_FOLDER"], + current_app.config["APP_VERSION"], + ) + + @after_this_request + def cleanup_backup(response): + Path(archive_path).unlink(missing_ok=True) + return response + + return send_file( + archive_path, + as_attachment=True, + download_name=download_name, + mimetype="application/zip", + max_age=0, + ) + + +@main_bp.post("/settings/backup/restore") +@login_required +@admin_required +def backup_restore(): + confirmation = request.form.get("restore_confirmation", "").strip().upper() + backup_file = request.files.get("backup_file") + if confirmation != RESTORE_CONFIRMATION_TEXT: + flash("Bitte die Bestätigung genau eintragen, bevor das Backup wiederhergestellt wird.", "error") + return redirect(url_for("main.settings_view")) + if not backup_file or not backup_file.filename: + flash("Bitte zuerst eine Backup-Datei auswählen.", "error") + return redirect(url_for("main.settings_view")) + + try: + metadata = restore_backup_archive(get_db(), current_app.config["UPLOAD_FOLDER"], backup_file) + get_db().commit() + except Exception as exc: + get_db().rollback() + flash(str(exc) or "Das Backup konnte gerade nicht wiederhergestellt werden.", "error") + return redirect(url_for("main.settings_view")) + + version_label = metadata.get("app_version") or "einer älteren Version" + flash(f"Das Backup aus {version_label} wurde wiederhergestellt.", "success") + return redirect(url_for("main.settings_view")) + + @main_bp.post("/push/subscribe") @login_required def push_subscribe(): diff --git a/nouri/schema.sql b/nouri/schema.sql index c712481..d539257 100644 --- a/nouri/schema.sql +++ b/nouri/schema.sql @@ -1,5 +1,11 @@ PRAGMA foreign_keys = ON; +CREATE TABLE IF NOT EXISTS app_meta ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + CREATE TABLE IF NOT EXISTS households ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, diff --git a/nouri/static/brand/favicon.svg b/nouri/static/brand/favicon.svg index fe1b8a0..15e14bc 100644 --- a/nouri/static/brand/favicon.svg +++ b/nouri/static/brand/favicon.svg @@ -1,16 +1,6 @@ - - - - - - - - - - - - - - - + + + + + diff --git a/nouri/static/brand/nouri-icon.svg b/nouri/static/brand/nouri-icon.svg index 662d605..8b64f2f 100644 --- a/nouri/static/brand/nouri-icon.svg +++ b/nouri/static/brand/nouri-icon.svg @@ -1,21 +1,20 @@ - - Nouri + - - - - + + + - - - + + + - - - - - - - + + + + + + + + diff --git a/nouri/static/brand/pwa-180.png b/nouri/static/brand/pwa-180.png new file mode 100644 index 0000000000000000000000000000000000000000..1d80a426002d563d1a12de756d6bdda4c7784141 GIT binary patch literal 3006 zcmV;v3qkaWP) z-)|h%701t7`aQ8*|vksz!h?(&TBa!GjNT*PNIhfLD8>=Ms)_R zvTf_JT@MYJf$Q{?x*n>zI}ZP%sXYhRS^xY`Gum^oO>J%ZZ1&&0a~$&8!$Zlts&sAb zGe3piG1gz=^=a=CBTgVO%nJQh_6M96ddJwyTavIoeb$G-dx3}X?JKmiGJ6&%)mRkq)z2i1J#zJTjoEa z&G7h0@|Te0Rc9VU8-G$I9Sq+^Y1@kU4Bj zUQM1Q5$~b97pdR-;ov$Fl6=dJtG3Ir`soy^bHHfjS9BkG*Q2i{t;WqM>@`Pltlpg3 zXgyvP+n<*BTDKl(j9H#t-+5kp3O!l<_usUqFaR<+ueEMWij1`Du~Qq>`47&v8mg;SdWOU;ZzeUbs9+<5!+!VX< z*r{gg25*%X?V(#YCY@+E8jl>)o&qc8f!g}wJ>{7sv~En!G#-C#qp0!vrhaphG@;9! zCZ@F-*o8KaOc$zij0tvS%_jeyO3i~v4^2d>y>nyKjihQ}q;B1IqXpq~LEzGNdKL0V z>@ufGVxf7p#lSa4&^>fx^tkpEoFnv@i0wl=v|Fdb+3_bz{rt-x|C4DZ`nlj7pS9%} z{@&|NBBGUlofdyL%CP5^sO$rv3`uO{TaLs7+VFyB44D^&!X;t&@Cj?@YJGWS)Y7SL zm;Hsa<4@4DC!gyYzj5ej>GSkrc=QCVeEzPDQw;`_6Lp@`>S04>ixOKWJ-4cI4_QE<9W{!bj#sOyJ6$Tq@v`<1T6=sKtGxD?$85UfzXOT zX8Y9J0zH$&7{1}E%u>1H8YifCtoVYfGAH=`st_%QFmB82KM-0F$jlE~4k7%J+3%JE z7gm_b+hcZ1X7b%cpxCvyPcgJziUr^N`1dRyQ*Mq&(?@L6W5Tyc$q9@;WK%S=S* zp+N`lWexyr+gTzUnf)%&sL&P&M`r&)4_%SqWcIs2qe539IGF_P}@Ozsh_zI1-qeBd_z&LzO^AfKM1)9qK7ujUwf0j`GeOhpC>I!5xFJX z4%-sXUwhN$=}N%0Z5NKuECgQ#wQ|EWZZJKxVg9OC#qp02V->*Hx@|q?ub#FQpI}L1 zB%2orCx-l;djDe%Qg6%L)zd0Dk#GV%bd0!=52iT;*F&e7yMij6CG?mC*+Zv~xhrp2 z2~-FqvCzwfgcDI*STRyReDv4KGtnWY2etP%_?J26F2|Et!`$UJ)Z;GcM9@8SFJh89 zrnMS^y7XO?xy!#NBBFK{L!AtKuM)Z0y)7ny)8wtU$4)Z0y>#0xIx z&_jcu5NbJyP-A_@cJBCGkaC2nu$gzxw#( z3=z@(@pqaw&c{+QKu>JO+T`E(z=dEBO@uz$H#qXe>j3W!{+a!@{p07FHqN)SvT8pP zpo^uIRcra*KYp(B?9A*M>prwOW-q<8`7~qsF#?!isAn{L=_R)PXLES_qHqyanhl?0 z%lxkI&M|xG#Tg=^Z~f?8)7Ci`S6VDViJ2s(_VS+On>O;#@0r{_bQ+6eE-trd&q%}G z2_t54xy5&KAM4reaN%my}O4w zP8$phOMD`8=e8e1L{yU=l&G^uxSh@G%#O9;;z|-ehDm4*BFSf+`)A>v1RT`OlIr<#;E@q@=9BW~5 znHCn~5qopJ%sK3m`QpTkrcbrI9~`HRzp8TJC-0_$azgA8zIV@Vsi8zzSX>evPb+WR zP%}akMJ%c5a8hW)!Drs<5$Izs z2HHzJjBiHh_x%#uaPX&F1E1#Z1*enX1cAgbS7;(nq&DOw1{B8=i49wBtgHPu0HJ&F zZAhNW9lzx5MK7U$w%reVFEt37p0_>sdy{O9AT@B4ZSUdt_H#J^=tZ8;D(_|ipbveW zEb?KEXgz={xMU2-O<&g0nSa?0aZI(R9`=737w`_03f*^IQQcJ{~s=+XEzFFasU7T07*qoM6N<$f@%Qi AivR!s literal 0 HcmV?d00001 diff --git a/nouri/static/brand/pwa-192.png b/nouri/static/brand/pwa-192.png index 24f0a95f71752a77bfb33095c3d4baa745166921..5b8cca972e8c7b6d630bf0ecdcabefa068f7948f 100644 GIT binary patch delta 3153 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 literal 10148 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 diff --git a/nouri/static/brand/pwa-512.png b/nouri/static/brand/pwa-512.png index 145a5cf01c24b29189438fc23f9548b1b4c09470..c6ef3b36ad7cd257f39d97568ba098aab2a82192 100644 GIT binary patch literal 7733 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 literal 4615 zcmV+i68P0eoVu2-uhd>I0mMKbug<=)xAN-*LT3RFpMg=S}M1zn@p^)f;39w|7eYhJoBs-hg znVq?F=icr<=jR{YeQ)2n_s-nDkJ(+BQ?p`TmJchv&Wx9@+8jQ&o^)69y@b^zGG&!=y~ z@efP)|KU4UGwxad`u6>|3;m5muz^oe{4DMpxxc9X%ZJ|6&AH<}AgOkApM<}GPrvxN zG_1rgOx=3-KdrQGD*zq**FCcFHzhW2;IrnkYvVW4Dly(mH{W{qp}coX6{LfIUzUyk zaRYzo_y|q=ia(=IyuFk+Z@mZDsrPq&+EGWi4g8+*v-nGvcI|jyp1e^7>9H?f?u>a7 zG}RrnL#9TJ?|I1|e2iz(LpI2yn*qQj>q#JstOZ$cQ4&8t@U!#^G5e<8_{Yxz1dS{h z?*rgg;FDW%o$!qZetuqXVDY<$}^P zAkz(QsQAWD&OFcS33Md*x zz<8MP&6x7!df+24irs4hV|4SHnHm(|7${9*Jilkq&Y4={IQzxV?;{w)cs{Pa2sUnC zW;poi{@u$xBe#E~XTIkrH@$O=Zw*xZ64sdR_*tGjA2Yrg{L%>Vg%j6%!t5Ctz8@r) zv@z%W%=et5;+J`F+ckW1Y6o*cc@0p58Q%ncc_jErs@ofY+d2~bpp-n}6aXC+eui*K zt-|)wBqxFij(TOT@fLV-{Vb)9ph+;}o1g}YYQx2skTF|5a-A(hJ@d~q{Os3P)fTYM z;+*A4Z=459Td5nb1AY^av1!O%F+;%zaBK=d<)PwxKBhD=bbK>DjhVg}Gj%a$Y%;l7 z21)gxIE1*4>J_wj8dsS?%V#^@aJR&DoO4*`aL#fH!~JH8H@LI-o$%A2&Db<@4%zx} z@RPsBf}!FYGhBS%$J=@Z-j3@qW7BjCrW`7v)_XqhR*m1gMsl)s5(Csd=TES@Cn9Y z2#=a(ax1~!SK(i}zi<4O;0FYk{|Mz9Uyqr(2qbpIb!>J8*k*O~NWAg|u{|HgOP}l* z93MyvU{HMHV}kO)_}-RX1lPR+GqJg#^4h^nZl(N!*Wqp7%|2Ug+z0a2(cu`v75Q(^ zhcKlHltvkN6*k_->&O8NL zt0ya4C&JDg#?BnU-+3Lul|S0?ikR4aGjCWv%Xj8xc+?u6ORJ-b6IspN4L{3^DNmxX z+BZHZ>y+;Y9~9&}_>1Ecj1la9S<(%;A}Fd79r`Nv)RVpAxBT8=Pd!C=@BtK7+qRGK zDD8bUFB~iL*72BM9CK_(gwFMZ-w36NzVT7xCfo-;!es3v{oq6Lq|@jOX$I9R#D^Y6 zwS}B+5&UM{Ezc8v_W@K}Y}=11O%Po95{$&&0DfcId7(_Tky7D)rNwNiI09gxycTQpY2=W65*k*ByY?sg1>Vg-t-PYi~~28{nl-) zf}fOtfznuC_+(X3VSFj+K<^VDgnwD*CmuUWw*Xa~gou|3*aPYGg?Cc5L+{w0mdRs0_nlbvoN1&ig z(!E{`UkI--J|s^Sed43!v9nuHeId8(@bI&===h^FZ|cnuTzY+f`02lI?GC&z1Y1(c))fL1SjdzVA_{kMe6yNhm z2G!)&>$Yr5JXuyzJ$<}v52`JU93O<6{{gDbwd~tf=TNlV++MnVPxuI9JZKDwZ2oRF zeCSjKt%i@10_Yb%jmO8nfhx1PahmDU%V!(wjS4?G6cHZ!cih4$a53)eDZ=kP*f)M- zcb;DeUo_f+dc;S7KM+0=s#S=Nd<}os3-B-5m&5b$Htj@GhnXXt@WC{9n8T-S+16uA zevVHZKv5O9yqFBk8U!C96Xd|j#XIAp4y~c9;)`^Mc>nmOJdJz2S>o71SGk@iQO5OkFtmS@5^) zq7Z(;Ge27|*CCVbXf6De9Tv^?I^lzI6Cf^}U`pc!@d2ko_{mMU@R{HFis^^6vq?;!Ejl#O8Xd;v?ju!9h9%g*EKiXGun?^%D&KmPbhngr4E2f+AR8CL zNBIt!9_xIi1<)%#2v@H(S{jEH{EMziUwj4+!HNzd)t7Mf#bWrVF)P1Ud|<7nh*tdM z3W$&<=oLR})pQG|@K@;xD;k0wyP10R?Zh*O!S;C%@H~R;R}!pfe~Qn1zc4-`wMjS9 z1HQ-&%`f1GxETP~6jp1kY(uSR*=fq8L8v3t!NwDPj$LDf#e4b5m{Wb?i)W7B_Qv0Gjm) z;WLkewT1A3WD-b0{PZSV3?E3oI#L8bO|z#Cfy?IJo_|4cNd4)@i{k?>TrP|cw2uv4 z6F)thS5E0)3m=5S8u7{R^kcIwp?>VYa8WugV1U>8qQ`~Kq;#Ye)1%k+Z}ZBM)HFwxAB{_y3n^=eC65qwxNFsl&0 z(w`fXA9}~nj>eukM0Dh9bQ-HU@*q)t;vwoMzugx;2)A5GPr&~0C7P|Fh4HhUM73mC zpYhw4T@63`+0GnB^(9LCUXyf!tV2*(W$8N)63;!|KYkqJ!sUVRA+3V?#c!n5D{ag6 zf}h3R*=GnJ`$94aZ^R)weU!yVK06S;1g^FSas%O`{7m^i@sW}znrOK(-KYqDb|4C? zgim}KZ`)OruD&UmR6WFC7f(|=@(9uF@#a1oyDx|zR+DG{T%P*D7pqKD?HND&eZ7)= zl*#i8ufj9`hY>cZe4h0{& zOj9k2->6Dz3+de4+(frk*P|`HG3WQp3F0%yF{N?5EtlbMxeRam;^ZrbrOuuFit4!P z9Cq<}?EFchGbfXNh{mo4!$-m{&9z+NgW#jamvaZlPhPSdsx2lTIm%_CD1KvmfWl?s z(?^NZCgLR3OFjtYjU{PuJ(++f*T`ra_@MZZ;5*B(@Uto?L@GB^`^0Y?Wr&s+lJ7kX zh~LayE>$2Lgmpx%1#YzX^SHFdb6EI@x+keJH2f@XLuky)9X5V5ZN2ba^(-!X>1^zN zV0^$WdImE$RQ&AVqFURGmkSZ!F3l&6Z9~Tw67wE%M~98yNQ-Ler^5wC2Zx) zh|c^*0U!1dhem>*Eo(zmolASsE)0Cxn0WE|QPlZ4eBlgFdImi*GW;y=ELxs#d^F>H z#&=;AyEr@ar=6Af0&sjIh`ivdz6%AIk>KajRj=TpS~64K3(hNi7sttHxHxQu9u>Zj zABPWn)3@Al3OF!o{KmoB2vz5C^_4A&=LTPL*yx=K?aJ35Y!f9MsWf%=>J>w_wdm!x*3;p;rpr!S}Z>}#1h0D0g zY|^?Ig)Ij;3VcO%;_6xK!f9-6@b>(^@WoPWxUW$Fn{T;m)`(B7D}MffND9I(&*K)K zNA-ntq=6kAUlteDam$OuXJ?3)=2mp2t~)*${pAh6_32s2|1U~W{4rDV-i9=_zW6KC zEUvx`q&;n3kjy|g0mk!^jvtf$uSwaQEGHzFv_;7x8z%#0UDWr8wGrYk2FCwb=F&2} zxca3(`dwrAlXHi!6&YTU)^_H%{@XmBF3{&1A7R9ud)|A`pJp~K4eb*Wd zioAhe6yIWW+x|Q5eR8FD$G*PFpZtR_NakG|_(Q>$lisoSJ@@`|C+n`Aty4eq`=2z1 zw}Rpg{Ql2;VcvQD`|ka#Zq92?vReA$-9Kw^pBCD*fnNxJ0i!o}JM**9(=4~z7eBby ziu-_99Xz+#fsEqCn!}4cueE(mc>Bn&>Go2Sdf9FmVycXQc9qdQCMzDkA|0pY8 x;P@e^B5~TF|Fwog#_ACQJ+$YY_sk4x_kXaJv_sExq~HJm002ovPDHLkV1k)*Mi>A9 diff --git a/nouri/static/brand/pwa-maskable-512.png b/nouri/static/brand/pwa-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..83ece23a1567e79d9256a60d6ef8d425c745eb60 GIT binary patch literal 8139 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 S 0; + + const scoreItem = (label, term) => { + if (label === term) return 0; + if (label.startsWith(term)) return 1; + if (label.split(/\s+/).some((part) => part.startsWith(term))) return 2; + if (label.includes(term)) return 3; + return 99; + }; + + const syncGroups = () => { + filterGroups.forEach((group) => { + const visibleChildren = Array.from(group.querySelectorAll("[data-filter-label]")).some((item) => !item.hidden); + const card = group.closest(".component-group, .template-list-card, .panel, .planner-subsection"); + if (card) { + card.hidden = !visibleChildren; + } else { + group.hidden = !visibleChildren; + } + }); + }; + const applyFilter = () => { const term = input.value.trim().toLowerCase(); + if (!term) { + items.forEach((item) => { + item.hidden = false; + }); + syncGroups(); + return; + } + + const rankedMatches = items + .map((item, index) => { + const haystack = (item.getAttribute("data-filter-label") || "").toLowerCase(); + const score = scoreItem(haystack, term); + return { item, index, score, matches: score < 99 }; + }) + .filter((entry) => entry.matches) + .sort((left, right) => left.score - right.score || left.index - right.index); + + const allowedItems = new Set( + (hasLimit ? rankedMatches.slice(0, resultLimit) : rankedMatches).map((entry) => entry.item) + ); + items.forEach((item) => { - const haystack = (item.getAttribute("data-filter-label") || "").toLowerCase(); - item.hidden = Boolean(term) && !haystack.includes(term); + item.hidden = !allowedItems.has(item); }); + syncGroups(); }; input.addEventListener("input", applyFilter); diff --git a/nouri/static/pwa/app.webmanifest b/nouri/static/pwa/app.webmanifest index 4a89c98..3e4d677 100644 --- a/nouri/static/pwa/app.webmanifest +++ b/nouri/static/pwa/app.webmanifest @@ -6,9 +6,16 @@ "start_url": "/", "scope": "/", "display": "standalone", + "display_override": ["standalone", "minimal-ui"], "background_color": "#fff6ef", - "theme_color": "#efab72", + "theme_color": "#de9862", + "categories": ["food", "lifestyle", "productivity"], "icons": [ + { + "src": "/static/brand/pwa-180.png", + "sizes": "180x180", + "type": "image/png" + }, { "src": "/static/brand/pwa-192.png", "sizes": "192x192", @@ -18,6 +25,12 @@ "src": "/static/brand/pwa-512.png", "sizes": "512x512", "type": "image/png" + }, + { + "src": "/static/brand/pwa-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } ] } diff --git a/nouri/static/pwa/offline.html b/nouri/static/pwa/offline.html new file mode 100644 index 0000000..d3a599a --- /dev/null +++ b/nouri/static/pwa/offline.html @@ -0,0 +1,79 @@ + + + + + + Nouri offline + + + +
+

Nouri ist gerade kurz offline

+

Die App bleibt da und versucht es gleich wieder. Sobald die Verbindung zurück ist, kannst du normal weitermachen.

+

Ein Teil der Oberfläche ist schon lokal verfügbar. Für aktuelle Haushaltsdaten braucht Nouri aber wieder eine Verbindung.

+
Erneut versuchen +
+ + diff --git a/nouri/static/pwa/service-worker.js b/nouri/static/pwa/service-worker.js index 599460a..3912614 100644 --- a/nouri/static/pwa/service-worker.js +++ b/nouri/static/pwa/service-worker.js @@ -1,15 +1,32 @@ -const CACHE_NAME = "nouri-v0-5-1-1"; +const CACHE_NAME = "nouri-v0-6-0"; +const OFFLINE_URL = "/static/pwa/offline.html"; const STATIC_ASSETS = [ "/static/css/styles.css", "/static/js/theme.js", "/static/js/ui.js", "/static/js/planner.js", "/static/js/pwa.js", + "/static/brand/pwa-180.png", "/static/brand/pwa-192.png", "/static/brand/pwa-512.png", + "/static/brand/pwa-maskable-512.png", + "/static/brand/pwa-badge.png", "/static/brand/favicon.svg", + "/app.webmanifest", + OFFLINE_URL, ]; +const cacheFirst = async (request) => { + const cached = await caches.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response && response.ok && response.type === "basic") { + const cache = await caches.open(CACHE_NAME); + cache.put(request, response.clone()); + } + return response; +}; + self.addEventListener("install", (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)).then(() => self.skipWaiting()) @@ -28,40 +45,32 @@ self.addEventListener("fetch", (event) => { if (event.request.method !== "GET") return; const requestUrl = new URL(event.request.url); - const isStaticAsset = - requestUrl.origin === self.location.origin && - ( - requestUrl.pathname.startsWith("/static/") - || requestUrl.pathname === "/app.webmanifest" - || requestUrl.pathname === "/service-worker.js" - ); + const isSameOrigin = requestUrl.origin === self.location.origin; + const isStaticAsset = isSameOrigin && ( + requestUrl.pathname.startsWith("/static/") + || requestUrl.pathname === "/app.webmanifest" + || requestUrl.pathname === "/service-worker.js" + ); + const isUpload = isSameOrigin && requestUrl.pathname.startsWith("/uploads/"); if (event.request.mode === "navigate") { event.respondWith( - fetch(event.request).catch(() => caches.match(event.request)) + fetch(event.request) + .then((response) => { + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy)); + return response; + }) + .catch(async () => { + return (await caches.match(event.request)) || caches.match(OFFLINE_URL); + }) ); return; } - if (!isStaticAsset) { - return; + if (isStaticAsset || isUpload) { + event.respondWith(cacheFirst(event.request)); } - - event.respondWith( - caches.match(event.request).then((cached) => { - if (cached) { - return cached; - } - return fetch(event.request).then((response) => { - if (!response || response.status !== 200 || response.type !== "basic") { - return response; - } - const clone = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); - return response; - }); - }) - ); }); self.addEventListener("push", (event) => { diff --git a/nouri/templates/archive/list.html b/nouri/templates/archive/list.html index 8f9ec94..1217820 100644 --- a/nouri/templates/archive/list.html +++ b/nouri/templates/archive/list.html @@ -44,7 +44,12 @@
{% if item.photo_filename %} - {{ item.name }} + {{ item.name }} {% else %}
{{ item.name[:1] }}
{% endif %} diff --git a/nouri/templates/auth/setup.html b/nouri/templates/auth/setup.html index 641b30c..607fd76 100644 --- a/nouri/templates/auth/setup.html +++ b/nouri/templates/auth/setup.html @@ -5,7 +5,22 @@

Erster Start

Den ersten Haushalt-Zugang anlegen

-

Danach könnt ihr Nouri gemeinsam nutzen. Persönliche und gemeinsame Einträge lassen sich später ruhig auseinanderhalten.

+

Danach könnt ihr Nouri gemeinsam nutzen. Der erste Einstieg bleibt bewusst klein: Zugang anlegen, Einkaufstag festlegen, erste Lebensmittel sammeln und bei Bedarf später eine Tagesvorlage merken.

+ +
+
+ 1. Ruhig starten +

Ein erster Haushalt und ein Admin-Zugang reichen für den Anfang völlig.

+
+
+ 2. Alltag festhalten +

Später könnt ihr Lebensmittel, Mahlzeitenideen und Planungen gemeinsam nutzen.

+
+
+ 3. Als App nutzen +

Auf dem iPhone lässt sich Nouri später über Safari zum Home-Bildschirm hinzufügen.

+
+
{{ csrf_input() }} diff --git a/nouri/templates/base.html b/nouri/templates/base.html index 7cb78f6..c6475e3 100644 --- a/nouri/templates/base.html +++ b/nouri/templates/base.html @@ -4,27 +4,29 @@ {% block title %}Nouri{% endblock %} - + + + - - + + - - - - - + + + + + +{% if setup_checklist %} +
+
+

Gut anfangen

+
+
+
+{% endif %} +
Zuhause diff --git a/nouri/templates/home/list.html b/nouri/templates/home/list.html index 5a5bdf6..742a083 100644 --- a/nouri/templates/home/list.html +++ b/nouri/templates/home/list.html @@ -72,7 +72,12 @@
{% if item.photo_filename %} - {{ item.name }} + {{ item.name }} {% else %}
{{ item.name[:1] }}
{% endif %} diff --git a/nouri/templates/items/form.html b/nouri/templates/items/form.html index ce0445b..edc8570 100644 --- a/nouri/templates/items/form.html +++ b/nouri/templates/items/form.html @@ -67,7 +67,12 @@ {% if item and item.photo_filename %}
- {{ item.name }} + {{ item.name }}
{% endif %} @@ -97,10 +102,12 @@ placeholder="z. B. Reis, Banane, Joghurt" data-filter-input data-filter-target="#meal-components-list" + data-filter-limit="3" >
+

Während der Suche zeigt Nouri nur die drei passendsten Lebensmittel, damit die Auswahl ruhig bleibt.

{% if food_groups %}
{% for group in food_groups %} diff --git a/nouri/templates/items/list.html b/nouri/templates/items/list.html index 57c3d63..bb3fc2f 100644 --- a/nouri/templates/items/list.html +++ b/nouri/templates/items/list.html @@ -54,7 +54,12 @@
{% if item.photo_filename %} - {{ item.name }} + {{ item.name }} {% else %}
{{ item.name[:1] }}
{% endif %} diff --git a/nouri/templates/settings.html b/nouri/templates/settings.html index 9d57c9c..06a40df 100644 --- a/nouri/templates/settings.html +++ b/nouri/templates/settings.html @@ -4,8 +4,8 @@

Optionen

-

Ruhige Einstellungen für Alltag, Einkauf und Erinnerungen

-

Hier lässt sich festlegen, wann Einkäufe vorbereitet werden, welche Hinweise hilfreich sind und ob Nouri sich wie eine App auf dem Home-Bildschirm verhalten soll.

+

Ruhige Einstellungen für Alltag, Sicherung und iPhone-Nutzung

+

Hier lässt sich festlegen, wann Einkäufe vorbereitet werden, welche Hinweise hilfreich sind und wie Nouri sich auf dem Home-Bildschirm oder beim Backup verhalten soll.

@@ -33,23 +33,29 @@ Erinnerung ungefähr um - +
+ +
-

Home-Bildschirm & Push

+

Für den Homescreen

- Als Web-App nutzen -

Auf dem iPhone kannst du Nouri über Teilen → Zum Home-Bildschirm hinzufügen. Danach wirkt die App deutlich app-näher.

+ Auf dem iPhone installieren +

Öffne Nouri in Safari, tippe auf Teilen und dann auf Zum Home-Bildschirm. Danach startet Nouri deutlich app-näher und ruhiger.

+
+
+ Offline etwas stabiler +

Die wichtigsten Oberflächen und Brand-Dateien bleiben lokal greifbar. Wenn das Netz kurz weg ist, wirkt die App dadurch stabiler und klarer.

Push-Mitteilungen {% if push_ready %} -

Push ist vorbereitet. Du kannst es auf diesem Gerät freigeben und später testweise prüfen.

+

Push ist vorbereitet. Wenn du möchtest, kannst du es auf diesem Gerät freigeben und mit einer Test-Mitteilung prüfen.

@@ -61,7 +67,10 @@ {{ push_subscription_count }} aktives Gerät{% if push_subscription_count != 1 %}e{% endif %} {% else %} -

Push wird sichtbar, sobald VAPID-Schlüssel für die App gesetzt sind.

+

Push wird sichtbar, sobald VAPID-Schlüssel für diese App gesetzt sind.

+ {% if push_public_key_value %} + Öffentlicher Schlüssel erkannt, privater Schlüssel fehlt noch. + {% endif %} {% endif %}
@@ -122,4 +131,45 @@
+ +{% if is_admin() %} +
+
+
+

Backup exportieren

+
+
+
+ Komplettes App-Backup +

Das ZIP enthält Nutzer, Einstellungen, Lebensmittel, Mahlzeiten, Vorlagen, Planungen, Einkaufsdaten und hochgeladene Bilder.

+ Backup herunterladen +
+
+
+ +
+
+

Backup wiederherstellen

+
+
+ {{ csrf_input() }} +
+ Nur bewusst verwenden +

Die Wiederherstellung ersetzt den aktuellen Datenstand dieses Haushalts. Vorher am besten selbst noch ein frisches Backup herunterladen.

+
+ + +
+ +
+
+
+
+{% endif %} {% endblock %} diff --git a/requirements.txt b/requirements.txt index 95cfb56..db71091 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ Flask==3.1.1 gunicorn==23.0.0 pywebpush==2.3.0 +Pillow==11.2.1 diff --git a/scripts/generate_brand_assets.py b/scripts/generate_brand_assets.py new file mode 100644 index 0000000..2237ba4 --- /dev/null +++ b/scripts/generate_brand_assets.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from pathlib import Path + +from PIL import Image, ImageDraw + + +ROOT = Path(__file__).resolve().parents[1] +BRAND_DIR = ROOT / "nouri" / "static" / "brand" + +BG_TOP = "#F6C394" +BG_BOTTOM = "#DE9862" +BOWL = "#FFF7EF" +STROKE = "#8C533B" + + +def rounded_gradient_icon(size: int, *, maskable: bool = False) -> Image.Image: + image = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + radius = int(size * (0.28 if maskable else 0.24)) + + for y in range(size): + blend = y / max(size - 1, 1) + r1, g1, b1 = ImageColorTuple(BG_TOP) + r2, g2, b2 = ImageColorTuple(BG_BOTTOM) + color = ( + int(r1 + (r2 - r1) * blend), + int(g1 + (g2 - g1) * blend), + int(b1 + (b2 - b1) * blend), + 255, + ) + draw.line((0, y, size, y), fill=color) + + mask = Image.new("L", (size, size), 0) + ImageDraw.Draw(mask).rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=255) + image.putalpha(mask) + + inset = int(size * (0.11 if maskable else 0.13)) + inner = [inset, inset, size - inset, size - inset] + draw.rounded_rectangle(inner, radius=int(radius * 0.84), outline=(255, 255, 255, 54), width=max(2, size // 80)) + + bowl_top = int(size * 0.24) + bowl_left = int(size * 0.27) + bowl_right = int(size * 0.73) + bowl_bottom = int(size * 0.68) + draw.rounded_rectangle( + (bowl_left, bowl_top, bowl_right, bowl_bottom), + radius=int(size * 0.16), + fill=BOWL, + ) + draw.rounded_rectangle( + (int(size * 0.31), int(size * 0.31), int(size * 0.69), int(size * 0.60)), + radius=int(size * 0.12), + fill=(239, 195, 159, 64), + ) + + bowl_curve_top = int(size * 0.56) + draw.pieslice( + (int(size * 0.24), bowl_curve_top, int(size * 0.76), int(size * 0.84)), + start=0, + end=180, + fill=(247, 179, 125, 255), + ) + + line_width = max(4, size // 26) + steam = [ + (0.50, 0.31), + (0.56, 0.31), + (0.56, 0.40), + (0.45, 0.50), + (0.45, 0.58), + ] + draw.line([(int(size * x), int(size * y)) for x, y in steam], fill=STROKE, width=line_width, joint="curve") + draw.line( + [(int(size * 0.45), int(size * 0.58)), (int(size * 0.56), int(size * 0.58))], + fill=STROKE, + width=line_width, + ) + return image + + +def ImageColorTuple(hex_color: str) -> tuple[int, int, int]: + hex_color = hex_color.lstrip("#") + return tuple(int(hex_color[index:index + 2], 16) for index in (0, 2, 4)) + + +def badge_icon(size: int) -> Image.Image: + image = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((0, 0, size - 1, size - 1), radius=size // 3, fill=BG_BOTTOM) + draw.ellipse((size * 0.18, size * 0.18, size * 0.82, size * 0.82), fill=BOWL) + draw.rectangle((size * 0.34, size * 0.52, size * 0.66, size * 0.63), fill=STROKE) + return image + + +def save_assets() -> None: + BRAND_DIR.mkdir(parents=True, exist_ok=True) + rounded_gradient_icon(180).save(BRAND_DIR / "pwa-180.png") + rounded_gradient_icon(192).save(BRAND_DIR / "pwa-192.png") + rounded_gradient_icon(512).save(BRAND_DIR / "pwa-512.png") + rounded_gradient_icon(512, maskable=True).save(BRAND_DIR / "pwa-maskable-512.png") + badge_icon(96).save(BRAND_DIR / "pwa-badge.png") + + +if __name__ == "__main__": + save_assets()