diff --git a/backend/app/config.py b/backend/app/config.py index af7f2bb3f..bf2d6318d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -53,6 +53,7 @@ def get_secret(env_var: str, default: str = None) -> str | None: STORAGE_PATH = os.getenv("STORAGE_PATH", PROJECT_DIR) UPLOAD_FOLDER = STORAGE_PATH + "/upload" +DEFAULT_MAX_CONTENT_LENGTH_MB = 32 ALLOWED_FILE_EXTENSIONS = {"txt", "pdf", "png", "jpg", "jpeg", "gif", "webp", "jxl"} FRONT_URL = os.getenv("FRONT_URL") @@ -153,7 +154,13 @@ def get_secret(env_var: str, default: str = None) -> str | None: jwt_secret = get_secret("JWT_SECRET_KEY", "super-secret") app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER -app.config["MAX_CONTENT_LENGTH"] = 32 * 1000 * 1000 # 32MB max upload +try: + max_upload_mb = int( + os.getenv("MAX_CONTENT_LENGTH_MB", str(DEFAULT_MAX_CONTENT_LENGTH_MB)) + ) +except (TypeError, ValueError): + max_upload_mb = DEFAULT_MAX_CONTENT_LENGTH_MB +app.config["MAX_CONTENT_LENGTH"] = max_upload_mb * 1024 * 1024 app.config["SECRET_KEY"] = jwt_secret # SQLAlchemy app.config["SQLALCHEMY_DATABASE_URI"] = DB_URL diff --git a/backend/app/controller/exportimport/import_controller.py b/backend/app/controller/exportimport/import_controller.py index 8cdae92bf..d287b022e 100644 --- a/backend/app/controller/exportimport/import_controller.py +++ b/backend/app/controller/exportimport/import_controller.py @@ -8,10 +8,15 @@ importShoppinglist, ) from app.service.recalculate_balances import recalculateBalances -from .schemas import ImportSchema +from .schemas import ImportSchema, RecipeImportCommitSchema from app.helpers import validate_args, authorize_household -from flask import jsonify, Blueprint +from flask import jsonify, Blueprint, request, abort from flask_jwt_extended import jwt_required +from app.service.recipe_import_service import ( + preview_recipe_import, + commit_recipe_import, + get_recipe_import_job, +) importBP = Blueprint("import", __name__) @@ -51,3 +56,45 @@ def importData(args, household_id): app.logger.info(f"Import took: {(time.time() - t0):.3f}s") return jsonify({"msg": "DONE"}) + + +@importBP.route("/recipes/preview", methods=["POST"]) +@jwt_required() +@authorize_household() +def previewRecipeImport(household_id): + if "file" not in request.files: + return jsonify({"msg": "missing file"}), 400 + file = request.files["file"] + if not file.filename: + return jsonify({"msg": "missing filename"}), 400 + filename = file.filename.lower() + if not ( + filename.endswith(".json") + or filename.endswith(".zip") + or filename.endswith(".paprikarecipes") + ): + return jsonify({"msg": "unsupported file"}), 400 + + data = file.read() + res = preview_recipe_import(household_id, data, filename) + return jsonify(res) + + +@importBP.route("/recipes/commit", methods=["POST"]) +@jwt_required() +@authorize_household() +@validate_args(RecipeImportCommitSchema) +def commitRecipeImport(args, household_id): + decisions = args.get("decisions", {}) + res = commit_recipe_import(household_id, args["token"], decisions) + return jsonify(res) + + +@importBP.route("/recipes/commit/", methods=["GET"]) +@jwt_required() +@authorize_household() +def getRecipeImportCommitStatus(household_id, token): + res = get_recipe_import_job(token) + if res is None: + abort(404, description="Import job not found or token has expired") + return jsonify(res) diff --git a/backend/app/controller/exportimport/schemas.py b/backend/app/controller/exportimport/schemas.py index 47a077ed9..dccedefa8 100644 --- a/backend/app/controller/exportimport/schemas.py +++ b/backend/app/controller/exportimport/schemas.py @@ -29,6 +29,7 @@ class RecipeItem(Schema): yields = fields.Integer(allow_none=True) source = fields.String(allow_none=True) photo = fields.String(allow_none=True) + photos = fields.List(fields.String(), load_default=[]) items = fields.List(fields.Nested(RecipeItem)) tags = fields.List(fields.String()) @@ -62,3 +63,12 @@ class Category(Schema): expenses = fields.List(fields.Nested(Expense)) member = fields.List(fields.String()) shoppinglists = fields.List(fields.String()) + + +class RecipeImportCommitSchema(Schema): + token = fields.String(required=True, validate=lambda a: a and not a.isspace()) + decisions = fields.Dict( + keys=fields.String(), + values=fields.String(), + load_default={}, + ) diff --git a/backend/app/controller/mcp_controller.py b/backend/app/controller/mcp_controller.py index a19f52f6c..dff7d3e50 100644 --- a/backend/app/controller/mcp_controller.py +++ b/backend/app/controller/mcp_controller.py @@ -37,7 +37,9 @@ def _jsonrpc_ok(id_value: Any, result: Any): def _jsonrpc_err(id_value: Any, code: int, message: str): - return jsonify({"jsonrpc": "2.0", "id": id_value, "error": {"code": code, "message": message}}) + return jsonify( + {"jsonrpc": "2.0", "id": id_value, "error": {"code": code, "message": message}} + ) def _as_tool_result(payload: Any): @@ -62,7 +64,11 @@ def _tool_list_households(_args: dict[str, Any]) -> Any: def _tool_list_shoppinglists(args: dict[str, Any]) -> Any: household_id = int(args["household_id"]) _require_household_access(household_id) - return {"items": [e.obj_to_dict() for e in Shoppinglist.all_from_household(household_id)]} + return { + "items": [ + e.obj_to_dict() for e in Shoppinglist.all_from_household(household_id) + ] + } def _tool_list_shoppinglist_items(args: dict[str, Any]) -> Any: @@ -108,7 +114,11 @@ def _tool_add_item_by_name(args: dict[str, Any]) -> Any: def _tool_list_recipes(args: dict[str, Any]) -> Any: household_id = int(args["household_id"]) _require_household_access(household_id) - recipes = Recipe.query.filter(Recipe.household_id == household_id).order_by(Recipe.name).all() + recipes = ( + Recipe.query.filter(Recipe.household_id == household_id) + .order_by(Recipe.name) + .all() + ) return {"items": [r.obj_to_full_dict() for r in recipes]} @@ -150,7 +160,7 @@ def _tool_create_recipe(args: dict[str, Any]) -> Any: recipe.save() - for recipe_item in (args.get("items") or []): + for recipe_item in args.get("items") or []: if isinstance(recipe_item, str): item_name = recipe_item item_description = "" @@ -172,7 +182,7 @@ def _tool_create_recipe(args: dict[str, Any]) -> Any: con.recipe = recipe con.save() - for tag_name in (args.get("tags") or []): + for tag_name in args.get("tags") or []: name = str(tag_name).strip() if not name: continue @@ -404,9 +414,13 @@ def _tool_update_recipe(args: dict[str, Any]) -> Any: if "time" in args: recipe.time = int(args["time"]) if args["time"] is not None else None if "cook_time" in args: - recipe.cook_time = int(args["cook_time"]) if args["cook_time"] is not None else None + recipe.cook_time = ( + int(args["cook_time"]) if args["cook_time"] is not None else None + ) if "prep_time" in args: - recipe.prep_time = int(args["prep_time"]) if args["prep_time"] is not None else None + recipe.prep_time = ( + int(args["prep_time"]) if args["prep_time"] is not None else None + ) if "yields" in args: recipe.yields = int(args["yields"]) if args["yields"] is not None else None if "source" in args: @@ -469,7 +483,9 @@ def _tool_add_planner_entry(args: dict[str, Any]) -> Any: if not recipe or recipe.household_id != household_id: raise NotFoundRequest() - cooking_date = datetime.fromisoformat(str(args["cooking_date"]).replace("Z", "+00:00")) + cooking_date = datetime.fromisoformat( + str(args["cooking_date"]).replace("Z", "+00:00") + ) existing = Planner.query.filter( Planner.household_id == household_id, @@ -492,7 +508,9 @@ def _tool_add_planner_entry(args: dict[str, Any]) -> Any: def _tool_remove_planner_entry(args: dict[str, Any]) -> Any: household_id = int(args["household_id"]) recipe_id = int(args["recipe_id"]) - cooking_date = datetime.fromisoformat(str(args["cooking_date"]).replace("Z", "+00:00")) + cooking_date = datetime.fromisoformat( + str(args["cooking_date"]).replace("Z", "+00:00") + ) _require_household_access(household_id) plan = Planner.query.filter( @@ -504,7 +522,12 @@ def _tool_remove_planner_entry(args: dict[str, Any]) -> Any: return {"removed": False, "reason": "not_found"} plan.delete() - return {"removed": True, "household_id": household_id, "recipe_id": recipe_id, "cooking_date": cooking_date} + return { + "removed": True, + "household_id": household_id, + "recipe_id": recipe_id, + "cooking_date": cooking_date, + } def _tool_list_planner(args: dict[str, Any]) -> Any: @@ -851,7 +874,10 @@ def _handle_jsonrpc(body: dict[str, Any]): { "protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, - "serverInfo": {"name": "kitchenowl-mcp", "version": str(BACKEND_VERSION)}, + "serverInfo": { + "name": "kitchenowl-mcp", + "version": str(BACKEND_VERSION), + }, }, ) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 5211ef906..b21bcf753 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -102,8 +102,16 @@ def addRecipe(args, household_id): sourceRecipe.save() if "visibility" in args: recipe.visibility = RecipeVisibility(args["visibility"]) - if "photo" in args and args["photo"] != recipe.photo: + if "photos" in args: + recipe.photos = [ + file_has_access_or_download(photo, user=current_user) + for photo in args["photos"] + if photo + ] + recipe.photo = recipe.photos[0] if recipe.photos else None + elif "photo" in args: recipe.photo = file_has_access_or_download(args["photo"], recipe.photo) + recipe.photos = [recipe.photo] if recipe.photo else [] if "server_curated" in args and current_user.admin: recipe.server_curated = args["server_curated"] recipe.save() @@ -155,14 +163,22 @@ def updateRecipe(args, id): # noqa: C901 recipe.source = args["source"] if "visibility" in args: recipe.visibility = RecipeVisibility(args["visibility"]) - if "photo" in args and args["photo"] != recipe.photo: + if "photos" in args: + recipe.photos = [ + file_has_access_or_download(photo, user=current_user) + for photo in args["photos"] + if photo + ] + recipe.photo = recipe.photos[0] if recipe.photos else None + elif "photo" in args: recipe.photo = file_has_access_or_download(args["photo"], recipe.photo) + recipe.photos = [recipe.photo] if recipe.photo else [] if "server_curated" in args and current_user.admin: recipe.server_curated = args["server_curated"] recipe.save() if "items" in args: + item_names = [e["name"] for e in args["items"]] for con in recipe.items: - item_names = [e["name"] for e in args["items"]] if con.item.name not in item_names: con.delete() for recipeItem in args["items"]: diff --git a/backend/app/controller/recipe/schemas.py b/backend/app/controller/recipe/schemas.py index c323aaae6..302282f92 100644 --- a/backend/app/controller/recipe/schemas.py +++ b/backend/app/controller/recipe/schemas.py @@ -19,6 +19,7 @@ class RecipeItem(Schema): source = fields.String() server_curated = fields.Boolean() photo = fields.String() + photos = fields.List(fields.String()) visibility = fields.Integer(validate=lambda a: a >= 0) items = fields.List(fields.Nested(RecipeItem())) tags = fields.List(fields.String()) @@ -42,6 +43,7 @@ class RecipeItem(Schema): source = fields.String() server_curated = fields.Boolean() photo = fields.String() + photos = fields.List(fields.String()) visibility = fields.Integer(validate=lambda a: a >= 0) items = fields.List(fields.Nested(RecipeItem())) tags = fields.List(fields.String()) diff --git a/backend/app/models/item.py b/backend/app/models/item.py index 179d94b38..4977e8692 100644 --- a/backend/app/models/item.py +++ b/backend/app/models/item.py @@ -192,12 +192,14 @@ def find_by_default_key(cls, household_id: int, default_key: str) -> Self | None @classmethod def find_name_starts_with(cls, household_id: int, starts_with: str) -> Self | None: starts_with = starts_with.strip() - return (cls.query.filter( - cls.household_id == household_id, - func.lower(cls.name).like(func.lower(starts_with) + "%"), + return ( + cls.query.filter( + cls.household_id == household_id, + func.lower(cls.name).like(func.lower(starts_with) + "%"), + ) + .order_by(cls.name) + .first() ) - .order_by(cls.name) - .first()) @classmethod def search_name(cls, name: str, household_id: int) -> list[Self]: diff --git a/backend/app/models/recipe.py b/backend/app/models/recipe.py index a8bdfc5a5..cab939433 100644 --- a/backend/app/models/recipe.py +++ b/backend/app/models/recipe.py @@ -50,6 +50,7 @@ class Recipe(Model, DbModelAuthorizeMixin): name: Mapped[str] = db.Column(db.String(128)) description: Mapped[str] = db.Column(db.String()) photo: Mapped[str | None] = db.Column(db.String(), db.ForeignKey("file.filename")) + photos: Mapped[list[str]] = db.Column(db.JSON, nullable=False, default=list) time: Mapped[int] = db.Column(db.Integer) cook_time: Mapped[int] = db.Column(db.Integer) prep_time: Mapped[int] = db.Column(db.Integer) @@ -138,6 +139,7 @@ def obj_to_dict( include_columns: list[str] | None = None, ) -> dict[str, Any]: res = super().obj_to_dict(skip_columns, include_columns) + res["photos"] = list(self.photos or []) res["planned"] = len(self.plans) > 0 res["planned_days"] = [ transform_cooking_date_to_day(plan.cooking_date) @@ -164,7 +166,9 @@ def obj_to_full_dict( res = self.obj_to_dict(skip_columns, include_columns) res["items"] = [e.obj_to_item_dict() for e in self.items] res["tags"] = [e.obj_to_item_dict() for e in self.tags] - res["household"] = self.household.obj_to_public_dict() + res["household"] = ( + self.household.obj_to_public_dict() if self.household else None + ) for column_name in skip_columns or []: if column_name in res: @@ -189,6 +193,7 @@ def obj_to_export_dict(self) -> dict[str, Any]: "description": self.description, "time": self.time, "photo": self.photo, + "photos": list(self.photos or []), "cook_time": self.cook_time, "prep_time": self.prep_time, "yields": self.yields, diff --git a/backend/app/service/importRecipes/json.py b/backend/app/service/importRecipes/json.py new file mode 100644 index 000000000..744704b15 --- /dev/null +++ b/backend/app/service/importRecipes/json.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from typing import Any +from app.service.importRecipes.utils import ( + normalize_text, + normalize_instruction_step, + normalize_int, + normalize_items, + parse_time, +) + + +def _normalize_recipe(raw: dict[str, Any]) -> dict[str, Any] | None: + name = normalize_text(raw.get("name") or raw.get("title") or raw.get("headline")) + if not name: + return None + + description = normalize_text(raw.get("description") or "") + instructions = ( + raw.get("recipeInstructions") or raw.get("instructions") or raw.get("method") + ) + if isinstance(instructions, list): + steps = [] + for entry in instructions: + raw_text = ( + entry.get("text") or entry.get("instruction") or entry.get("value") + if isinstance(entry, dict) + else entry + ) + text = normalize_instruction_step(raw_text) + if text: + steps.append(text) + + if steps: + description = (description + "\n\n" if description else "") + "\n".join( + f"{idx + 1}. {step}" for idx, step in enumerate(steps) + ) + else: + instructions_text = normalize_text(instructions) + if instructions_text: + description = ( + description + "\n\n" if description else "" + ) + instructions_text + + recipe: dict[str, Any] = { + "name": name, + "description": description, + "cook_time": parse_time(raw, "cook_time", "cookTime"), + "prep_time": parse_time(raw, "prep_time", "prepTime"), + "time": parse_time(raw, "time", "total_time", "totalTime"), + "yields": normalize_int( + raw.get("yields") + or raw.get("servings") + or raw.get("persons_served") + or raw.get("recipeYield") + ), + "source": normalize_text(raw.get("source") or raw.get("url")), + "cuisine": normalize_text(raw.get("cuisine") or raw.get("recipeCuisine")), + "items": normalize_items( + raw.get("items") or raw.get("ingredients") or raw.get("recipeIngredient") + ), + } + + if recipe["time"] is None: + prep = recipe.get("prep_time") or 0 + cook = recipe.get("cook_time") or 0 + if (prep + cook) > 0: + recipe["time"] = prep + cook + + photo = normalize_text( + raw.get("photo") or raw.get("image") or raw.get("preview_picture") + ) + if photo: + recipe["photo"] = photo + photos = [ + normalize_text(photo) + for photo in (raw.get("photos") or raw.get("images") or []) + if normalize_text(photo) + ] + if photos: + recipe["photos"] = photos + + raw_tags = ( + raw.get("tags") or raw.get("categories") or raw.get("recipeCategory") or [] + ) + if isinstance(raw_tags, str): + raw_tags = [t.strip() for t in raw_tags.split(",")] + + tags = [normalize_text(tag) for tag in raw_tags if normalize_text(tag)] + if tags: + recipe["tags"] = tags + + nutrition = raw.get("nutrition") + if isinstance(nutrition, dict): + recipe["nutrition"] = nutrition + + return recipe + + +def json_extract_recipes(payload: Any) -> list[dict[str, Any]]: + recipes: list[dict[str, Any]] = [] + + def add_if_valid(entry: Any): + if isinstance(entry, dict): + normalized = _normalize_recipe(entry) + if normalized: + recipes.append(normalized) + + if isinstance(payload, dict): + candidates = ( + payload.get("recipes") or payload.get("data") or payload.get("@graph") + ) + if isinstance(candidates, list): + for entry in candidates: + add_if_valid(entry) + else: + add_if_valid(payload) + + elif isinstance(payload, list): + for entry in payload: + add_if_valid(entry) + + return recipes diff --git a/backend/app/service/importRecipes/mealie.py b/backend/app/service/importRecipes/mealie.py new file mode 100644 index 000000000..4bd1fcab5 --- /dev/null +++ b/backend/app/service/importRecipes/mealie.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import os +import re +import zipfile +from collections import defaultdict +from typing import Any + +from app.service.importRecipes.utils import ( + maybe_decode_json_payload, + normalize_text, + normalize_id, + parse_time, +) +from app.util.filename_validator import allowed_file + + +def parse_mealie_zip( + zf: zipfile.ZipFile, zip_entries: dict[str, str] +) -> list[dict[str, Any]]: + try: + database_file = zip_entries.get("database.json") + if database_file is None: + return [] + data = zf.read(database_file) + except Exception: + return [] + + payload = maybe_decode_json_payload(data) or {} + if not isinstance(payload, dict): + return [] + + def table(name: str) -> list[dict[str, Any]]: + v = payload.get(name) + return v if isinstance(v, list) else [] + + # Base tables + recipes_table = table("recipes") + instr_table = table("recipe_instructions") + ingred_table = ( + table("recipes_ingredients") + or table("recipe_ingredients") + or table("recipe_ingredient") + ) + + # Lookup dictionaries + foods_table = {normalize_id(e.get("id")): e for e in table("ingredient_foods")} + units_table = {normalize_id(e.get("id")): e for e in table("ingredient_units")} + nutrition_table = { + normalize_id(r.get("recipe_id")): r for r in table("recipe_nutrition") + } + tags_table = {normalize_id(t.get("id")): t for t in table("tags")} + categories_table = {normalize_id(c.get("id")): c for c in table("categories")} + + instr_by_recipe = defaultdict(list) + for step in instr_table: + r_id = normalize_id(step.get("recipe_id")) + if r_id: + instr_by_recipe[r_id].append(step) + + ingred_by_recipe = defaultdict(list) + for ing in ingred_table: + r_id = normalize_id(ing.get("recipe_id")) + if r_id: + ingred_by_recipe[r_id].append(ing) + + tags_by_recipe = defaultdict(list) + for t in table("recipes_to_tags"): + r_id = normalize_id(t.get("recipe_id")) + if r_id: + tags_by_recipe[r_id].append(t.get("tag_id")) + + cats_by_recipe = defaultdict(list) + for c in table("recipes_to_categories"): + r_id = normalize_id(c.get("recipe_id")) + if r_id: + cats_by_recipe[r_id].append(c.get("category_id")) + + images_by_recipe = {} + for zip_key, entry in zip_entries.items(): + zip_key_lower = zip_key.lower() + if ( + "/recipes/" in zip_key_lower + and os.path.basename(zip_key_lower).startswith("original.") + and allowed_file(entry) + ): + try: + rid_part = zip_key_lower.split("/recipes/")[1].split("/")[0] + images_by_recipe[normalize_id(rid_part)] = entry + except IndexError: + pass + + recipes: list[dict[str, Any]] = [] + for r in recipes_table: + rid = r.get("id") + rid_key = normalize_id(rid) + name_val = normalize_text(r.get("name") or r.get("title")) + if not name_val: + continue + description = normalize_text(r.get("description")) + if "demo.mealie.io" in description.lower(): + continue + + recipe: dict[str, Any] = {} + recipe["id"] = rid + recipe["name"] = name_val + recipe["description"] = description + recipe["source"] = normalize_text( + r.get("org_url") or r.get("source_url") or r.get("source") + ) + recipe["slug"] = normalize_text(r.get("slug")) + recipe["rating"] = r.get("rating") + recipe["cuisine"] = normalize_text(r.get("recipeCuisine") or r.get("cuisine")) + + recipe["prep_time"] = parse_time(r, "prep_time", "prepTime", "prepTimeStr") + recipe["cook_time"] = parse_time(r, "cook_time", "cookTime", "cookTimeStr") + recipe["perform_time"] = parse_time( + r, "perform_time", "performTime", "performTimeStr" + ) + if recipe["cook_time"] is None: + recipe["cook_time"] = recipe["perform_time"] + recipe["time"] = parse_time( + r, "time", "total_time", "totalTime", "totalTimeStr" + ) + if recipe["time"] is None: + prep = recipe.get("prep_time") or 0 + cook = recipe.get("cook_time") or 0 + recipe["time"] = (prep + cook) if (prep + cook) > 0 else None + + servings = r.get("recipe_servings") + if servings is not None: + try: + recipe["yields"] = int(round(float(servings))) + except Exception: + recipe["yields"] = None + else: + recipe["yields"] = normalize_text(r.get("recipe_yield")) + + # Grab pre-grouped instructions + steps = instr_by_recipe.get(rid_key, []) + steps.sort(key=lambda s: s.get("position") or 0) + instrs: list[str] = [] + for idx, s in enumerate(steps, start=1): + title = normalize_text(s.get("title")) + text = normalize_text( + s.get("text") or s.get("value") or s.get("instruction") + ) + if title: + instrs.append(title) + if text: + # Strip existing starting numbers/punctuation + clean_text = re.sub(r"^\d+[\.\)]\s*", "", text) + instrs.append(f"{idx}. {clean_text}") + if instrs: + recipe["description"] = ( + (recipe.get("description") + "\n\n") + if recipe.get("description") + else "" + ) + "\n".join(instrs) + + # Grab pre-grouped ingredients + ing = ingred_by_recipe.get(rid_key, []) + ing.sort(key=lambda s: s.get("position") or 0) + items: list[dict[str, Any]] = [] + for it in ing: + qty = it.get("quantity") + note = normalize_text(it.get("note")) + orig = normalize_text(it.get("original_text")) + food = foods_table.get(normalize_id(it.get("food_id"))) + food_name = normalize_text( + food.get("name") if isinstance(food, dict) else None + ) + unit = units_table.get(normalize_id(it.get("unit_id"))) + unit_name = None + if isinstance(unit, dict): + use_abbr = unit.get("use_abbreviation") + unit_name = unit.get("abbreviation") if use_abbr else unit.get("name") + parts = [] + if qty not in (None, 0, 0.0): + parts.append(str(qty)) + if unit_name: + parts.append(str(unit_name)) + if note: + parts.append(note) + desc = " ".join(parts).strip() + name = ( + food_name + or orig + or normalize_text(it.get("name") or it.get("ingredient")) + ) + if not name and note: + name = note + desc = "" + if not name: + continue + items.append({"name": name, "description": desc, "optional": False}) + if items: + recipe["items"] = items + + nut = nutrition_table.get(rid_key) + if isinstance(nut, dict): + recipe["nutrition"] = { + k: nut.get(k) + for k in ( + "calories", + "protein_content", + "fat_content", + "carbohydrate_content", + "fiber_content", + "sodium_content", + "sugar_content", + ) + if k in nut + } + + # Grab pre-grouped tags + tag_ids = tags_by_recipe.get(rid_key, []) + tags = [ + tags_table.get(normalize_id(tid) if tid is not None else None, {}).get( + "name" + ) + for tid in tag_ids + ] + tags = [t for t in tags if t] + if tags: + recipe["tags"] = tags + + # Grab pre-grouped categories + cat_ids = cats_by_recipe.get(rid_key, []) + cats = [ + categories_table.get( + normalize_id(cid) if cid is not None else None, {} + ).get("name") + for cid in cat_ids + ] + cats = [c for c in cats if c] + if cats: + recipe["categories"] = cats + + # Grab pre-mapped image + mapped_photo = images_by_recipe.get(rid_key) + if mapped_photo: + recipe["photo"] = mapped_photo + + recipes.append(recipe) + + return recipes diff --git a/backend/app/service/importRecipes/tandoor.py b/backend/app/service/importRecipes/tandoor.py new file mode 100644 index 000000000..c3157751e --- /dev/null +++ b/backend/app/service/importRecipes/tandoor.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import io +import os +import re +import uuid +import zipfile +from typing import Any + +from app.service.importRecipes.utils import ( + maybe_decode_json_payload, + normalize_text, + normalize_instruction_step, + parse_time, +) +from app import app + + +def _parse_tandoor_recipe( + payload: dict[str, Any], inner: zipfile.ZipFile | None, images_dir: str +) -> dict[str, Any] | None: + name = normalize_text(payload.get("name")) + if not name: + return None + + rec: dict[str, Any] = {} + rec["name"] = name + rec["description"] = normalize_text(payload.get("description")) + rec["source"] = normalize_text(payload.get("source_url") or payload.get("source")) + + servings = payload.get("servings") + if isinstance(servings, (int, float)): + rec["yields"] = round(servings) + else: + rec["yields"] = normalize_text(payload.get("servings_text")) or None + + rec["prep_time"] = parse_time(payload, "working_time") + rec["cook_time"] = parse_time(payload, "waiting_time") + + prep = rec.get("prep_time") or 0 + cook = rec.get("cook_time") or 0 + rec["time"] = (prep + cook) or None + + kws = payload.get("keywords") or [] + tags = [] + for k in kws: + tag = normalize_text(k.get("name") if isinstance(k, dict) else k) + if tag: + tags.append(tag) + if tags: + rec["tags"] = tags + + steps = payload.get("steps") or [] + steps = [s for s in steps if isinstance(s, dict)] + steps.sort(key=lambda s: s.get("order") or 0) + + instr_lines: list[tuple[str, str]] = [] + items: list[dict[str, Any]] = [] + + for s in steps: + section = normalize_instruction_step(s.get("name")) + if section: + instr_lines.append(("header", section)) + raw_instruction = normalize_instruction_step(s.get("instruction")) + if raw_instruction: + cleaned = re.sub(r"\n{2,}", "\n", raw_instruction.strip()) + instr_lines.append(("step", cleaned)) + ingrs = s.get("ingredients") or [] + for ing in sorted( + [i for i in ingrs if isinstance(i, dict)], key=lambda x: x.get("order") or 0 + ): + if ing.get("is_header"): + continue + food = ing.get("food") or {} + food_name = normalize_text( + food.get("name") if isinstance(food, dict) else ing.get("name") + ) + if not food_name: + continue + unit = ing.get("unit") or {} + unit_name = unit.get("name") if isinstance(unit, dict) else None + amount = ing.get("amount") if not ing.get("no_amount") else None + note = normalize_text(ing.get("note") or "") + parts = [str(p) for p in (amount, unit_name, note) if p not in (None, "")] + + items.append( + { + "name": food_name, + "description": " ".join(parts).strip(), + "optional": False, + } + ) + + seen: set[str] = set() + step_counter = 0 + assembled: list[str] = [] + for kind, text in instr_lines: + if kind == "header": + assembled.append(text) + else: + if text in seen: + continue + seen.add(text) + step_counter += 1 + assembled.append(f"{step_counter}. {text}") + + if assembled: + base = rec.get("description", "").strip() + rec["description"] = (base + "\n\n" if base else "") + "\n".join(assembled) + + if items: + rec["items"] = items + + if inner is not None: + image_name = next( + (n for n in inner.namelist() if os.path.basename(n).lower() == "image.jpg"), + None, + ) + if image_name: + try: + img_bytes = inner.read(image_name) + fname = f"{uuid.uuid4()}_{os.path.basename(image_name)}" + path = os.path.join(images_dir, fname) + with open(path, "wb") as fh: + fh.write(img_bytes) + rec["photo_temp"] = fname + except Exception: + app.logger.warning( + "Failed to extract Tandoor image: %s", image_name, exc_info=True + ) + + return rec + + +def parse_tandoor_zip( + zf: zipfile.ZipFile, + entry_names: list[str], + zip_entries: dict[str, str], + images_dir: str, +) -> list[dict[str, Any]]: + recipes: list[dict[str, Any]] = [] + root_zips = [ + n + for n in entry_names + if os.path.dirname(n) == "" and n.lower().endswith(".zip") + ] + + if not root_zips: + flat_entry = zip_entries.get("recipes.json") + if flat_entry: + try: + payload = maybe_decode_json_payload(zf.read(flat_entry)) + if isinstance(payload, list): + for item in payload: + if isinstance(item, dict): + rec = _parse_tandoor_recipe( + item, inner=None, images_dir=images_dir + ) + if rec: + recipes.append(rec) + except Exception: + app.logger.warning( + "Tandoor flat recipes.json parse failed", exc_info=True + ) + return recipes + + for zname in root_zips: + try: + inner_bytes = zf.read(zname) + with zipfile.ZipFile(io.BytesIO(inner_bytes)) as inner: + recipe_json_name = next( + (n for n in inner.namelist() if n.lower() == "recipe.json"), None + ) + if not recipe_json_name: + continue + payload = maybe_decode_json_payload(inner.read(recipe_json_name)) + if not isinstance(payload, dict): + continue + rec = _parse_tandoor_recipe(payload, inner=inner, images_dir=images_dir) + if rec: + recipes.append(rec) + except Exception: + app.logger.warning( + "Failed to parse Tandoor inner zip: %s", zname, exc_info=True + ) + continue + + return recipes diff --git a/backend/app/service/importRecipes/utils.py b/backend/app/service/importRecipes/utils.py new file mode 100644 index 000000000..d857a85c4 --- /dev/null +++ b/backend/app/service/importRecipes/utils.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import gzip +import json +import re +from typing import Any + + +def normalize_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def normalize_id(value: Any) -> str | None: + if value is None: + return None + return str(value).lower().replace("-", "").strip() + + +def normalize_instruction_step(value: Any) -> str: + text = normalize_text(value) + if not text: + return "" + return re.sub(r"^(?:\d+\s*[\.)]\s+)+", "", text) + + +def normalize_int(value: Any) -> int | None: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _parse_minutes(value: Any) -> int | None: + if value is None: + return None + if isinstance(value, (int, float)): + return int(value) + match = re.search(r"\d+", str(value)) + if match: + return int(match.group(0)) + return None + + +def parse_time(payload: dict[str, Any], *keys: str) -> int | None: + for k in keys: + val = payload.get(k) + if val: + res = normalize_int(val) or _parse_minutes(val) + if res is not None: + return res + return None + + +def normalize_items(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + + items: list[dict[str, Any]] = [] + for entry in value: + if isinstance(entry, dict): + name = normalize_text( + entry.get("name") + or entry.get("item") + or entry.get("ingredient") + or entry.get("recipeIngredient") + ) + if not name: + continue + + quantity = normalize_text(entry.get("quantity") or entry.get("amount")) + note = normalize_text(entry.get("note")) + desc = " ".join([p for p in (quantity, note) if p]).strip() + + items.append( + { + "name": name, + "description": desc, + "optional": bool(entry.get("optional", False)), + } + ) + else: + name = normalize_text(entry) + if name: + items.append( + { + "name": name, + "description": "", + "optional": False, + } + ) + return items + + +def _is_gzip_bytes(data: bytes) -> bool: + return len(data) >= 2 and data[0] == 0x1F and data[1] == 0x8B + + +def _load_json_bytes(data: bytes) -> Any: + return json.loads(data.decode("utf-8")) + + +def maybe_decode_json_payload(data: bytes) -> Any | None: + if _is_gzip_bytes(data): + try: + data = gzip.decompress(data) + except Exception: + return None + try: + return _load_json_bytes(data) + except Exception: + return None diff --git a/backend/app/service/importServices/import_recipe.py b/backend/app/service/importServices/import_recipe.py index 69826abae..ff521f441 100644 --- a/backend/app/service/importServices/import_recipe.py +++ b/backend/app/service/importServices/import_recipe.py @@ -1,8 +1,62 @@ +import re +from typing import Any +from app.config import db from app.models import Recipe, RecipeTags, RecipeItems, Item, Tag from app.service.file_has_access_or_download import file_has_access_or_download -def importRecipe(household_id: int, args: dict, overwrite: bool = False): +def _to_reference(value: str) -> str: + return ( + value.lower() + .replace(" ", "_") + .replace("\n", "") + .replace("\r", "") + .replace("\t", "") + ) + + +def _apply_ingredient_refs(description: str, item_names: list[str]) -> str: + if not description or not item_names: + return description + names = [n for n in item_names if n] + if not names: + return description + pattern = "|".join( + [re.escape(name) for name in sorted(names, key=len, reverse=True)] + ) + if not pattern: + return description + + def _replace(match: re.Match[str]) -> str: + name = match.group(1) + if not name: + return match.group(0) + return "@" + re.sub( + r"\n|\.|\(|\)|\\|/|\?|\*|\+|,|!|%|\$|#|@|\^|;|:|\"|=|~|\{", + "", + _to_reference(name), + ) + + return re.sub( + rf"(? list[str]: + if not isinstance(value, list): + return [] + return [str(entry).strip() for entry in value if str(entry).strip()] + + +def importRecipe( + household_id: int, + args: dict, + overwrite: bool = False, + user=None, +): recipeNameCount = 0 recipe = Recipe.find_by_name(household_id, args["name"]) if recipe and not overwrite: @@ -17,10 +71,20 @@ def importRecipe(household_id: int, args: dict, overwrite: bool = False): if not recipe: recipe = Recipe() recipe.household_id = household_id + elif overwrite: + RecipeItems.query.filter_by(recipe_id=recipe.id).delete() + RecipeTags.query.filter_by(recipe_id=recipe.id).delete() + db.session.commit() recipe.name = args["name"] + ( f" ({recipeNameCount + 1})" if recipeNameCount > 0 else "" ) - recipe.description = args["description"] + description = args["description"] + if "items" in args: + description = _apply_ingredient_refs( + description, + [str(e.get("name", "")) for e in args["items"] if isinstance(e, dict)], + ) + recipe.description = description if "time" in args: recipe.time = args["time"] if "cook_time" in args: @@ -32,27 +96,60 @@ def importRecipe(household_id: int, args: dict, overwrite: bool = False): if "source" in args: recipe.source = args["source"] if "photo" in args: - recipe.photo = file_has_access_or_download(args["photo"]) + recipe.photo = file_has_access_or_download(args["photo"], user=user) + extra_photos = _normalize_photo_list(args.get("photos")) + if extra_photos: + resolved_photos = [ + file_has_access_or_download(photo, user=user) for photo in extra_photos + ] + recipe.photos = [photo for photo in resolved_photos if photo] + if not recipe.photo and recipe.photos: + recipe.photo = recipe.photos[0] + elif "photos" in args: + recipe.photos = [] recipe.save() if "items" in args: + seen_item_ids: set[int] = set() for recipeItem in args["items"]: - item = Item.find_by_name(household_id, recipeItem["name"]) + item_name = str(recipeItem["name"]).strip() + if not item_name: + continue + item = Item.find_by_name(household_id, item_name) if not item: - item = Item.create_by_name(household_id, recipeItem["name"]) - con = RecipeItems( - description=recipeItem["description"], optional=recipeItem["optional"] - ) + item = Item.create_by_name(household_id, item_name) + if item.id in seen_item_ids: + continue + seen_item_ids.add(item.id) + + con = RecipeItems.find_by_ids(recipe.id, item.id) + if not con: + con = RecipeItems( + description=recipeItem["description"], + optional=recipeItem["optional"], + ) + else: + if "description" in recipeItem: + con.description = recipeItem["description"] + if "optional" in recipeItem: + con.optional = recipeItem["optional"] con.item = item con.recipe = recipe con.save() if "tags" in args: + seen_tag_ids: set[int] = set() for tagName in args["tags"]: tag = Tag.find_by_name(household_id, tagName) if not tag: tag = Tag.create_by_name(household_id, tagName) - con = RecipeTags() - con.tag = tag - con.recipe = recipe - con.save() + if tag.id in seen_tag_ids: + continue + seen_tag_ids.add(tag.id) + + con = RecipeTags.find_by_ids(recipe.id, tag.id) + if not con: + con = RecipeTags() + con.tag = tag + con.recipe = recipe + con.save() diff --git a/backend/app/service/recipe_import_service.py b/backend/app/service/recipe_import_service.py new file mode 100644 index 000000000..eb1320f5f --- /dev/null +++ b/backend/app/service/recipe_import_service.py @@ -0,0 +1,530 @@ +from __future__ import annotations + +import io +import json +import gzip +import os +import shutil +import time +import uuid +import zipfile +from typing import Any + +import blurhash +import gevent +from sqlalchemy import func +from PIL import Image +from werkzeug.utils import secure_filename + +from app.config import UPLOAD_FOLDER, app +from app.models import File +from app.models import Recipe +from app.models.user import User +from app.service.file_has_access_or_download import file_has_access_or_download +from app.service.importServices import importRecipe +from app.service.importRecipes.mealie import parse_mealie_zip +from app.service.importRecipes.tandoor import parse_tandoor_zip +from app.service.importRecipes.json import json_extract_recipes +from app.util.filename_validator import allowed_file +from flask_jwt_extended import current_user + + +IMPORT_TMP_FOLDER = os.path.join(UPLOAD_FOLDER, "import_tmp") +IMPORT_JOB_STATE: dict[str, dict[str, Any]] = {} + + +def _store_image_bytes(file_bytes: bytes, filename: str, user=None) -> str | None: + if not user: + user = current_user + if not user: + return None + + if not filename or not allowed_file(filename): + return None + + stored_filename = secure_filename( + str(uuid.uuid4()) + "." + filename.rsplit(".", 1)[1].lower() + ) + file_path = os.path.join(UPLOAD_FOLDER, stored_filename) + with open(file_path, "wb") as handle: + handle.write(file_bytes) + blur = None + try: + with Image.open(file_path) as image: + image.thumbnail((100, 100)) + blur = blurhash.encode(image, x_components=4, y_components=3) + except FileNotFoundError: + return None + except Exception: + pass + File(filename=stored_filename, blur_hash=blur, created_by=user.id).save() + return stored_filename + + +def _extract_recipes(payload: Any) -> list[dict[str, Any]]: + try: + return json_extract_recipes(payload) + except Exception: + return [] + + +def _parse_mealie_zip( + zf: zipfile.ZipFile, zip_entries: dict[str, str] +) -> list[dict[str, Any]]: + try: + return parse_mealie_zip(zf, zip_entries) + except Exception: + return [] + + +def _parse_tandoor_zip( + zf: zipfile.ZipFile, + entry_names: list[str], + zip_entries: dict[str, str], + images_dir: str, +) -> list[dict[str, Any]]: + try: + return parse_tandoor_zip(zf, entry_names, zip_entries, images_dir) + except Exception: + return [] + + +def _is_gzip_bytes(data: bytes) -> bool: + return len(data) >= 2 and data[0] == 0x1F and data[1] == 0x8B + + +def _maybe_decode_json_payload(data: bytes) -> Any | None: + if _is_gzip_bytes(data): + try: + data = gzip.decompress(data) + except Exception: + return None + try: + return _load_json_bytes(data) + except Exception: + return None + + +def _normalize_zip_path(path: str) -> str: + return path.replace("\\", "/").strip("/").lower() + + +def _resolve_photo_from_zip( + photo_value: str, + zip_entries: dict[str, str], + recipe_dir: str, + recipe_images: dict[str, list[str]], + zip_file: zipfile.ZipFile, + images_dir: str, +) -> str | None: + if not photo_value: + return None + + photo_key = _normalize_zip_path(photo_value) + recipe_dir_key = _normalize_zip_path(recipe_dir) + base_name = os.path.basename(photo_key) + search_keys = [] + if recipe_dir_key: + search_keys.append(_normalize_zip_path(f"{recipe_dir}/{photo_key}")) + search_keys.append(photo_key) + if recipe_dir_key: + search_keys.extend( + _normalize_zip_path(candidate) + for candidate in recipe_images.get(recipe_dir_key, []) + if os.path.basename(candidate).lower() == base_name + ) + search_keys.append(base_name) + + entry_name = None + for key in search_keys: + entry_name = zip_entries.get(key) + if entry_name: + break + + if not entry_name: + return None + + try: + data = zip_file.read(entry_name) + except Exception: + return None + if not allowed_file(entry_name): + return None + filename = f"{uuid.uuid4()}_{os.path.basename(entry_name)}" + path = os.path.join(images_dir, filename) + with open(path, "wb") as handle: + handle.write(data) + return filename + + +def _collect_zip_images(entries: list[str]) -> dict[str, list[str]]: + images: dict[str, list[str]] = {} + for name in entries: + if allowed_file(name): + images.setdefault(_normalize_zip_path(os.path.dirname(name)), []).append( + name + ) + return images + + +def _set_import_job(token: str, **state: Any) -> None: + IMPORT_JOB_STATE[token] = state + + +def get_recipe_import_job(token: str) -> dict[str, Any] | None: + job = IMPORT_JOB_STATE.get(token) + if not job: + return None + return dict(job) + + +def _resolve_photo_candidates_from_zip( + recipe: dict[str, Any], + zip_entries: dict[str, str], + recipe_images: dict[str, list[str]], + zip_file: zipfile.ZipFile, + images_dir: str, +) -> tuple[str | None, list[str]]: + recipe_dir = _normalize_zip_path(recipe.get("import_source_dir", "")) + folder_images = recipe_images.get(recipe_dir, []) + main_source = recipe.get("photo", "") + extra_sources = list(recipe.get("photos", [])) + + if not main_source and extra_sources: + main_source = extra_sources.pop(0) + if not main_source and folder_images: + main_source = folder_images[0] + + resolved_main = None + if main_source: + resolved_main = _resolve_photo_from_zip( + main_source, + zip_entries, + recipe_dir, + recipe_images, + zip_file, + images_dir, + ) + + resolved_extras: list[str] = [] + candidate_sources = extra_sources + [ + candidate for candidate in folder_images if candidate != main_source + ] + for candidate in candidate_sources: + resolved = _resolve_photo_from_zip( + candidate, + zip_entries, + recipe_dir, + recipe_images, + zip_file, + images_dir, + ) + if resolved and resolved != resolved_main and resolved not in resolved_extras: + resolved_extras.append(resolved) + + return resolved_main, resolved_extras + + +def _load_json_bytes(data: bytes) -> Any: + return json.loads(data.decode("utf-8")) + + +def _ensure_tmp_dir(token: str) -> str: + base = os.path.join(IMPORT_TMP_FOLDER, token) + os.makedirs(os.path.join(base, "images"), exist_ok=True) + return base + + +def _cleanup_old_tmp(max_age_seconds: int = 60 * 60 * 24) -> None: + if not os.path.isdir(IMPORT_TMP_FOLDER): + return + now = time.time() + for entry in os.scandir(IMPORT_TMP_FOLDER): + if not entry.is_dir(): + continue + meta_path = os.path.join(entry.path, "meta.json") + try: + with open(meta_path, "r", encoding="utf-8") as handle: + meta = json.load(handle) + except Exception: + meta = {} + created = float(meta.get("created", 0)) + if created and now - created > max_age_seconds: + try: + shutil.rmtree(entry.path) + except Exception: + continue + + +def preview_recipe_import( + household_id: int, + data: bytes, + filename: str, +) -> dict[str, Any]: + _cleanup_old_tmp() + token = uuid.uuid4().hex + base_dir = _ensure_tmp_dir(token) + images_dir = os.path.join(base_dir, "images") + + recipes: list[dict[str, Any]] = [] + if filename.lower().endswith(".zip") or filename.lower().endswith( + ".paprikarecipes" + ): + with zipfile.ZipFile(io.BytesIO(data)) as zf: + entries = [ + name + for name in zf.namelist() + if not name.endswith("/") and not name.lower().startswith("__macosx/") + ] + zip_entries = {name.lower().replace("\\", "/"): name for name in entries} + zip_entries.update( + { + os.path.basename(name).lower(): name + for name in entries + if os.path.basename(name) + } + ) + recipe_images = _collect_zip_images(entries) + # Detect Mealie export (database.json at root) + if "database.json" in zip_entries: + mrecipes = _parse_mealie_zip(zf, zip_entries) + for recipe in mrecipes: + recipe["import_source_dir"] = "" + recipes.append(recipe) + else: + # Detect Tandoor export: root-level entries are ZIP files + root_entries = [name for name in entries if os.path.dirname(name) == ""] + if root_entries and all( + name.lower().endswith(".zip") for name in root_entries + ): + trecipes = _parse_tandoor_zip(zf, entries, zip_entries, images_dir) + for recipe in trecipes: + recipe["import_source_dir"] = "" + recipes.append(recipe) + else: + json_entries = [ + name for name in entries if name.lower().endswith(".json") + ] + for json_name in json_entries: + payload = _maybe_decode_json_payload(zf.read(json_name)) + if payload is None: + continue + recipe_dir = _normalize_zip_path(os.path.dirname(json_name)) + for recipe in _extract_recipes(payload): + recipe["import_source_dir"] = recipe_dir + recipes.append(recipe) + + for entry_name in entries: + if entry_name.lower().endswith(".json"): + continue + payload = _maybe_decode_json_payload(zf.read(entry_name)) + if payload is None: + continue + recipe_dir = _normalize_zip_path(os.path.dirname(entry_name)) + for recipe in _extract_recipes(payload): + recipe["import_source_dir"] = recipe_dir + recipes.append(recipe) + + for recipe in recipes: + photo_value = recipe.get("photo", "") + if photo_value.startswith("http"): + continue + resolved_main, resolved_extras = _resolve_photo_candidates_from_zip( + recipe, + zip_entries, + recipe_images, + zf, + images_dir, + ) + if resolved_main: + recipe["photo_temp"] = resolved_main + recipe.pop("photo", None) + if resolved_extras: + recipe["photo_temps"] = resolved_extras + recipe.pop("photos", None) + else: + payload = _load_json_bytes(data) + recipes = _extract_recipes(payload) + for recipe in recipes: + photo_value = recipe.get("photo", "") + if photo_value and not photo_value.startswith("http"): + recipe.pop("photo", None) + + for recipe in recipes: + recipe["import_id"] = uuid.uuid4().hex + recipe.pop("import_source_dir", None) + + duplicates: list[dict[str, Any]] = [] + for recipe in recipes: + name = recipe.get("name", "") + if not name: + continue + existing = Recipe.query.filter( + Recipe.household_id == household_id, + func.lower(Recipe.name) == name.lower(), + ).first() + if existing: + duplicates.append( + { + "import_id": recipe["import_id"], + "recipe_id": existing.id, + "recipe_name": existing.name, + } + ) + + with open(os.path.join(base_dir, "recipes.json"), "w", encoding="utf-8") as f: + json.dump(recipes, f, ensure_ascii=False) + with open(os.path.join(base_dir, "meta.json"), "w", encoding="utf-8") as f: + json.dump({"created": time.time()}, f) + + return { + "token": token, + "recipes": [ + { + "import_id": r["import_id"], + "name": r.get("name", ""), + "source": r.get("source", ""), + } + for r in recipes + ], + "duplicates": duplicates, + } + + +def commit_recipe_import( + household_id: int, + token: str, + decisions: dict[str, str], +) -> dict[str, Any]: + if token in IMPORT_JOB_STATE and not IMPORT_JOB_STATE[token].get("complete"): + return get_recipe_import_job(token) or { + "imported": 0, + "skipped": 0, + "failed": 0, + "complete": False, + } + + user = User.find_by_id(current_user.id) if current_user else None + _set_import_job( + token, + detected=0, + imported=0, + skipped=0, + failed=0, + complete=False, + running=True, + ) + + if app.testing: + _run_recipe_import_job(household_id, token, decisions, user) + else: + gevent.spawn(_run_recipe_import_job, household_id, token, decisions, user) + + return get_recipe_import_job(token) or { + "detected": 0, + "imported": 0, + "skipped": 0, + "failed": 0, + "complete": False, + } + + +def _run_recipe_import_job( + household_id: int, + token: str, + decisions: dict[str, str], + user, +) -> None: + with app.app_context(): + base_dir = os.path.join(IMPORT_TMP_FOLDER, token) + recipes_path = os.path.join(base_dir, "recipes.json") + images_dir = os.path.join(base_dir, "images") + if not os.path.exists(recipes_path): + _set_import_job(token, imported=0, skipped=0, failed=0, complete=True) + return + + with open(recipes_path, "r", encoding="utf-8") as f: + recipes = json.load(f) + + _set_import_job( + token, + detected=len(recipes), + imported=0, + skipped=0, + failed=0, + complete=False, + running=True, + ) + + imported = 0 + skipped = 0 + failed = 0 + for recipe in recipes: + import_id = recipe.get("import_id") + action = decisions.get(import_id, "copy") + if action == "skip": + skipped += 1 + continue + + try: + if recipe.get("photo_temp"): + image_path = os.path.join(images_dir, recipe["photo_temp"]) + with open(image_path, "rb") as handle: + file_bytes = handle.read() + filename = _store_image_bytes( + file_bytes, recipe["photo_temp"], user + ) + if filename: + recipe["photo"] = filename + if recipe.get("photo_temps"): + resolved_photos = [] + for photo_temp in recipe["photo_temps"]: + image_path = os.path.join(images_dir, photo_temp) + with open(image_path, "rb") as handle: + file_bytes = handle.read() + filename = _store_image_bytes(file_bytes, photo_temp, user) + if filename: + resolved_photos.append(filename) + if resolved_photos: + recipe["photos"] = resolved_photos + elif recipe.get("photo"): + recipe["photo"] = file_has_access_or_download( + recipe["photo"], user=user + ) + elif recipe.get("photo") is None: + recipe.pop("photo", None) + + importRecipe( + household_id, + recipe, + overwrite=action == "overwrite", + user=user, + ) + imported += 1 + except Exception: + app.logger.exception("Failed to import recipe") + failed += 1 + _set_import_job( + token, + detected=len(recipes), + imported=imported, + skipped=skipped, + failed=failed, + complete=False, + running=True, + ) + + try: + shutil.rmtree(base_dir) + except Exception: + app.logger.warning("Failed to cleanup recipe import temp dir") + + _set_import_job( + token, + detected=len(recipes), + imported=imported, + skipped=skipped, + failed=failed, + complete=True, + running=False, + ) diff --git a/backend/migrations/versions/6950c7b662d1_recipe_photos_list.py b/backend/migrations/versions/6950c7b662d1_recipe_photos_list.py new file mode 100644 index 000000000..6810b95a1 --- /dev/null +++ b/backend/migrations/versions/6950c7b662d1_recipe_photos_list.py @@ -0,0 +1,25 @@ +"""recipe photos list + +Revision ID: 6950c7b662d1 +Revises: 0b10d67750be +Create Date: 2026-05-29 21:32:23.419806 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '6950c7b662d1' +down_revision = '0b10d67750be' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.add_column(sa.Column('photos', sa.JSON(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table('recipe', schema=None) as batch_op: + batch_op.drop_column('photos') diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 369050019..54c5f3e1c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "psycopg2-binary>=2.9.10", "uWSGI>=2.0.28", "uwsgi-tools>=1.1.1", + "pyright>=1.1.410", ] [dependency-groups] diff --git a/backend/tests/api/test_recipe_import_formats.py b/backend/tests/api/test_recipe_import_formats.py new file mode 100644 index 000000000..85f2ae231 --- /dev/null +++ b/backend/tests/api/test_recipe_import_formats.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import gzip +import io +import json +import zipfile +from pathlib import Path +from unittest.mock import patch + +from app import db +from app.config import UPLOAD_FOLDER +from app.models import Household, Recipe, RecipeTags, Tag +from app.service.recipe_import_service import ( + commit_recipe_import, + preview_recipe_import, +) + + +PNG_ONE = bytes.fromhex( + "89504e470d0a1a0a0000000d4948445200000001000000010802000000907753de" + "0000000a49444154789c6360000002000154a2f6450000000049454e44ae426082" +) +PNG_TWO = bytes.fromhex( + "89504e470d0a1a0a0000000d4948445200000001000000010802000000907753de" + "0000000a49444154789c63f8cf0000020301ff7f0f9c0000000049454e44ae426082" +) + + +def _make_zip(entries: dict[str, bytes]) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path, data in entries.items(): + archive.writestr(path, data) + return buffer.getvalue() + + +def _make_gzip_json(payload: dict) -> bytes: + return gzip.compress(json.dumps(payload).encode("utf-8")) + + +def _get_recipe(household_id: int, name: str) -> Recipe: + return Recipe.query.filter_by(household_id=household_id, name=name).one() + + +def _read_uploaded_photo(filename: str) -> bytes: + return Path(UPLOAD_FOLDER, filename).read_bytes() + + +def _create_household(name: str) -> Household: + household = Household(name=name) + db.session.add(household) + db.session.commit() + return household + + +def _create_recipe_with_tag(household: Household, name: str, tag_name: str) -> None: + recipe = Recipe( + household_id=household.id, + name=name, + description="Existing recipe", + ) + tag = Tag(name=tag_name, household_id=household.id) + db.session.add_all([recipe, tag]) + db.session.commit() + + db.session.add( + RecipeTags( + recipe_id=recipe.id, + tag_id=tag.id, + ) + ) + db.session.commit() + + +def test_recipe_import_nextcloud_and_tandoor_folder_images(client): + household = _create_household("Nextcloud import") + payload_one = { + "name": "Folder One Pasta", + "description": "First recipe", + "ingredients": [{"name": "Tomato", "quantity": "1"}], + "image": "preview.jpg", + } + payload_two = { + "name": "Folder Two Salad", + "description": "Second recipe", + "ingredients": [{"name": "Lettuce", "quantity": "2"}], + "image": "preview.jpg", + } + archive = _make_zip( + { + "Recipes/Folder One/recipe.json": json.dumps(payload_one).encode("utf-8"), + "Recipes/Folder One/preview.jpg": PNG_ONE, + "Recipes/Folder Two/recipe.json": json.dumps(payload_two).encode("utf-8"), + "Recipes/Folder Two/preview.jpg": PNG_TWO, + } + ) + + preview = preview_recipe_import(household.id, archive, "Recipes.zip") + assert len(preview["recipes"]) == 2 + + def _save_image_bytes(file_bytes, filename, _user_id): + saved_name = f"test_{filename}" + Path(UPLOAD_FOLDER, saved_name).write_bytes(file_bytes) + return saved_name + + with patch( + "app.service.recipe_import_service._store_image_bytes", + side_effect=_save_image_bytes, + ), patch( + "app.service.importServices.import_recipe.file_has_access_or_download", + side_effect=lambda value, **kwargs: value, + ): + result = commit_recipe_import( + household.id, + preview["token"], + {recipe["import_id"]: "copy" for recipe in preview["recipes"]}, + ) + assert result["imported"] == 2 + + first = _get_recipe(household.id, "Folder One Pasta") + second = _get_recipe(household.id, "Folder Two Salad") + assert first.photo + assert second.photo + assert first.photo != second.photo + assert _read_uploaded_photo(first.photo) == PNG_ONE + assert _read_uploaded_photo(second.photo) == PNG_TWO + + +def test_recipe_import_mealie_backup_zip(client): + household = _create_household("Mealie import") + archive = _make_zip( + { + "backup/recipes/mealie-breakfast.json": json.dumps( + { + "name": "Mealie Breakfast", + "description": "Mealie-style backup export", + "ingredients": [ + {"name": "Egg", "quantity": "2"}, + ], + } + ).encode("utf-8"), + } + ) + + preview = preview_recipe_import(household.id, archive, "mealie-backup.zip") + assert [recipe["name"] for recipe in preview["recipes"]] == ["Mealie Breakfast"] + + result = commit_recipe_import( + household.id, + preview["token"], + {preview["recipes"][0]["import_id"]: "copy"}, + ) + assert result["imported"] == 1 + assert _get_recipe(household.id, "Mealie Breakfast").description.startswith( + "Mealie-style backup export" + ) + + +def test_recipe_import_paprika_gzipped_zip_entries(client): + household = _create_household("Paprika import") + archive = _make_zip( + { + "Paprika Recipe One.json.gz": _make_gzip_json( + { + "name": "Paprika Recipe One", + "description": "First paprika recipe", + "ingredients": [ + {"name": "Butter", "quantity": "50g"}, + ], + } + ), + "Paprika Recipe Two.json.gz": _make_gzip_json( + { + "name": "Paprika Recipe Two", + "description": "Second paprika recipe", + "ingredients": [ + {"name": "Salt", "quantity": "1 tsp"}, + ], + } + ), + } + ) + + preview = preview_recipe_import(household.id, archive, "recipes.paprikarecipes") + assert {recipe["name"] for recipe in preview["recipes"]} == { + "Paprika Recipe One", + "Paprika Recipe Two", + } + + result = commit_recipe_import( + household.id, + preview["token"], + {recipe["import_id"]: "copy" for recipe in preview["recipes"]}, + ) + assert result["imported"] == 2 + assert _get_recipe(household.id, "Paprika Recipe One").description.startswith( + "First paprika recipe" + ) + assert _get_recipe(household.id, "Paprika Recipe Two").description.startswith( + "Second paprika recipe" + ) + + +def test_recipe_import_overwrite_keeps_existing_tags_without_error(client): + household = _create_household("Overwrite import") + _create_recipe_with_tag(household, "Overwrite Me", "Dinner") + + archive = _make_zip( + { + "recipe.json": json.dumps( + { + "name": "Overwrite Me", + "description": "Updated recipe", + "tags": ["Dinner"], + } + ).encode("utf-8") + } + ) + + preview = preview_recipe_import(household.id, archive, "overwrite.zip") + assert len(preview["duplicates"]) == 1 + + result = commit_recipe_import( + household.id, + preview["token"], + {preview["recipes"][0]["import_id"]: "overwrite"}, + ) + + assert result["imported"] == 1 + assert result["failed"] == 0 + + recipe = _get_recipe(household.id, "Overwrite Me") + assert recipe.description == "Updated recipe" + assert [tag.tag.name for tag in recipe.tags] == ["Dinner"] diff --git a/backend/uv.lock b/backend/uv.lock index db69bb9de..1f0c39fd7 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1280,6 +1280,7 @@ dependencies = [ { name = "prometheus-client" }, { name = "prometheus-flask-exporter" }, { name = "psycopg2-binary" }, + { name = "pyright" }, { name = "python-socketio" }, { name = "recipe-scrapers" }, { name = "requests" }, @@ -1331,6 +1332,7 @@ requires-dist = [ { name = "prometheus-client", specifier = ">=0.21.1" }, { name = "prometheus-flask-exporter", specifier = ">=0.23.2" }, { name = "psycopg2-binary", specifier = ">=2.9.10" }, + { name = "pyright", specifier = ">=1.1.410" }, { name = "python-socketio", specifier = ">=5.16.2" }, { name = "recipe-scrapers", specifier = ">=15.6.0" }, { name = "requests", specifier = ">=2.32.3" }, diff --git a/kitchenowl/default.conf.template b/kitchenowl/default.conf.template index 5df8c5c2d..2847bb232 100644 --- a/kitchenowl/default.conf.template +++ b/kitchenowl/default.conf.template @@ -8,13 +8,13 @@ server { server_name _; location / { - client_max_body_size 32M; + client_max_body_size ${MAX_CONTENT_LENGTH_MB}M; try_files $uri $uri/ /index.html; } location /api/ { include uwsgi_params; uwsgi_pass ${BACK_URL}; - client_max_body_size 32M; + client_max_body_size ${MAX_CONTENT_LENGTH_MB}M; } location /mcp { include uwsgi_params; diff --git a/kitchenowl/lib/cubits/household_add_update/household_update_cubit.dart b/kitchenowl/lib/cubits/household_add_update/household_update_cubit.dart index 1785b0528..4f5e3233e 100644 --- a/kitchenowl/lib/cubits/household_add_update/household_update_cubit.dart +++ b/kitchenowl/lib/cubits/household_add_update/household_update_cubit.dart @@ -5,6 +5,8 @@ import 'package:kitchenowl/models/expense_category.dart'; import 'package:kitchenowl/models/household.dart'; import 'package:kitchenowl/models/import_settings.dart'; import 'package:kitchenowl/models/member.dart'; +import 'package:kitchenowl/models/recipe_import_preview.dart'; +import 'package:kitchenowl/models/recipe_import_result.dart'; import 'package:kitchenowl/models/shoppinglist.dart'; import 'package:kitchenowl/models/tag.dart'; import 'package:kitchenowl/services/api/api_service.dart'; @@ -305,6 +307,24 @@ class HouseholdUpdateCubit await ApiService.getInstance() .importHousehold(household, content, settings.recipesOverwrite); } + + Future previewRecipeImport( + NamedByteArray file, + ) async { + return ApiService.getInstance().previewRecipeImport(household, file); + } + + Future commitRecipeImport( + String token, + Map decisions, + ) async { + return ApiService.getInstance() + .commitRecipeImport(household, token, decisions); + } + + Future getRecipeImportStatus(String token) async { + return ApiService.getInstance().getRecipeImportStatus(household, token); + } } class HouseholdUpdateState extends HouseholdAddUpdateState { diff --git a/kitchenowl/lib/cubits/recipe_add_update_cubit.dart b/kitchenowl/lib/cubits/recipe_add_update_cubit.dart index 6eb885506..6f78b6230 100644 --- a/kitchenowl/lib/cubits/recipe_add_update_cubit.dart +++ b/kitchenowl/lib/cubits/recipe_add_update_cubit.dart @@ -27,6 +27,7 @@ class AddUpdateRecipeCubit extends ReplayCubit { yields: recipe.yields, source: recipe.source, items: recipe.items, + additionalImages: recipe.galleryImages, selectedTags: recipe.tags, tags: recipe.tags, visibility: recipe.visibility, @@ -45,11 +46,13 @@ class AddUpdateRecipeCubit extends ReplayCubit { Future saveRecipe() async { final AddUpdateRecipeState _state = state; if (state.isValid()) { - String? image; - if (_state.image != null) { - image = _state.image!.isEmpty - ? '' - : await ApiService.getInstance().uploadBytes(_state.image!); + final List additionalImages = + List.from(_state.additionalImages); + for (final pendingImage in _state.additionalImageFiles) { + final uploaded = await ApiService.getInstance().uploadBytes(pendingImage); + if (uploaded != null && uploaded.isNotEmpty) { + additionalImages.add(uploaded); + } } if (recipe.id == null) { return ApiService.getInstance().addRecipe( @@ -62,7 +65,7 @@ class AddUpdateRecipeCubit extends ReplayCubit { prepTime: _state.prepTime, yields: _state.yields, source: _state.source, - image: image ?? recipe.image, + additionalImages: additionalImages, items: _state.items, tags: _state.selectedTags, visibility: _state.visibility, @@ -78,14 +81,19 @@ class AddUpdateRecipeCubit extends ReplayCubit { prepTime: _state.prepTime, yields: _state.yields, source: _state.source, - image: image, + additionalImages: additionalImages, items: _state.items, tags: _state.selectedTags, visibility: _state.visibility, curated: _state.curated, )); } - emit(_state.copyWith(hasChanges: false)); + emit(_state.copyWith( + image: null, + additionalImages: additionalImages, + additionalImageFiles: const [], + hasChanges: false, + )); this.clearHistory(); } @@ -106,6 +114,36 @@ class AddUpdateRecipeCubit extends ReplayCubit { emit(state.copyWith(image: image, hasChanges: true)); } + Future addAdditionalImage(NamedByteArray image) async { + if (image.isEmpty) return; + emit(state.copyWith( + additionalImageFiles: + List.from(state.additionalImageFiles)..add(image), + hasChanges: true, + )); + } + + void removeAdditionalImage(String image) { + final additionalImages = List.from(state.additionalImages); + if (additionalImages.remove(image)) { + emit(state.copyWith( + additionalImages: additionalImages, + hasChanges: true, + )); + } + } + + void removeAdditionalImageFile(NamedByteArray image) { + final additionalImageFiles = + List.from(state.additionalImageFiles); + if (additionalImageFiles.remove(image)) { + emit(state.copyWith( + additionalImageFiles: additionalImageFiles, + hasChanges: true, + )); + } + } + void setDescription(String desc) { emit(state.copyWith(description: desc, hasChanges: true)); } @@ -251,6 +289,8 @@ class AddUpdateRecipeState extends Equatable { final String source; final bool curated; final NamedByteArray? image; + final List additionalImages; + final List additionalImageFiles; final RecipeVisibility visibility; final List items; final Set tags; @@ -267,6 +307,8 @@ class AddUpdateRecipeState extends Equatable { this.source = '', this.curated = false, this.image, + this.additionalImages = const [], + this.additionalImageFiles = const [], this.visibility = RecipeVisibility.private, this.items = const [], this.tags = const {}, @@ -284,6 +326,8 @@ class AddUpdateRecipeState extends Equatable { String? source, bool? curated, NamedByteArray? image, + List? additionalImages, + List? additionalImageFiles, RecipeVisibility? visibility, List? items, Set? tags, @@ -300,6 +344,8 @@ class AddUpdateRecipeState extends Equatable { source: source ?? this.source, curated: curated ?? this.curated, image: image ?? this.image, + additionalImages: additionalImages ?? this.additionalImages, + additionalImageFiles: additionalImageFiles ?? this.additionalImageFiles, items: items ?? this.items, tags: tags ?? this.tags, visibility: visibility ?? this.visibility, @@ -320,6 +366,8 @@ class AddUpdateRecipeState extends Equatable { source, curated, image, + additionalImages, + additionalImageFiles, items, tags, visibility, diff --git a/kitchenowl/lib/helpers/short_image_markdown_extension.dart b/kitchenowl/lib/helpers/short_image_markdown_extension.dart index a5b267fa5..97bb7a1e2 100644 --- a/kitchenowl/lib/helpers/short_image_markdown_extension.dart +++ b/kitchenowl/lib/helpers/short_image_markdown_extension.dart @@ -7,7 +7,7 @@ class ShortImageMarkdownSyntax extends md.InlineSyntax { caseSensitive: false, ); - static const String _pattern = r"""!\[(.+)\]"""; + static const String _pattern = r"""!\[([^\]]+)\](?!\s*\()"""; @override bool onMatch(md.InlineParser parser, Match match) { diff --git a/kitchenowl/lib/l10n/app_de.arb b/kitchenowl/lib/l10n/app_de.arb index c9507b6c1..db9c5dbfb 100644 --- a/kitchenowl/lib/l10n/app_de.arb +++ b/kitchenowl/lib/l10n/app_de.arb @@ -176,6 +176,82 @@ } } }, + "@recipeImportActionCopy": {}, + "@recipeImportActionOverwrite": {}, + "@recipeImportActionSkip": {}, + "@recipeImportApplyAllDuplicates": {}, + "@recipeImportChooseFile": {}, + "@recipeImportDuplicateMatch": { + "placeholders": { + "name": { + "example": "Cake", + "type": "String" + } + } + }, + "@recipeImportDuplicatesBody": {}, + "@recipeImportDuplicatesTitle": {}, + "@recipeImportNoRecipes": {}, + "@recipeImportPreviewFailed": {}, + "@recipeImportProgressDetected": { + "placeholders": { + "count": { + "example": "3", + "type": "int" + } + } + }, + "@recipeImportProgressDoneTitle": {}, + "@recipeImportProgressFailed": { + "placeholders": { + "count": { + "example": "1", + "type": "int" + } + } + }, + "@recipeImportProgressImported": { + "placeholders": { + "count": { + "example": "3", + "type": "int" + } + } + }, + "@recipeImportProgressSkipped": { + "placeholders": { + "count": { + "example": "0", + "type": "int" + } + } + }, + "@recipeImportProgressTitle": {}, + "@recipeImportResultSummary": { + "placeholders": { + "imported": { + "example": "3", + "type": "int" + }, + "failed": { + "example": "1", + "type": "int" + }, + "skipped": { + "example": "0", + "type": "int" + } + } + }, + "@recipeImportTitle": {}, + "@recipeSavedCount": { + "placeholders": { + "count": { + "example": "42", + "type": "int" + } + } + }, "@shoppingListContainsEntries": { "placeholders": { "entriesCount": { @@ -307,7 +383,7 @@ "expensePaidFor": "Bezahlt für", "expenseReceivedBy": "Erhalten von", "expenseReceivedFor": "Erhalten für", - "export": "Exportieren", + "export": "Backup exportieren", "features": "Eigenschaften", "fieldCannotBeEmpty": "{field} darf nicht leer sein.", "forceOfflineMode": "Offline Modus erzwingen", @@ -329,7 +405,7 @@ "households": "Haushalte", "icon": "Symbol", "imageSelect": "Wähle ein Bild aus", - "import": "Importieren", + "import": "Backup importieren", "importStartedHint": "Importierung gestartet, dies kann ein paar Minuten dauern.", "income": "Einnahme", "ingredients": "Zutaten", @@ -427,7 +503,27 @@ "recipeEmptySearch": "Keine Rezepte gefunden :(", "recipeGoToInHousehold": "Bereits hinzugefügt, gehe zum Rezept in {householdName}", "recipeImageSelect": "Wähle ein Rezeptbild", + "recipeImportActionCopy": "Als Kopie importieren", + "recipeImportActionOverwrite": "Überschreiben", + "recipeImportActionSkip": "Überspringen", + "recipeImportApplyAllDuplicates": "Auf alle Duplikate anwenden:", + "recipeImportChooseFile": "JSON- oder ZIP-Datei auswählen", + "recipeImportDuplicateMatch": "Passt zu vorhandenem Rezept: {name}", + "recipeImportDuplicatesBody": "Wähle, was mit bereits vorhandenen Rezepten passieren soll.", + "recipeImportDuplicatesTitle": "Duplikate auflösen", + "recipeImportNoRecipes": "Keine Rezepte in dieser Datei gefunden.", + "recipeImportPreviewFailed": "Rezeptimport konnte nicht vorbereitet werden.", + "recipeImportProgressDetected": "Erkannt: {count}", + "recipeImportProgressDoneTitle": "Import abgeschlossen", + "recipeImportProgressFailed": "Fehlgeschlagen: {count}", + "recipeImportProgressImported": "Importiert: {count}", + "recipeImportProgressSkipped": "Übersprungen: {count}", + "recipeImportProgressTitle": "Rezepte werden importiert", + "recipeImportResultSummary": "Import abgeschlossen: {imported} importiert, {failed} fehlgeschlagen, {skipped} übersprungen.", + "recipeImportStatusFetchFailed": "Der Importstatus konnte nicht abgerufen werden. Der Import läuft möglicherweise noch im Hintergrund.", + "recipeImportTitle": "Rezepte importieren", "recipeNew": "Neues Rezept", + "recipeSavedCount": "Gespeicherte Rezepte: {count}", "recipeSource": "Rezept Quelle", "recipes": "Rezepte", "recipesCurated": "Ausgewählte Rezepte", diff --git a/kitchenowl/lib/l10n/app_en.arb b/kitchenowl/lib/l10n/app_en.arb index c0ba23eb4..c9bbf2194 100644 --- a/kitchenowl/lib/l10n/app_en.arb +++ b/kitchenowl/lib/l10n/app_en.arb @@ -367,6 +367,82 @@ } }, "@recipeImageSelect": {}, + "@recipeImportActionCopy": {}, + "@recipeImportActionOverwrite": {}, + "@recipeImportActionSkip": {}, + "@recipeImportApplyAllDuplicates": {}, + "@recipeImportChooseFile": {}, + "@recipeImportDuplicateMatch": { + "placeholders": { + "name": { + "example": "Cake", + "type": "String" + } + } + }, + "@recipeImportDuplicatesBody": {}, + "@recipeImportDuplicatesTitle": {}, + "@recipeImportNoRecipes": {}, + "@recipeImportPreviewFailed": {}, + "@recipeImportProgressDetected": { + "placeholders": { + "count": { + "example": "3", + "type": "int" + } + } + }, + "@recipeImportProgressDoneTitle": {}, + "@recipeImportProgressFailed": { + "placeholders": { + "count": { + "example": "1", + "type": "int" + } + } + }, + "@recipeImportProgressImported": { + "placeholders": { + "count": { + "example": "3", + "type": "int" + } + } + }, + "@recipeImportProgressSkipped": { + "placeholders": { + "count": { + "example": "0", + "type": "int" + } + } + }, + "@recipeImportProgressTitle": {}, + "@recipeImportResultSummary": { + "placeholders": { + "imported": { + "example": "3", + "type": "int" + }, + "failed": { + "example": "1", + "type": "int" + }, + "skipped": { + "example": "0", + "type": "int" + } + } + }, + "@recipeImportTitle": {}, + "@recipeSavedCount": { + "placeholders": { + "count": { + "example": "42", + "type": "int" + } + } + }, "@recipeNew": {}, "@recipeSource": {}, "@recipes": {}, @@ -591,7 +667,7 @@ "expensePaidFor": "Paid for", "expenseReceivedBy": "Received by", "expenseReceivedFor": "Received for", - "export": "Export", + "export": "Export Backup", "features": "Features", "fieldCannotBeEmpty": "{field} cannot be empty.", "forceOfflineMode": "Force offline mode", @@ -613,7 +689,7 @@ "households": "Households", "icon": "Icon", "imageSelect": "Select an image", - "import": "Import", + "import": "Import Backup", "importStartedHint": "Import started, this may take a few minutes.", "income": "Income", "ingredients": "Ingredients", @@ -711,7 +787,27 @@ "recipeEmptySearch": "No recipes found :(", "recipeGoToInHousehold": "Already added, go to recipe in {householdName}", "recipeImageSelect": "Select a recipe image", + "recipeImportActionCopy": "Import as copy", + "recipeImportActionOverwrite": "Overwrite", + "recipeImportActionSkip": "Skip", + "recipeImportApplyAllDuplicates": "Apply to all duplicates:", + "recipeImportChooseFile": "Select a JSON or ZIP file", + "recipeImportDuplicateMatch": "Matches existing: {name}", + "recipeImportDuplicatesBody": "Choose what to do with recipes that already exist.", + "recipeImportDuplicatesTitle": "Resolve duplicates", + "recipeImportNoRecipes": "No recipes found in this file.", + "recipeImportPreviewFailed": "Recipe import preview failed.", + "recipeImportProgressDetected": "Detected: {count}", + "recipeImportProgressDoneTitle": "Import complete", + "recipeImportProgressFailed": "Failed: {count}", + "recipeImportProgressImported": "Imported: {count}", + "recipeImportProgressSkipped": "Skipped: {count}", + "recipeImportProgressTitle": "Importing recipes", + "recipeImportResultSummary": "Import complete: {imported} imported, {failed} failed, {skipped} skipped.", + "recipeImportStatusFetchFailed": "Could not fetch import status. Import may still be running in the background.", + "recipeImportTitle": "Import recipes", "recipeNew": "New recipe", + "recipeSavedCount": "Saved recipes: {count}", "recipeSource": "Recipe source", "recipes": "Recipes", "recipesCurated": "Curated recipes", diff --git a/kitchenowl/lib/models/recipe.dart b/kitchenowl/lib/models/recipe.dart index 8598ecadb..cbe0891c3 100644 --- a/kitchenowl/lib/models/recipe.dart +++ b/kitchenowl/lib/models/recipe.dart @@ -20,6 +20,7 @@ class Recipe extends Model { final String source; final String? image; final String? imageHash; + final List additionalImages; final List items; final Set tags; final RecipeVisibility visibility; @@ -41,6 +42,7 @@ class Recipe extends Model { this.source = '', this.image, this.imageHash, + this.additionalImages = const [], this.items = const [], this.tags = const {}, this.plannedCookingDates = const {}, @@ -55,6 +57,10 @@ class Recipe extends Model { if (map.containsKey('items')) { items = List.from(map['items'].map((e) => RecipeItem.fromJson(e))); } + final additionalImages = List.from(map['photos'] ?? const []) + .where((e) => e.toString().isNotEmpty) + .map((e) => e.toString()) + .toList(); Set tags = const {}; if (map.containsKey('tags')) { tags = Set.from(map['tags'].map((e) => Tag.fromJson(e))); @@ -85,15 +91,19 @@ class Recipe extends Model { prepTime: map['prep_time'] ?? 0, yields: map['yields'] ?? 0, source: map['source'] ?? '', - image: map['photo'], + image: (additionalImages.isNotEmpty + ? additionalImages.first + : map['photo']) + ?.toString(), imageHash: map['photo_hash'], + additionalImages: additionalImages, visibility: RecipeVisibility.values[map['visibility'] ?? 0], householdId: map['household_id'], items: items, tags: tags, plannedCookingDates: plannedCookingDates, - household: map.containsKey("household") - ? Household.fromJson(map['household']) + household: map['household'] is Map + ? Household.fromJson(Map.from(map['household'])) : null, curated: map['server_curated'] ?? false, ); @@ -109,6 +119,7 @@ class Recipe extends Model { int? yields, String? source, String? image, + List? additionalImages, RecipeVisibility? visibility, List? items, Set? tags, @@ -130,6 +141,7 @@ class Recipe extends Model { curated: curated ?? this.curated, imageHash: imageHash, image: image ?? this.image, + additionalImages: additionalImages ?? this.additionalImages, tags: tags ?? this.tags, plannedCookingDates: plannedCookingDates ?? this.plannedCookingDates, visibility: visibility ?? this.visibility, @@ -160,6 +172,7 @@ class Recipe extends Model { source, image, imageHash, + additionalImages, tags, items, plannedCookingDates, @@ -179,7 +192,7 @@ class Recipe extends Model { "yields": yields, "source": source, "visibility": visibility.index, - if (image != null) "photo": image, + "photos": additionalImages, "items": items.map((e) => e.toJson()).toList(), "tags": tags.map((e) => e.toString()).toList(), "server_curated": curated, @@ -193,11 +206,28 @@ class Recipe extends Model { "items": items.map((e) => e.toJsonWithId()).toList(), "tags": tags.map((e) => e.toJsonWithId()).toList(), if (imageHash != null) "photo_hash": imageHash, + if (additionalImages.isNotEmpty) "photos": additionalImages, "planned_cooking_dates": plannedCookingDates.map((e) => e.millisecondsSinceEpoch).toList(), "household_id": householdId, }); + List get galleryImages { + final seen = {}; + final result = []; + + for (final candidate in [image, ...additionalImages]) { + final normalized = candidate?.trim(); + if (normalized?.isNotEmpty ?? false) { + if (seen.add(normalized!)) { + result.add(normalized); + } + } + } + + return result; + } + List get optionalItems => items.where((e) => e.optional).toList(); List get mandatoryItems => items.where((e) => !e.optional).toList(); diff --git a/kitchenowl/lib/models/recipe_import_preview.dart b/kitchenowl/lib/models/recipe_import_preview.dart new file mode 100644 index 000000000..c51074f5d --- /dev/null +++ b/kitchenowl/lib/models/recipe_import_preview.dart @@ -0,0 +1,95 @@ +class RecipeImportPreview { + final String token; + final List recipes; + final List duplicates; + final Map _duplicatesByImportId; + + RecipeImportPreview({ + required this.token, + required List recipes, + required List duplicates, + }) : recipes = List.unmodifiable(recipes), + duplicates = List.unmodifiable(duplicates), + _duplicatesByImportId = Map.unmodifiable({ + for (final duplicate in duplicates) + if (duplicate.importId.isNotEmpty) duplicate.importId: duplicate, + }); + + bool get hasDuplicates => duplicates.isNotEmpty; + + RecipeImportDuplicate? duplicateFor(String importId) { + return _duplicatesByImportId[importId]; + } + + factory RecipeImportPreview.fromJson(Map json) { + final recipes = []; + for (final entry in json['recipes'] as List? ?? const []) { + if (entry is Map) { + recipes.add( + RecipeImportRecipe.fromJson(Map.from(entry)), + ); + } + } + + final duplicates = []; + for (final entry in json['duplicates'] as List? ?? const []) { + if (entry is Map) { + duplicates.add( + RecipeImportDuplicate.fromJson(Map.from(entry)), + ); + } + } + + return RecipeImportPreview( + token: json['token']?.toString() ?? '', + recipes: recipes, + duplicates: duplicates, + ); + } +} + +class RecipeImportRecipe { + final String importId; + final String name; + final String source; + + const RecipeImportRecipe({ + required this.importId, + required this.name, + required this.source, + }); + + factory RecipeImportRecipe.fromJson(Map json) { + return RecipeImportRecipe( + importId: json['import_id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + source: json['source']?.toString() ?? '', + ); + } +} + +class RecipeImportDuplicate { + final String importId; + final int? recipeId; + final String recipeName; + + const RecipeImportDuplicate({ + required this.importId, + required this.recipeId, + required this.recipeName, + }); + + factory RecipeImportDuplicate.fromJson(Map json) { + return RecipeImportDuplicate( + importId: json['import_id']?.toString() ?? '', + recipeId: _parseRecipeId(json['recipe_id']), + recipeName: json['recipe_name']?.toString() ?? '', + ); + } +} + +int? _parseRecipeId(Object? value) { + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; +} diff --git a/kitchenowl/lib/models/recipe_import_result.dart b/kitchenowl/lib/models/recipe_import_result.dart new file mode 100644 index 000000000..911e9d90c --- /dev/null +++ b/kitchenowl/lib/models/recipe_import_result.dart @@ -0,0 +1,27 @@ +class RecipeImportResult { + final int detected; + final int imported; + final int skipped; + final int failed; + final bool complete; + + const RecipeImportResult({ + required this.detected, + required this.imported, + required this.skipped, + required this.failed, + required this.complete, + }); + + int get total => imported + skipped + failed; + + factory RecipeImportResult.fromJson(Map json) { + return RecipeImportResult( + detected: (json['detected'] as num?)?.toInt() ?? 0, + imported: (json['imported'] as num?)?.toInt() ?? 0, + skipped: (json['skipped'] as num?)?.toInt() ?? 0, + failed: (json['failed'] as num?)?.toInt() ?? 0, + complete: json['complete'] as bool? ?? false, + ); + } +} \ No newline at end of file diff --git a/kitchenowl/lib/pages/household_page/recipe_list.dart b/kitchenowl/lib/pages/household_page/recipe_list.dart index 33a215d01..ba74cc42a 100644 --- a/kitchenowl/lib/pages/household_page/recipe_list.dart +++ b/kitchenowl/lib/pages/household_page/recipe_list.dart @@ -246,7 +246,7 @@ class _RecipeListPageState extends State { childAspectRatio: 0.67, ), itemBuilder: (context, i) => RecipeCard( - key: Key(recipes[i].name), + key: ValueKey(recipes[i].id), recipe: recipes[i], onUpdated: cubit.refresh, ), diff --git a/kitchenowl/lib/pages/recipe_add_update_page.dart b/kitchenowl/lib/pages/recipe_add_update_page.dart index 3c72be22d..8ff28e6cc 100644 --- a/kitchenowl/lib/pages/recipe_add_update_page.dart +++ b/kitchenowl/lib/pages/recipe_add_update_page.dart @@ -210,13 +210,95 @@ class _AddUpdateRecipePageState extends State { AddUpdateRecipeState>( bloc: cubit, buildWhen: (previous, current) => - previous.image != current.image, - builder: (context, state) => ImageSelector( - tooltip: AppLocalizations.of(context)! - .recipeImageSelect, - image: state.image, - originalImage: cubit.recipe.image, - setImage: cubit.setImage, + !listEquals(previous.additionalImages, + current.additionalImages) || + !listEquals(previous.additionalImageFiles, + current.additionalImageFiles), + builder: (context, state) => Padding( + padding: + const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppLocalizations.of(context)! + .recipeImageSelect, + style: + Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ...state.additionalImages.map( + (image) => _RecipeImageTile( + image: Image( + fit: BoxFit.cover, + image: getImageProvider( + context, + image, + maxWidth: 256, + ), + ), + onRemove: () => + cubit.removeAdditionalImage( + image, + ), + ), + ), + ...state.additionalImageFiles.map( + (image) => _RecipeImageTile( + image: Image.memory( + image.bytes, + fit: BoxFit.cover, + ), + onRemove: () => + cubit.removeAdditionalImageFile( + image, + ), + ), + ), + InkWell( + onTap: () async { + final image = await selectFile( + context: context, + title: AppLocalizations.of( + context)! + .recipeImageSelect, + ); + if (image != null && + image.isNotEmpty) { + await cubit.addAdditionalImage( + image); + } + }, + borderRadius: + BorderRadius.circular(12), + child: Container( + width: 88, + height: 88, + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context) + .colorScheme + .secondary, + ), + ), + child: Icon( + Icons.add_photo_alternate_rounded, + color: Theme.of(context) + .colorScheme + .secondary, + ), + ), + ), + ], + ), + ], + ), ), ), Padding( @@ -771,3 +853,50 @@ class _AddUpdateRecipePageState extends State { } } } + +class _RecipeImageTile extends StatelessWidget { + final Widget image; + final VoidCallback onRemove; + + const _RecipeImageTile({ + required this.image, + required this.onRemove, + }); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 88, + height: 88, + child: Stack( + fit: StackFit.expand, + children: [ + image, + Positioned( + top: 4, + right: 4, + child: Material( + color: Theme.of(context).colorScheme.surface.withValues(alpha: .8), + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: IconButton( + onPressed: onRemove, + iconSize: 18, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints.tightFor( + width: 28, + height: 28, + ), + icon: const Icon(Icons.close_rounded), + tooltip: AppLocalizations.of(context)!.delete, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/kitchenowl/lib/pages/recipe_page.dart b/kitchenowl/lib/pages/recipe_page.dart index c34526eb9..2205bbb77 100644 --- a/kitchenowl/lib/pages/recipe_page.dart +++ b/kitchenowl/lib/pages/recipe_page.dart @@ -482,6 +482,7 @@ class _RecipePageState extends State { SliverImageAppBar( title: state.recipe.name, imageUrl: state.recipe.image, + imageUrls: state.recipe.galleryImages, imageHash: state.recipe.imageHash, popValue: () => cubit.state.updateState, actions: (isCollapsed) => [ diff --git a/kitchenowl/lib/services/api/api_service.dart b/kitchenowl/lib/services/api/api_service.dart index 7e63f0241..a368c56e3 100644 --- a/kitchenowl/lib/services/api/api_service.dart +++ b/kitchenowl/lib/services/api/api_service.dart @@ -228,9 +228,13 @@ class ApiService { ), ); - Future postBytes(String url, NamedByteArray array) => + Future postBytes( + String url, + NamedByteArray array, { + Duration? timeout, + }) => _handleRequest( - timeout: _TIMEOUT_FILE_UPLOAD, + timeout: timeout ?? _TIMEOUT_FILE_UPLOAD, () async { final request = http.MultipartRequest('POST', Uri.parse(baseUrl + url)); diff --git a/kitchenowl/lib/services/api/import_export.dart b/kitchenowl/lib/services/api/import_export.dart index 348caa913..95a11c251 100644 --- a/kitchenowl/lib/services/api/import_export.dart +++ b/kitchenowl/lib/services/api/import_export.dart @@ -1,6 +1,9 @@ import 'dart:convert'; +import 'package:kitchenowl/helpers/named_bytearray.dart'; import 'package:kitchenowl/models/household.dart'; +import 'package:kitchenowl/models/recipe_import_preview.dart'; +import 'package:kitchenowl/models/recipe_import_result.dart'; import 'package:kitchenowl/services/api/api_service.dart'; extension ImportExportApi on ApiService { @@ -29,4 +32,54 @@ extension ImportExportApi on ApiService { return res.body; } + + Future previewRecipeImport( + Household household, + NamedByteArray file, + ) async { + final res = await postBytes( + '${householdPath(household)}$baseRoute/recipes/preview', + file, + timeout: const Duration(minutes: 10), + ); + if (res.statusCode != 200) return null; + final body = jsonDecode(res.body) as Map; + + return RecipeImportPreview.fromJson(body); + } + + Future commitRecipeImport( + Household household, + String token, + Map decisions, + ) async { + final res = await post( + '${householdPath(household)}$baseRoute/recipes/commit', + jsonEncode({ + 'token': token, + 'decisions': decisions, + }), + ); + if (res.statusCode != 200) return null; + + return RecipeImportResult.fromJson( + jsonDecode(res.body) as Map, + ); + } + + Future getRecipeImportStatus( + Household household, + String token, + ) async { + final res = await get( + '${householdPath(household)}$baseRoute/recipes/commit/$token', + refreshOnException: false, + timeout: const Duration(seconds: 15), + ); + if (res.statusCode != 200) return null; + + return RecipeImportResult.fromJson( + jsonDecode(res.body) as Map, + ); + } } diff --git a/kitchenowl/lib/widgets/flexible_image_space_bar.dart b/kitchenowl/lib/widgets/flexible_image_space_bar.dart index a48f14c08..99c795ea9 100644 --- a/kitchenowl/lib/widgets/flexible_image_space_bar.dart +++ b/kitchenowl/lib/widgets/flexible_image_space_bar.dart @@ -4,10 +4,11 @@ import 'package:kitchenowl/pages/photo_view_page.dart'; import 'package:kitchenowl/widgets/image_provider.dart'; import 'package:transparent_image/transparent_image.dart'; -class FlexibleImageSpaceBar extends StatelessWidget { +class FlexibleImageSpaceBar extends StatefulWidget { final String title; final int actionCount; final String imageUrl; + final List? imageUrls; final String? imageHash; final bool isCollapsed; @@ -16,79 +17,230 @@ class FlexibleImageSpaceBar extends StatelessWidget { required this.title, this.isCollapsed = false, String? imageUrl, + this.imageUrls, this.imageHash, this.actionCount = 1, }) : imageUrl = imageUrl ?? ""; + @override + State createState() => _FlexibleImageSpaceBarState(); +} + +class _FlexibleImageSpaceBarState extends State { + final PageController _pageController = PageController(); + int _page = 0; + + List get _images => [ + ...(() { + final uniqueImages = []; + final seenImages = {}; + + final candidates = widget.imageUrls?.isNotEmpty ?? false + ? widget.imageUrls! + : [widget.imageUrl]; + for (final candidate in candidates) { + final normalized = candidate.trim(); + if (normalized.isEmpty) { + continue; + } + if (seenImages.add(normalized)) { + uniqueImages.add(normalized); + } + } + + return uniqueImages; + })(), + ]; + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _jumpToPage(int page) { + if (page < 0 || page >= _images.length || page == _page) { + return; + } + setState(() { + _page = page; + }); + if (_pageController.hasClients) { + _pageController.animateToPage( + page, + duration: const Duration(milliseconds: 240), + curve: Curves.easeInOut, + ); + } + } + + void _openCurrentImage() { + if (_images.isEmpty) { + return; + } + final currentIndex = _page.clamp(0, _images.length - 1); + Navigator.of(context, rootNavigator: true).push(MaterialPageRoute( + builder: (context) => PhotoViewPage( + title: widget.title, + imageProvider: getImageProvider(context, _images[currentIndex]), + ), + )); + } + @override Widget build(BuildContext context) { return FlexibleSpaceBar( titlePadding: EdgeInsetsDirectional.only( start: 60, bottom: 16, - end: 16 + actionCount * 40, + end: 16 + widget.actionCount * 40, ), title: Text( - title, - maxLines: isCollapsed ? 1 : 2, + widget.title, + maxLines: widget.isCollapsed ? 1 : 2, overflow: TextOverflow.ellipsis, style: TextStyle( color: Theme.of(context).colorScheme.onSurface, ), ), - background: imageUrl.isNotEmpty - ? GestureDetector( - onTap: () => Navigator.of(context, rootNavigator: true) - .push(MaterialPageRoute( - builder: (context) => PhotoViewPage( - title: title, - imageProvider: getImageProvider( - context, - imageUrl, + background: _images.isNotEmpty + ? Stack( + fit: StackFit.expand, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _openCurrentImage, + child: PageView.builder( + controller: _pageController, + onPageChanged: (value) { + setState(() { + _page = value; + }); + }, + itemCount: _images.length, + itemBuilder: (context, index) { + final imageUrl = _images[index]; + return ShaderMask( + shaderCallback: (rect) { + return const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [.6, .85], + colors: [Colors.black, Colors.transparent], + ).createShader( + Rect.fromLTRB(0, 0, rect.width, rect.height), + ); + }, + blendMode: BlendMode.dstIn, + child: FadeInImage( + placeholder: widget.imageHash != null && index == 0 + ? BlurHashImage(widget.imageHash!) + : MemoryImage(kTransparentImage) + as ImageProvider, + image: getImageProvider(context, imageUrl), + fit: BoxFit.cover, + ), + ); + }, ), - // heroTag: imageUrl, # TODO cannot use Hero inside OpenContainer ), - )), - child: - // Hero( - // tag: imageUrl, - // flightShuttleBuilder: ( - // BuildContext flightContext, - // Animation animation, - // HeroFlightDirection flightDirection, - // BuildContext fromHeroContext, - // BuildContext toHeroContext, - // ) { - // final Hero hero = flightDirection == - // HeroFlightDirection.push - // ? fromHeroContext.widget as Hero - // : toHeroContext.widget as Hero; - - // return hero.child; - // }, - ShaderMask( - shaderCallback: (rect) { - return const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: [.6, .85], - colors: [Colors.black, Colors.transparent], - ).createShader(Rect.fromLTRB(0, 0, rect.width, rect.height)); - }, - blendMode: BlendMode.dstIn, - child: FadeInImage( - placeholder: imageHash != null - ? BlurHashImage(imageHash!) - : MemoryImage(kTransparentImage) as ImageProvider, - image: getImageProvider( - context, - imageUrl, + if (_images.length > 1) + Positioned( + left: 12, + top: 0, + bottom: 0, + child: Center( + child: _GalleryArrowButton( + icon: Icons.chevron_left_rounded, + onPressed: + _page > 0 ? () => _jumpToPage(_page - 1) : null, + ), + ), ), - fit: BoxFit.cover, - ), - ), + if (_images.length > 1) + Positioned( + right: 12, + top: 0, + bottom: 0, + child: Center( + child: _GalleryArrowButton( + icon: Icons.chevron_right_rounded, + onPressed: _page < _images.length - 1 + ? () => _jumpToPage(_page + 1) + : null, + ), + ), + ), + if (_images.length > 1) + Positioned( + left: 0, + right: 0, + bottom: 12, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + _images.length, + (index) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _jumpToPage(index), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 3), + width: 22, + height: 22, + alignment: Alignment.center, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: 7, + height: 7, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: index == _page + ? Theme.of(context).colorScheme.primary + : Theme.of(context) + .colorScheme + .onSurface + .withAlpha(100), + ), + ), + ), + ), + ), + ), + ), + ], ) : null, ); } } + +class _GalleryArrowButton extends StatelessWidget { + final IconData icon; + final VoidCallback? onPressed; + + const _GalleryArrowButton({required this.icon, required this.onPressed}); + + @override + Widget build(BuildContext context) { + final enabled = onPressed != null; + return Material( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.22), + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onPressed, + customBorder: const CircleBorder(), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + icon, + color: enabled + ? Theme.of(context).colorScheme.onSurface + : Theme.of(context).colorScheme.onSurface.withAlpha(90), + size: 28, + ), + ), + ), + ); + } +} diff --git a/kitchenowl/lib/widgets/settings_household/recipe_import_dialog.dart b/kitchenowl/lib/widgets/settings_household/recipe_import_dialog.dart new file mode 100644 index 000000000..d04373d0b --- /dev/null +++ b/kitchenowl/lib/widgets/settings_household/recipe_import_dialog.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'package:kitchenowl/kitchenowl.dart'; +import 'package:kitchenowl/models/recipe_import_preview.dart'; + +const String _actionSkip = 'skip'; +const String _actionOverwrite = 'overwrite'; +const String _actionCopy = 'copy'; +const String _defaultDuplicateAction = _actionSkip; +const String _defaultImportAction = _actionCopy; + +Future?> askForRecipeImportDecisions({ + required BuildContext context, + required RecipeImportPreview preview, +}) async { + if (!preview.hasDuplicates) { + return { + for (final recipe in preview.recipes) recipe.importId: _defaultImportAction, + }; + } + + return showDialog>( + context: context, + builder: (context) => _RecipeImportDialog(preview: preview), + ); +} + +class _RecipeImportDialog extends StatefulWidget { + final RecipeImportPreview preview; + + const _RecipeImportDialog({ + required this.preview, + }); + + @override + State<_RecipeImportDialog> createState() => _RecipeImportDialogState(); +} + +class _RecipeImportDialogState extends State<_RecipeImportDialog> { + late final Map decisions; + late final Map recipesById; + + void _setAllDuplicateDecisions(String action) { + setState(() { + for (final duplicate in widget.preview.duplicates) { + decisions[duplicate.importId] = action; + } + }); + } + + @override + void initState() { + super.initState(); + decisions = { + for (final recipe in widget.preview.recipes) + recipe.importId: _defaultDuplicateAction, + }; + recipesById = { + for (final recipe in widget.preview.recipes) recipe.importId: recipe, + }; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(AppLocalizations.of(context)!.recipeImportDuplicatesTitle), + content: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppLocalizations.of(context)!.recipeImportDuplicatesBody, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Text( + AppLocalizations.of(context)! + .recipeImportApplyAllDuplicates, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + TextButton( + onPressed: () => _setAllDuplicateDecisions(_actionSkip), + child: + Text(AppLocalizations.of(context)!.recipeImportActionSkip), + ), + TextButton( + onPressed: () => _setAllDuplicateDecisions(_actionOverwrite), + child: Text( + AppLocalizations.of(context)!.recipeImportActionOverwrite, + ), + ), + TextButton( + onPressed: () => _setAllDuplicateDecisions(_actionCopy), + child: + Text(AppLocalizations.of(context)!.recipeImportActionCopy), + ), + ], + ), + const SizedBox(height: 12), + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: widget.preview.duplicates.length, + itemBuilder: (context, index) { + final duplicate = widget.preview.duplicates[index]; + final recipe = recipesById[duplicate.importId]; + if (recipe == null) return const SizedBox.shrink(); + return Padding( + key: ValueKey(duplicate.importId), + padding: EdgeInsets.only( + bottom: index == widget.preview.duplicates.length - 1 ? 0 : 12, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recipe.name, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: decisions[duplicate.importId] ?? _defaultDuplicateAction, + items: [ + DropdownMenuItem( + value: _actionSkip, + child: Text( + AppLocalizations.of(context)! + .recipeImportActionSkip, + ), + ), + DropdownMenuItem( + value: _actionOverwrite, + child: Text( + AppLocalizations.of(context)! + .recipeImportActionOverwrite, + ), + ), + DropdownMenuItem( + value: _actionCopy, + child: Text( + AppLocalizations.of(context)! + .recipeImportActionCopy, + ), + ), + ], + onChanged: (value) { + if (value == null) return; + setState(() { + decisions[duplicate.importId] = value; + }); + }, + ), + ], + ), + ); + }, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(null), + child: Text(AppLocalizations.of(context)!.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop({ + for (final d in widget.preview.duplicates) + d.importId: decisions[d.importId] ?? _defaultDuplicateAction, + }), + child: Text(AppLocalizations.of(context)!.import), + ), + ], + ); + } +} diff --git a/kitchenowl/lib/widgets/settings_household/sliver_household_danger_zone.dart b/kitchenowl/lib/widgets/settings_household/sliver_household_danger_zone.dart index e75f9f289..92958be42 100644 --- a/kitchenowl/lib/widgets/settings_household/sliver_household_danger_zone.dart +++ b/kitchenowl/lib/widgets/settings_household/sliver_household_danger_zone.dart @@ -1,138 +1,421 @@ import 'dart:convert'; +import 'dart:async'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:kitchenowl/cubits/household_add_update/household_update_cubit.dart'; +import 'package:kitchenowl/helpers/named_bytearray.dart'; import 'package:kitchenowl/helpers/share.dart'; import 'package:kitchenowl/kitchenowl.dart'; -import 'package:kitchenowl/models/import_settings.dart'; +import 'package:kitchenowl/models/recipe_import_result.dart'; import 'import_settings_dialog.dart'; +import 'recipe_import_dialog.dart'; -class SliverHouseholdDangerZone extends StatelessWidget { +class _RecipeImportProgress { + final int detected; + final int imported; + final int failed; + final int skipped; + final bool complete; + + const _RecipeImportProgress({ + required this.detected, + required this.imported, + required this.failed, + required this.skipped, + required this.complete, + }); +} + +class _RecipeImportProgressTracker { + static final ValueNotifier<_RecipeImportProgress?> value = + ValueNotifier<_RecipeImportProgress?>(null); + + static void start(int detected) { + value.value = _RecipeImportProgress( + detected: detected, + imported: 0, + failed: 0, + skipped: 0, + complete: false, + ); + } + + static void update(RecipeImportResult current, int fallbackDetected) { + value.value = _RecipeImportProgress( + detected: current.detected > 0 ? current.detected : fallbackDetected, + imported: current.imported, + failed: current.failed, + skipped: current.skipped, + complete: current.complete, + ); + } + + static void clear() { + value.value = null; + } +} + +class SliverHouseholdDangerZone extends StatefulWidget { const SliverHouseholdDangerZone({super.key}); + @override + State createState() => + _SliverHouseholdDangerZoneState(); +} + +class _SliverHouseholdDangerZoneState + extends State { @override Widget build(BuildContext context) { return SliverToBoxAdapter( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: Colors.redAccent, - width: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ValueListenableBuilder<_RecipeImportProgress?>( + valueListenable: _RecipeImportProgressTracker.value, + builder: (context, progress, child) { + if (progress == null) return const SizedBox.shrink(); + + return Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: Colors.orangeAccent, + width: 2, + ), + ), + padding: const EdgeInsets.all(16), + margin: const EdgeInsets.only(top: 16, bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + progress.complete + ? AppLocalizations.of(context)! + .recipeImportProgressDoneTitle + : AppLocalizations.of(context)! + .recipeImportProgressTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + [ + AppLocalizations.of(context)!.recipeImportProgressDetected( + progress.detected, + ), + AppLocalizations.of(context)!.recipeImportProgressImported( + progress.imported, + ), + AppLocalizations.of(context)!.recipeImportProgressFailed( + progress.failed, + ), + AppLocalizations.of(context)!.recipeImportProgressSkipped( + progress.skipped, + ), + ].join(' | '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: progress.complete + ? 1 + : (progress.detected > 0 + ? ((progress.imported + + progress.failed + + progress.skipped) / + progress.detected) + .clamp(0.0, 1.0) + : null), + ), + if (progress.complete) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () { + _RecipeImportProgressTracker.clear(); + }, + child: Text(AppLocalizations.of(context)!.done), + ), + ), + ], + ], + ), + ); + }, ), - ), - padding: const EdgeInsets.all(16), - margin: const EdgeInsets.symmetric(vertical: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - '${AppLocalizations.of(context)!.dangerZone}:', - style: Theme.of(context).textTheme.titleLarge, + Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: Colors.redAccent, + width: 2, + ), ), - const SizedBox(height: 16), - Row( + padding: const EdgeInsets.all(16), + margin: const EdgeInsets.symmetric(vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded( - child: Builder( - builder: (context) => LoadingElevatedButton( - onPressed: () async { - final export = + Text( + '${AppLocalizations.of(context)!.dangerZone}:', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Builder( + builder: (context) => LoadingElevatedButton( + onPressed: () async { + final export = + await BlocProvider.of( + context, + ).getExportHousehold(); + if (export == null) return; + Share.shareJsonFile(context, export, + '${BlocProvider.of(context).household.name}_export.json'); + }, + child: Text(AppLocalizations.of(context)!.export), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: LoadingElevatedButton( + onPressed: () async { + final file = await FilePicker.pickFiles( + allowMultiple: false, + allowedExtensions: ['json'], + dialogTitle: 'Please select a file to import:', + type: FileType.custom, + withData: true, + ); + if (file == null || file.files.first.bytes == null) { + return; + } + + try { + final decoded = jsonDecode( + String.fromCharCodes(file.files.first.bytes!), + ); + if (decoded is! Map) { + showSnackbar( + context: context, + content: const Text( + 'Selected file is not a valid household export.', + ), + width: null, + ); + return; + } + + final settings = + await askForImportSettings(context: context); + if (settings == null) return; + + if (!settings.items && + !settings.recipes && + !settings.expenses && + !settings.shoppinglists) { + showSnackbar( + context: context, + content: const Text( + 'Select at least one section to import.', + ), + width: null, + ); + return; + } + + showSnackbar( + context: context, + content: Text( + AppLocalizations.of(context)!.importStartedHint, + ), + width: null, + ); + await BlocProvider.of( - context, - ).getExportHousehold(); - if (export == null) return; - Share.shareJsonFile(context, export, - '${BlocProvider.of(context).household.name}_export.json'); - }, - child: Text(AppLocalizations.of(context)!.export), + context, + ).importHousehold( + decoded, + settings, + ); + } on FormatException { + showSnackbar( + context: context, + content: const Text( + 'Selected file is not valid JSON.', + ), + width: null, + ); + } catch (_) { + showSnackbar( + context: context, + content: const Text('Import failed.'), + width: null, + ); + } + }, + child: Text(AppLocalizations.of(context)!.import), + ), ), - ), + ], ), - const SizedBox(width: 8), - Expanded( - child: LoadingElevatedButton( - onPressed: () async { - final file = await FilePicker.pickFiles( - allowMultiple: false, - allowedExtensions: ['json'], - dialogTitle: 'Please select a file to import:', - type: FileType.custom, - withData: true, + const SizedBox(height: 8), + LoadingElevatedButton( + onPressed: () async { + final file = await FilePicker.pickFiles( + allowMultiple: false, + allowedExtensions: ['json', 'zip'], + dialogTitle: AppLocalizations.of(context)! + .recipeImportChooseFile, + type: FileType.custom, + withData: true, + ); + if (file == null || file.files.first.bytes == null) return; + + final preview = + await BlocProvider.of(context) + .previewRecipeImport( + NamedByteArray( + file.files.first.name, + file.files.first.bytes!, + ), + ); + + if (preview == null) { + showSnackbar( + context: context, + content: Text( + AppLocalizations.of(context)! + .recipeImportPreviewFailed, + ), + width: null, ); - if (file != null && file.files.first.name.isNotEmpty) { - try { - dynamic content = jsonDecode( - String.fromCharCodes(file.files.first.bytes!), - ); - if (content == null || - content is! Map) return; + return; + } - ImportSettings? settings = - await askForImportSettings(context: context); + if (preview.recipes.isEmpty) { + showSnackbar( + context: context, + content: Text( + AppLocalizations.of(context)! + .recipeImportNoRecipes, + ), + width: null, + ); + return; + } - if (settings == null) return; + final householdUpdateCubit = + BlocProvider.of(context); - showSnackbar( - context: context, - content: Text( - AppLocalizations.of(context)!.importStartedHint, - ), - width: null, - ); + final decisions = await askForRecipeImportDecisions( + context: context, + preview: preview, + ); + if (decisions == null) return; - return BlocProvider.of(context) - .importHousehold( - content, - settings, - ); - } catch (_) {} + _RecipeImportProgressTracker.start(preview.recipes.length); + + final result = await householdUpdateCubit.commitRecipeImport( + preview.token, + decisions, + ); + + if (result != null) { + RecipeImportResult current = result; + int failedStatusPolls = 0; + const int maxFailedStatusPolls = 100; + _RecipeImportProgressTracker.update( + current, + preview.recipes.length, + ); + while (!current.complete) { + await Future.delayed( + const Duration(milliseconds: 500), + ); + final status = + await householdUpdateCubit.getRecipeImportStatus( + preview.token, + ); + if (status == null) { + failedStatusPolls += 1; + if (failedStatusPolls >= maxFailedStatusPolls) { + if (mounted) { + showSnackbar( + context: context, + content: Text( + AppLocalizations.of(context)! + .recipeImportStatusFetchFailed, + ), + width: null, + ); + } + break; + } + continue; + } + failedStatusPolls = 0; + current = status; + _RecipeImportProgressTracker.update( + current, + preview.recipes.length, + ); } - }, - child: Text(AppLocalizations.of(context)!.import), - ), + } else { + _RecipeImportProgressTracker.clear(); + } + }, + child: Text(AppLocalizations.of(context)!.recipeImportTitle), ), - ], - ), - const Divider(), - LoadingElevatedButton( - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all( - Colors.redAccent, - ), - foregroundColor: WidgetStateProperty.all( - Colors.white, - ), - ), - onPressed: () async { - final confirm = await askForConfirmation( - context: context, - title: Text( - AppLocalizations.of(context)!.householdDelete, - ), - content: Text( - AppLocalizations.of(context)!.householdDeleteConfirmation( - BlocProvider.of(context) - .household - .name, + const Divider(), + LoadingElevatedButton( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + Colors.redAccent, + ), + foregroundColor: WidgetStateProperty.all( + Colors.white, ), ), - ); - if (confirm) { - if (await BlocProvider.of(context) - .deleteHousehold()) { - context.go('/household'); - } - } - }, - child: Text(AppLocalizations.of(context)!.householdDelete), + onPressed: () async { + final confirm = await askForConfirmation( + context: context, + title: Text( + AppLocalizations.of(context)!.householdDelete, + ), + content: Text( + AppLocalizations.of(context)! + .householdDeleteConfirmation( + BlocProvider.of(context) + .household + .name, + ), + ), + ); + if (confirm) { + if (await BlocProvider.of(context) + .deleteHousehold()) { + context.go('/household'); + } + } + }, + child: Text(AppLocalizations.of(context)!.householdDelete), + ), + ], ), - ], - ), + ), + ], ), ); } diff --git a/kitchenowl/lib/widgets/sliver_image_app_bar.dart b/kitchenowl/lib/widgets/sliver_image_app_bar.dart index 42cd5bdd0..369b3ec36 100644 --- a/kitchenowl/lib/widgets/sliver_image_app_bar.dart +++ b/kitchenowl/lib/widgets/sliver_image_app_bar.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:kitchenowl/kitchenowl.dart'; +import 'package:kitchenowl/widgets/flexible_image_space_bar.dart'; class SliverImageAppBar extends StatefulWidget { final String title; final String? imageUrl; + final List? imageUrls; final String? imageHash; final Object? Function() popValue; final List? Function(bool isCollapsed)? actions; @@ -13,6 +14,7 @@ class SliverImageAppBar extends StatefulWidget { super.key, required this.title, required this.imageUrl, + this.imageUrls, this.imageHash, required this.popValue, this.actions, @@ -32,19 +34,22 @@ class SliverImageAppBarrState extends State { return SliverAppBar( flexibleSpace: LayoutBuilder(builder: (context, constraints) { - bool localIsCollapsed = constraints.biggest.height <= + final bool localIsCollapsed = constraints.biggest.height <= MediaQuery.paddingOf(context).top + kToolbarHeight - 16 + 32; - if (isCollapsed != localIsCollapsed) + if (isCollapsed != localIsCollapsed) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) + if (mounted) { setState(() { isCollapsed = localIsCollapsed; }); + } }); + } return FlexibleImageSpaceBar( title: widget.title, imageUrl: widget.imageUrl, + imageUrls: widget.imageUrls, imageHash: widget.imageHash, isCollapsed: isCollapsed, actionCount: isCollapsed ? actions?.length ?? 0 : 0, @@ -56,18 +61,20 @@ class SliverImageAppBarrState extends State { (widget.imageUrl == null || widget.imageUrl!.isEmpty || isCollapsed ? IconButton.new : IconButton.filledTonal)( - key: ValueKey('back' + isCollapsed.toString()), + key: ValueKey('back$isCollapsed'), icon: const BackButtonIcon(), tooltip: MaterialLocalizations.of(context).backButtonTooltip, onPressed: () { - if (Navigator.canPop(context)) + if (Navigator.canPop(context)) { Navigator.of(context).pop(widget.popValue()); - else - context.go("/"); + } else { + context.go('/'); + } }, ), ), - expandedHeight: widget.imageUrl?.isNotEmpty ?? false + expandedHeight: (widget.imageUrls?.isNotEmpty ?? false) || + (widget.imageUrl?.isNotEmpty ?? false) ? (MediaQuery.sizeOf(context).height / 3.3).clamp(160, 350) : null, pinned: true, diff --git a/kitchenowl/test/helpers/short_image_markdown_extension_test.dart b/kitchenowl/test/helpers/short_image_markdown_extension_test.dart new file mode 100644 index 000000000..4395fd069 --- /dev/null +++ b/kitchenowl/test/helpers/short_image_markdown_extension_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:kitchenowl/helpers/short_image_markdown_extension.dart'; + +md.Document _buildDocument() { + return md.Document( + extensionSet: md.ExtensionSet.gitHubWeb, + encodeHtml: false, + inlineSyntaxes: [ShortImageMarkdownSyntax()], + ); +} + +List _parseImageNodes(List lines) { + final nodes = _buildDocument().parseLines(lines); + return nodes + .whereType() + .expand((node) => node.children ?? const []) + .whereType() + .where((node) => node.tag == 'img') + .toList(); +} + +void main() { + test('Short image syntax should not override standard markdown images', () { + final imageNodes = _parseImageNodes(['![step image](step_1.jpg)']); + + expect(imageNodes, hasLength(1)); + expect(imageNodes.first.attributes['src'], equals('step_1.jpg')); + expect(imageNodes.first.attributes['alt'], equals('step image')); + }); + + test('Numeric short image syntax should produce numeric src', () { + final imageNodes = _parseImageNodes(['![0]']); + + expect(imageNodes, hasLength(1)); + expect(imageNodes.first.attributes['src'], equals('0')); + }); +} \ No newline at end of file diff --git a/kitchenowl/test/models/recipe_import_preview_test.dart b/kitchenowl/test/models/recipe_import_preview_test.dart new file mode 100644 index 000000000..43136806e --- /dev/null +++ b/kitchenowl/test/models/recipe_import_preview_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kitchenowl/models/recipe_import_preview.dart'; + +void main() { + test('RecipeImportPreview fromJson skips malformed list entries', () { + final preview = RecipeImportPreview.fromJson({ + 'token': 'preview-token', + 'recipes': [ + null, + 'invalid', + { + 'import_id': 'import-1', + 'name': 'Soup', + 'source': 'https://example.test/soup', + }, + ], + 'duplicates': [ + 123, + { + 'import_id': 'import-1', + 'recipe_id': '42', + 'recipe_name': 'Soup', + }, + { + 'import_id': 'import-2', + 'recipe_name': 'Pasta', + }, + ], + }); + + expect(preview.token, 'preview-token'); + expect(preview.recipes, hasLength(1)); + expect(preview.recipes.single.importId, 'import-1'); + expect(preview.duplicates, hasLength(2)); + expect(preview.duplicates.first.recipeId, 42); + expect(preview.duplicates.last.recipeId, isNull); + }); + + test('RecipeImportPreview duplicate lookup uses indexed access', () { + final preview = RecipeImportPreview( + token: 'token', + recipes: const [], + duplicates: const [ + RecipeImportDuplicate( + importId: 'import-1', + recipeId: 7, + recipeName: 'Soup', + ), + ], + ); + + expect(preview.hasDuplicates, isTrue); + expect(preview.duplicateFor('import-1')?.recipeId, 7); + expect(preview.duplicateFor('missing'), isNull); + }); + + test('RecipeImportPreview fromJson tolerates missing keys', () { + final preview = RecipeImportPreview.fromJson({ + 'recipes': [ + { + 'import_id': 'import-1', + }, + ], + 'duplicates': [ + { + 'import_id': 'import-2', + }, + ], + }); + + expect(preview.token, isEmpty); + expect(preview.recipes.single.name, isEmpty); + expect(preview.recipes.single.source, isEmpty); + expect(preview.duplicates.single.recipeId, isNull); + expect(preview.duplicates.single.recipeName, isEmpty); + }); +} \ No newline at end of file diff --git a/kitchenowl/test/models/recipe_test.dart b/kitchenowl/test/models/recipe_test.dart new file mode 100644 index 000000000..842b13c71 --- /dev/null +++ b/kitchenowl/test/models/recipe_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:kitchenowl/models/recipe.dart'; + +void main() { + test("Recipe gallery images should be deduplicated", () { + final recipe = Recipe( + image: "preview.jpg", + additionalImages: const [ + "preview.jpg", + " step_1.jpg ", + "step_1.jpg", + "step_2.jpg", + "", + ], + ); + + expect( + recipe.galleryImages, + equals(["preview.jpg", "step_1.jpg", "step_2.jpg"]), + ); + }); +} \ No newline at end of file