From 68e7891964c5dcb7f46c9e381a026e004b4e06eb Mon Sep 17 00:00:00 2001 From: Jojo-05 Date: Sat, 30 May 2026 00:17:54 +0200 Subject: [PATCH 01/16] feat(backend): add recipe import from external apps --- backend/app/config.py | 9 +- .../exportimport/import_controller.py | 51 +- .../app/controller/exportimport/schemas.py | 10 + .../controller/recipe/recipe_controller.py | 12 + backend/app/models/recipe.py | 7 +- .../service/importServices/import_recipe.py | 100 ++- backend/app/service/recipe_import_service.py | 714 ++++++++++++++++++ .../6950c7b662d1_recipe_photos_list.py | 25 + .../tests/api/test_recipe_import_formats.py | 182 +++++ 9 files changed, 1098 insertions(+), 12 deletions(-) create mode 100644 backend/app/service/recipe_import_service.py create mode 100644 backend/migrations/versions/6950c7b662d1_recipe_photos_list.py create mode 100644 backend/tests/api/test_recipe_import_formats.py diff --git a/backend/app/config.py b/backend/app/config.py index 4920aa611..92750ff84 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 = 128 ALLOWED_FILE_EXTENSIONS = {"txt", "pdf", "png", "jpg", "jpeg", "gif", "webp", "jxl"} FRONT_URL = os.getenv("FRONT_URL") @@ -148,7 +149,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 * 1000 * 1000 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..d20ebbb70 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 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: + return jsonify({"imported": 0, "skipped": 0, "failed": 0, "complete": True}) + 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/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 5211ef906..c200eff00 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -104,6 +104,12 @@ def addRecipe(args, household_id): recipe.visibility = RecipeVisibility(args["visibility"]) if "photo" in args and args["photo"] != recipe.photo: recipe.photo = file_has_access_or_download(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 + ] if "server_curated" in args and current_user.admin: recipe.server_curated = args["server_curated"] recipe.save() @@ -157,6 +163,12 @@ def updateRecipe(args, id): # noqa: C901 recipe.visibility = RecipeVisibility(args["visibility"]) if "photo" in args and args["photo"] != recipe.photo: recipe.photo = file_has_access_or_download(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 + ] if "server_curated" in args and current_user.admin: recipe.server_curated = args["server_curated"] recipe.save() 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/importServices/import_recipe.py b/backend/app/service/importServices/import_recipe.py index 69826abae..fac7e1a40 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.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: @@ -20,7 +74,13 @@ def importRecipe(household_id: int, args: dict, overwrite: bool = False): 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,18 +92,42 @@ 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] + 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() diff --git a/backend/app/service/recipe_import_service.py b/backend/app/service/recipe_import_service.py new file mode 100644 index 000000000..0f42e7490 --- /dev/null +++ b/backend/app/service/recipe_import_service.py @@ -0,0 +1,714 @@ +from __future__ import annotations + +import io +import json +import gzip +import os +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.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 _normalize_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +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) + text = str(value) + digits = "".join([c for c in text if c.isdigit() or c == " "]) + parts = [p for p in digits.split(" ") if p] + if not parts: + return None + try: + return int(parts[0]) + except ValueError: + return None + + +def _normalize_items(value: Any) -> list[dict[str, Any]]: + if not value: + return [] + if isinstance(value, list): + 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") + ) + if not name: + continue + quantity = _normalize_text(entry.get("quantity") or entry.get("amount")) + note = _normalize_text(entry.get("note")) + description_parts = [p for p in [quantity, note] if p] + items.append( + { + "name": name, + "description": " ".join(description_parts).strip(), + "optional": bool(entry.get("optional", False)), + } + ) + else: + name = _normalize_text(entry) + if name: + items.append( + { + "name": name, + "description": "", + "optional": False, + } + ) + return items + return [] + + +def _normalize_step_image(entry: dict[str, Any]) -> str: + image_sources = [ + entry.get("image"), + entry.get("photo"), + entry.get("picture"), + entry.get("preview_picture"), + entry.get("step_image"), + entry.get("stepImage"), + entry.get("img"), + ] + images = entry.get("images") + if isinstance(images, list): + image_sources.extend(images) + for source in image_sources: + if isinstance(source, dict): + source = source.get("url") or source.get("src") or source.get("path") + source_text = _normalize_text(source) + if source_text: + return source_text + return "" + + +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("instructions") or raw.get("method") + if isinstance(instructions, list): + steps = [] + for entry in instructions: + if isinstance(entry, dict): + text = _normalize_text( + entry.get("text") or entry.get("instruction") or entry.get("value") + ) + if text: + image = _normalize_step_image(entry) + if image: + steps.append(f"![step image]({image})\n\n{text}") + else: + steps.append(text) + else: + text = _normalize_text(entry) + if text: + steps.append(text) + if steps: + if description: + description += "\n\n" + description += "\n".join( + [f"{idx + 1}. {step}" for idx, step in enumerate(steps)] + ) + else: + instructions_text = _normalize_text(instructions) + if instructions_text: + if description: + description += "\n\n" + description += instructions_text + + recipe: dict[str, Any] = { + "name": name, + "description": description, + "time": _normalize_int(raw.get("time") or raw.get("total_time")), + "cook_time": _normalize_int(raw.get("cook_time")), + "prep_time": _normalize_int(raw.get("prep_time")), + "yields": _normalize_int( + raw.get("yields") or raw.get("servings") or raw.get("persons_served") + ), + "source": _normalize_text(raw.get("source") or raw.get("url")), + "photo": _normalize_text( + raw.get("photo") or raw.get("image") or raw.get("preview_picture") + ), + "photos": [ + _normalize_text(photo) + for photo in (raw.get("photos") or raw.get("images") or []) + if _normalize_text(photo) + ], + "items": _normalize_items(raw.get("items") or raw.get("ingredients")), + "tags": [ + _normalize_text(tag) + for tag in (raw.get("tags") or []) + if _normalize_text(tag) + ], + } + + if recipe["time"] is None: + recipe["time"] = _parse_minutes(raw.get("time") or raw.get("total_time")) + if recipe["cook_time"] is None: + recipe["cook_time"] = _parse_minutes(raw.get("cook_time")) + if recipe["prep_time"] is None: + recipe["prep_time"] = _parse_minutes(raw.get("prep_time")) + + if not recipe["tags"] and isinstance(raw.get("categories"), list): + recipe["tags"] = [ + _normalize_text(tag) + for tag in raw.get("categories", []) + if _normalize_text(tag) + ] + + if not recipe["photo"]: + recipe.pop("photo", None) + if not recipe["photos"]: + recipe.pop("photos", None) + return recipe + + +def _extract_recipes(payload: Any) -> list[dict[str, Any]]: + recipes: list[dict[str, Any]] = [] + if isinstance(payload, dict): + candidates = payload.get("recipes") + if isinstance(candidates, list): + for entry in candidates: + if isinstance(entry, dict): + normalized = _normalize_recipe(entry) + if normalized: + recipes.append(normalized) + else: + normalized = _normalize_recipe(payload) + if normalized: + recipes.append(normalized) + elif isinstance(payload, list): + for entry in payload: + if isinstance(entry, dict): + normalized = _normalize_recipe(entry) + if normalized: + recipes.append(normalized) + return recipes + + +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: + for root, dirs, files in os.walk(entry.path, topdown=False): + for file in files: + os.remove(os.path.join(root, file)) + for dir_name in dirs: + os.rmdir(os.path.join(root, dir_name)) + os.rmdir(entry.path) + except Exception: + continue + + +def _resolve_zip_photo( + photo_value: str, + zip_entries: dict[str, str], + zip_file: zipfile.ZipFile, + images_dir: str, +) -> str | None: + if not photo_value: + return None + photo_key = photo_value.strip().lower().replace("\\", "/") + if photo_key in zip_entries: + entry_name = zip_entries[photo_key] + else: + base_name = os.path.basename(photo_key) + entry_name = zip_entries.get(base_name) + 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 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) + 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: + for root, dirs, files in os.walk(base_dir, topdown=False): + for file in files: + os.remove(os.path.join(root, file)) + for dir_name in dirs: + os.rmdir(os.path.join(root, dir_name)) + os.rmdir(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..36c216713 --- /dev/null +++ b/backend/migrations/versions/6950c7b662d1_recipe_photos_list.py @@ -0,0 +1,25 @@ +"""recipe photos list + +Revision ID: 6950c7b662d1 +Revises: bd383e73ef4d +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 = 'bd383e73ef4d' +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/tests/api/test_recipe_import_formats.py b/backend/tests/api/test_recipe_import_formats.py new file mode 100644 index 000000000..b36eb17c4 --- /dev/null +++ b/backend/tests/api/test_recipe_import_formats.py @@ -0,0 +1,182 @@ +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 +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 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" + ) From 4c4315b71cebe902671b77b3b2f2a4f463f56741 Mon Sep 17 00:00:00 2001 From: Jojo-05 Date: Sat, 30 May 2026 19:13:58 +0200 Subject: [PATCH 02/16] feat(frontend): add recipe import functionality and multi-image support --- backend/app/controller/mcp_controller.py | 48 +- backend/app/models/item.py | 12 +- backend/app/service/recipe_import_service.py | 6 +- .../household_update_cubit.dart | 20 + .../lib/cubits/recipe_add_update_cubit.dart | 37 +- .../short_image_markdown_extension.dart | 2 +- kitchenowl/lib/l10n/app_de.arb | 95 ++++ kitchenowl/lib/l10n/app_en.arb | 95 ++++ kitchenowl/lib/models/recipe.dart | 31 +- .../lib/models/recipe_import_preview.dart | 95 ++++ .../lib/models/recipe_import_result.dart | 27 + .../lib/pages/household_page/recipe_list.dart | 2 +- .../lib/pages/recipe_add_update_page.dart | 99 ++++ kitchenowl/lib/pages/recipe_page.dart | 1 + .../lib/services/api/import_export.dart | 48 ++ .../lib/widgets/flexible_image_space_bar.dart | 256 ++++++++-- .../recipe_import_dialog.dart | 181 +++++++ .../sliver_household_danger_zone.dart | 467 ++++++++++++++---- .../lib/widgets/sliver_image_app_bar.dart | 25 +- .../short_image_markdown_extension_test.dart | 38 ++ .../models/recipe_import_preview_test.dart | 77 +++ kitchenowl/test/models/recipe_test.dart | 22 + 22 files changed, 1495 insertions(+), 189 deletions(-) create mode 100644 kitchenowl/lib/models/recipe_import_preview.dart create mode 100644 kitchenowl/lib/models/recipe_import_result.dart create mode 100644 kitchenowl/lib/widgets/settings_household/recipe_import_dialog.dart create mode 100644 kitchenowl/test/helpers/short_image_markdown_extension_test.dart create mode 100644 kitchenowl/test/models/recipe_import_preview_test.dart create mode 100644 kitchenowl/test/models/recipe_test.dart 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/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/service/recipe_import_service.py b/backend/app/service/recipe_import_service.py index 0f42e7490..b543785bd 100644 --- a/backend/app/service/recipe_import_service.py +++ b/backend/app/service/recipe_import_service.py @@ -161,11 +161,7 @@ def _normalize_recipe(raw: dict[str, Any]) -> dict[str, Any] | None: entry.get("text") or entry.get("instruction") or entry.get("value") ) if text: - image = _normalize_step_image(entry) - if image: - steps.append(f"![step image]({image})\n\n{text}") - else: - steps.append(text) + steps.append(text) else: text = _normalize_text(entry) if text: 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..ed30fc40c 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.additionalImages, selectedTags: recipe.tags, tags: recipe.tags, visibility: recipe.visibility, @@ -51,6 +52,14 @@ class AddUpdateRecipeCubit extends ReplayCubit { ? '' : 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( household, @@ -63,6 +72,7 @@ class AddUpdateRecipeCubit extends ReplayCubit { yields: _state.yields, source: _state.source, image: image ?? recipe.image, + additionalImages: additionalImages, items: _state.items, tags: _state.selectedTags, visibility: _state.visibility, @@ -79,13 +89,19 @@ class AddUpdateRecipeCubit extends ReplayCubit { 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 +122,15 @@ 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 setDescription(String desc) { emit(state.copyWith(description: desc, hasChanges: true)); } @@ -251,6 +276,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 +294,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 +313,8 @@ class AddUpdateRecipeState extends Equatable { String? source, bool? curated, NamedByteArray? image, + List? additionalImages, + List? additionalImageFiles, RecipeVisibility? visibility, List? items, Set? tags, @@ -300,6 +331,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 +353,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 9b5099b59..a474a5489 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": { @@ -426,7 +502,26 @@ "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.", + "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 c96acb692..5a5db0f09 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": {}, @@ -710,7 +786,26 @@ "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.", + "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..cc23b5683 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 {}, @@ -87,13 +89,17 @@ class Recipe extends Model { source: map['source'] ?? '', image: map['photo'], imageHash: map['photo_hash'], + additionalImages: List.from(map['photos'] ?? const []) + .where((e) => e.toString().isNotEmpty) + .map((e) => e.toString()) + .toList(), 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 +115,7 @@ class Recipe extends Model { int? yields, String? source, String? image, + List? additionalImages, RecipeVisibility? visibility, List? items, Set? tags, @@ -130,6 +137,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 +168,7 @@ class Recipe extends Model { source, image, imageHash, + additionalImages, tags, items, plannedCookingDates, @@ -180,6 +189,7 @@ class Recipe extends Model { "source": source, "visibility": visibility.index, if (image != null) "photo": image, + if (additionalImages.isNotEmpty) "photos": additionalImages, "items": items.map((e) => e.toJson()).toList(), "tags": tags.map((e) => e.toString()).toList(), "server_curated": curated, @@ -193,11 +203,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..c766f5054 100644 --- a/kitchenowl/lib/pages/recipe_add_update_page.dart +++ b/kitchenowl/lib/pages/recipe_add_update_page.dart @@ -219,6 +219,105 @@ class _AddUpdateRecipePageState extends State { setImage: cubit.setImage, ), ), + BlocBuilder( + bloc: cubit, + buildWhen: (previous, current) => + !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) => ClipRRect( + borderRadius: + BorderRadius.circular(12), + child: SizedBox( + width: 88, + height: 88, + child: Image( + fit: BoxFit.cover, + image: getImageProvider( + context, + image, + maxWidth: 256, + ), + ), + ), + ), + ), + ...state.additionalImageFiles.map( + (image) => ClipRRect( + borderRadius: + BorderRadius.circular(12), + child: SizedBox( + width: 88, + height: 88, + child: Image.memory( + image.bytes, + fit: BoxFit.cover, + ), + ), + ), + ), + 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( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: TextField( 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/import_export.dart b/kitchenowl/lib/services/api/import_export.dart index 348caa913..dc0105ec3 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,49 @@ extension ImportExportApi on ApiService { return res.body; } + + Future previewRecipeImport( + Household household, + NamedByteArray file, + ) async { + final res = await postBytes( + '${householdPath(household)}$baseRoute/recipes/preview', + file, + ); + 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'); + 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..652d22b41 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,401 @@ 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 SliverHouseholdDangerZone extends StatefulWidget { const SliverHouseholdDangerZone({super.key}); + @override + State createState() => + _SliverHouseholdDangerZoneState(); +} + +class _SliverHouseholdDangerZoneState + extends State { + _RecipeImportProgress? _progress; + @override Widget build(BuildContext context) { return SliverToBoxAdapter( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: Colors.redAccent, - width: 2, - ), - ), - 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, + child: Column( + children: [ + if (_progress != null) ...[ + Container( + 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), + ), + const SizedBox(height: 4), + Text( + AppLocalizations.of(context)! + .recipeImportProgressImported(_progress!.imported), + ), + const SizedBox(height: 4), + Text( + AppLocalizations.of(context)! + .recipeImportProgressFailed(_progress!.failed), + ), + const SizedBox(height: 4), + Text( + AppLocalizations.of(context)! + .recipeImportProgressSkipped(_progress!.skipped), + ), + if (_progress!.complete) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () { + setState(() { + _progress = null; + }); + }, + child: Text(AppLocalizations.of(context)!.done), + ), + ), + ], + ], + ), ), - const SizedBox(height: 16), - Row( + ], + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: Colors.redAccent, + width: 2, + ), + ), + 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 Divider(), + 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 decisions = await askForRecipeImportDecisions( + context: context, + preview: preview, + ); + if (decisions == null) return; - showSnackbar( - context: context, - content: Text( - AppLocalizations.of(context)!.importStartedHint, - ), - width: null, - ); + setState(() { + _progress = _RecipeImportProgress( + detected: preview.recipes.length, + imported: 0, + failed: 0, + skipped: 0, + complete: false, + ); + }); + + showSnackbar( + context: context, + content: Text( + AppLocalizations.of(context)!.importStartedHint, + ), + width: null, + ); + + final result = + await BlocProvider.of(context) + .commitRecipeImport(preview.token, decisions); - return BlocProvider.of(context) - .importHousehold( - content, - settings, + if (!mounted) return; + + if (result != null) { + RecipeImportResult current = result; + setState(() { + _progress = _RecipeImportProgress( + detected: current.detected > 0 + ? current.detected + : preview.recipes.length, + imported: current.imported, + failed: current.failed, + skipped: current.skipped, + complete: current.complete, + ); + }); + while (!current.complete && mounted) { + await Future.delayed( + const Duration(milliseconds: 500), + ); + final status = + await BlocProvider.of(context) + .getRecipeImportStatus(preview.token); + if (!mounted || status == null) break; + current = status; + setState(() { + _progress = _RecipeImportProgress( + detected: current.detected > 0 + ? current.detected + : preview.recipes.length, + imported: current.imported, + failed: current.failed, + skipped: current.skipped, + complete: current.complete, ); - } catch (_) {} + }); } - }, - child: Text(AppLocalizations.of(context)!.import), - ), - ), - ], - ), - const Divider(), - LoadingElevatedButton( - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all( - Colors.redAccent, - ), - foregroundColor: WidgetStateProperty.all( - Colors.white, + if (mounted && _progress != null) { + showSnackbar( + context: context, + content: Text( + AppLocalizations.of(context)! + .recipeImportResultSummary( + _progress!.imported, + _progress!.failed, + _progress!.skipped, + ), + ), + width: null, + ); + } + } else { + setState(() { + _progress = null; + }); + } + }, + child: Text(AppLocalizations.of(context)!.recipeImportTitle), ), - ), - 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 From e7585cf32c1dfd780be732c9e8d20bd097928229 Mon Sep 17 00:00:00 2001 From: Jojo-05 Date: Sat, 30 May 2026 19:54:50 +0200 Subject: [PATCH 03/16] fix(recipe-import): add step normalization to avoid duplicates --- backend/app/service/recipe_import_service.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/app/service/recipe_import_service.py b/backend/app/service/recipe_import_service.py index b543785bd..4af239841 100644 --- a/backend/app/service/recipe_import_service.py +++ b/backend/app/service/recipe_import_service.py @@ -4,6 +4,7 @@ import json import gzip import os +import re import time import uuid import zipfile @@ -63,6 +64,13 @@ def _normalize_text(value: Any) -> str: return str(value).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 @@ -157,13 +165,13 @@ def _normalize_recipe(raw: dict[str, Any]) -> dict[str, Any] | None: steps = [] for entry in instructions: if isinstance(entry, dict): - text = _normalize_text( + text = _normalize_instruction_step( entry.get("text") or entry.get("instruction") or entry.get("value") ) if text: steps.append(text) else: - text = _normalize_text(entry) + text = _normalize_instruction_step(entry) if text: steps.append(text) if steps: From aef85aa89384e249411c1e86f4deedbaab2ff9e0 Mon Sep 17 00:00:00 2001 From: Jojo-05 Date: Sun, 31 May 2026 12:43:33 +0200 Subject: [PATCH 04/16] fix(recipe-import): progress tracking and improve UI --- .../lib/services/api/import_export.dart | 6 +- .../sliver_household_danger_zone.dart | 269 ++++++++++-------- 2 files changed, 162 insertions(+), 113 deletions(-) diff --git a/kitchenowl/lib/services/api/import_export.dart b/kitchenowl/lib/services/api/import_export.dart index dc0105ec3..ca0e9cbd3 100644 --- a/kitchenowl/lib/services/api/import_export.dart +++ b/kitchenowl/lib/services/api/import_export.dart @@ -70,7 +70,11 @@ extension ImportExportApi on ApiService { Household household, String token, ) async { - final res = await get('${householdPath(household)}$baseRoute/recipes/commit/$token'); + 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( 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 652d22b41..fcb602b63 100644 --- a/kitchenowl/lib/widgets/settings_household/sliver_household_danger_zone.dart +++ b/kitchenowl/lib/widgets/settings_household/sliver_household_danger_zone.dart @@ -30,6 +30,35 @@ class _RecipeImportProgress { }); } +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}); @@ -40,74 +69,89 @@ class SliverHouseholdDangerZone extends StatefulWidget { class _SliverHouseholdDangerZoneState extends State { - _RecipeImportProgress? _progress; - @override Widget build(BuildContext context) { return SliverToBoxAdapter( child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (_progress != null) ...[ - Container( - 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), - ), - const SizedBox(height: 4), - Text( - AppLocalizations.of(context)! - .recipeImportProgressImported(_progress!.imported), - ), - const SizedBox(height: 4), - Text( - AppLocalizations.of(context)! - .recipeImportProgressFailed(_progress!.failed), - ), - const SizedBox(height: 4), - Text( - AppLocalizations.of(context)! - .recipeImportProgressSkipped(_progress!.skipped), + 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, ), - if (_progress!.complete) ...[ + ), + 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), - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: () { - setState(() { - _progress = null; - }); - }, - child: Text(AppLocalizations.of(context)!.done), - ), + 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), + ), + ), + ], ], - ], - ), - ), - ], + ), + ); + }, + ), Container( + width: double.infinity, decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), border: Border.all( @@ -226,7 +270,7 @@ class _SliverHouseholdDangerZoneState ), ], ), - const Divider(), + const SizedBox(height: 8), LoadingElevatedButton( onPressed: () async { final file = await FilePicker.pickFiles( @@ -272,88 +316,89 @@ class _SliverHouseholdDangerZoneState return; } + final householdUpdateCubit = + BlocProvider.of(context); + final decisions = await askForRecipeImportDecisions( context: context, preview: preview, ); if (decisions == null) return; - setState(() { - _progress = _RecipeImportProgress( - detected: preview.recipes.length, - imported: 0, - failed: 0, - skipped: 0, - complete: false, + _RecipeImportProgressTracker.start(preview.recipes.length); + + if (mounted) { + showSnackbar( + context: context, + content: Text( + AppLocalizations.of(context)!.importStartedHint, + ), + width: null, ); - }); + } - showSnackbar( - context: context, - content: Text( - AppLocalizations.of(context)!.importStartedHint, - ), - width: null, + final result = await householdUpdateCubit.commitRecipeImport( + preview.token, + decisions, ); - final result = - await BlocProvider.of(context) - .commitRecipeImport(preview.token, decisions); - - if (!mounted) return; - if (result != null) { RecipeImportResult current = result; - setState(() { - _progress = _RecipeImportProgress( - detected: current.detected > 0 - ? current.detected - : preview.recipes.length, - imported: current.imported, - failed: current.failed, - skipped: current.skipped, - complete: current.complete, - ); - }); - while (!current.complete && mounted) { + 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 BlocProvider.of(context) - .getRecipeImportStatus(preview.token); - if (!mounted || status == null) break; + await householdUpdateCubit.getRecipeImportStatus( + preview.token, + ); + if (status == null) { + failedStatusPolls += 1; + if (failedStatusPolls >= maxFailedStatusPolls) { + if (mounted) { + showSnackbar( + context: context, + content: const Text( + 'Could not fetch import status. Import may still be running in the background.', + ), + width: null, + ); + } + break; + } + continue; + } + failedStatusPolls = 0; current = status; - setState(() { - _progress = _RecipeImportProgress( - detected: current.detected > 0 - ? current.detected - : preview.recipes.length, - imported: current.imported, - failed: current.failed, - skipped: current.skipped, - complete: current.complete, - ); - }); + _RecipeImportProgressTracker.update( + current, + preview.recipes.length, + ); } - if (mounted && _progress != null) { + final progress = + _RecipeImportProgressTracker.value.value; + if (mounted && progress != null) { showSnackbar( context: context, content: Text( AppLocalizations.of(context)! .recipeImportResultSummary( - _progress!.imported, - _progress!.failed, - _progress!.skipped, + progress.imported, + progress.failed, + progress.skipped, ), ), width: null, ); } } else { - setState(() { - _progress = null; - }); + _RecipeImportProgressTracker.clear(); } }, child: Text(AppLocalizations.of(context)!.recipeImportTitle), From 136dd38269fbf92a186225597eb29dc7c59ea0b0 Mon Sep 17 00:00:00 2001 From: Jojo-05 Date: Sun, 31 May 2026 13:27:52 +0200 Subject: [PATCH 05/16] fix(recipe-import): remove unnecessary snackbars and add localization --- kitchenowl/lib/l10n/app_de.arb | 1 + kitchenowl/lib/l10n/app_en.arb | 1 + .../sliver_household_danger_zone.dart | 31 ++----------------- 3 files changed, 5 insertions(+), 28 deletions(-) diff --git a/kitchenowl/lib/l10n/app_de.arb b/kitchenowl/lib/l10n/app_de.arb index a474a5489..1e5eb2389 100644 --- a/kitchenowl/lib/l10n/app_de.arb +++ b/kitchenowl/lib/l10n/app_de.arb @@ -519,6 +519,7 @@ "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}", diff --git a/kitchenowl/lib/l10n/app_en.arb b/kitchenowl/lib/l10n/app_en.arb index 5a5db0f09..5d90cd85f 100644 --- a/kitchenowl/lib/l10n/app_en.arb +++ b/kitchenowl/lib/l10n/app_en.arb @@ -803,6 +803,7 @@ "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}", 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 fcb602b63..92958be42 100644 --- a/kitchenowl/lib/widgets/settings_household/sliver_household_danger_zone.dart +++ b/kitchenowl/lib/widgets/settings_household/sliver_household_danger_zone.dart @@ -327,16 +327,6 @@ class _SliverHouseholdDangerZoneState _RecipeImportProgressTracker.start(preview.recipes.length); - if (mounted) { - showSnackbar( - context: context, - content: Text( - AppLocalizations.of(context)!.importStartedHint, - ), - width: null, - ); - } - final result = await householdUpdateCubit.commitRecipeImport( preview.token, decisions, @@ -364,8 +354,9 @@ class _SliverHouseholdDangerZoneState if (mounted) { showSnackbar( context: context, - content: const Text( - 'Could not fetch import status. Import may still be running in the background.', + content: Text( + AppLocalizations.of(context)! + .recipeImportStatusFetchFailed, ), width: null, ); @@ -381,22 +372,6 @@ class _SliverHouseholdDangerZoneState preview.recipes.length, ); } - final progress = - _RecipeImportProgressTracker.value.value; - if (mounted && progress != null) { - showSnackbar( - context: context, - content: Text( - AppLocalizations.of(context)! - .recipeImportResultSummary( - progress.imported, - progress.failed, - progress.skipped, - ), - ), - width: null, - ); - } } else { _RecipeImportProgressTracker.clear(); } From f9e60290ac4815c539561900cb18889d05578476 Mon Sep 17 00:00:00 2001 From: Jojo-05 Date: Sun, 31 May 2026 14:07:19 +0200 Subject: [PATCH 06/16] fix(recipe-import): prevent duplicate tags --- .../service/importServices/import_recipe.py | 15 ++++-- .../tests/api/test_recipe_import_formats.py | 54 ++++++++++++++++++- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/backend/app/service/importServices/import_recipe.py b/backend/app/service/importServices/import_recipe.py index fac7e1a40..8c5bfeed1 100644 --- a/backend/app/service/importServices/import_recipe.py +++ b/backend/app/service/importServices/import_recipe.py @@ -132,11 +132,18 @@ def importRecipe( 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/tests/api/test_recipe_import_formats.py b/backend/tests/api/test_recipe_import_formats.py index b36eb17c4..85f2ae231 100644 --- a/backend/tests/api/test_recipe_import_formats.py +++ b/backend/tests/api/test_recipe_import_formats.py @@ -9,7 +9,7 @@ from app import db from app.config import UPLOAD_FOLDER -from app.models import Household, Recipe +from app.models import Household, Recipe, RecipeTags, Tag from app.service.recipe_import_service import ( commit_recipe_import, preview_recipe_import, @@ -53,6 +53,25 @@ def _create_household(name: str) -> Household: 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 = { @@ -180,3 +199,36 @@ def test_recipe_import_paprika_gzipped_zip_entries(client): 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"] From 675d111e62e36338d31bb3dadee102ea34e2774e Mon Sep 17 00:00:00 2001 From: Jojo-05 Date: Mon, 1 Jun 2026 19:50:07 +0200 Subject: [PATCH 07/16] fix(recipe-import): image handling and gallery --- .../controller/recipe/recipe_controller.py | 12 ++- backend/app/controller/recipe/schemas.py | 2 + .../lib/cubits/recipe_add_update_cubit.dart | 31 ++++-- kitchenowl/lib/models/recipe.dart | 17 +-- .../lib/pages/recipe_add_update_page.dart | 102 +++++++++++------- 5 files changed, 108 insertions(+), 56 deletions(-) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index c200eff00..1dfe3530a 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -102,14 +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: - recipe.photo = file_has_access_or_download(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() @@ -161,14 +163,16 @@ 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: - recipe.photo = file_has_access_or_download(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() 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/kitchenowl/lib/cubits/recipe_add_update_cubit.dart b/kitchenowl/lib/cubits/recipe_add_update_cubit.dart index ed30fc40c..6f78b6230 100644 --- a/kitchenowl/lib/cubits/recipe_add_update_cubit.dart +++ b/kitchenowl/lib/cubits/recipe_add_update_cubit.dart @@ -27,7 +27,7 @@ class AddUpdateRecipeCubit extends ReplayCubit { yields: recipe.yields, source: recipe.source, items: recipe.items, - additionalImages: recipe.additionalImages, + additionalImages: recipe.galleryImages, selectedTags: recipe.tags, tags: recipe.tags, visibility: recipe.visibility, @@ -46,12 +46,6 @@ 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) { @@ -71,7 +65,6 @@ 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, @@ -88,7 +81,6 @@ class AddUpdateRecipeCubit extends ReplayCubit { prepTime: _state.prepTime, yields: _state.yields, source: _state.source, - image: image, additionalImages: additionalImages, items: _state.items, tags: _state.selectedTags, @@ -131,6 +123,27 @@ class AddUpdateRecipeCubit extends ReplayCubit { )); } + 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)); } diff --git a/kitchenowl/lib/models/recipe.dart b/kitchenowl/lib/models/recipe.dart index cc23b5683..cbe0891c3 100644 --- a/kitchenowl/lib/models/recipe.dart +++ b/kitchenowl/lib/models/recipe.dart @@ -57,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))); @@ -87,12 +91,12 @@ 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: List.from(map['photos'] ?? const []) - .where((e) => e.toString().isNotEmpty) - .map((e) => e.toString()) - .toList(), + additionalImages: additionalImages, visibility: RecipeVisibility.values[map['visibility'] ?? 0], householdId: map['household_id'], items: items, @@ -188,8 +192,7 @@ class Recipe extends Model { "yields": yields, "source": source, "visibility": visibility.index, - if (image != null) "photo": image, - if (additionalImages.isNotEmpty) "photos": additionalImages, + "photos": additionalImages, "items": items.map((e) => e.toJson()).toList(), "tags": tags.map((e) => e.toString()).toList(), "server_curated": curated, diff --git a/kitchenowl/lib/pages/recipe_add_update_page.dart b/kitchenowl/lib/pages/recipe_add_update_page.dart index c766f5054..8ff28e6cc 100644 --- a/kitchenowl/lib/pages/recipe_add_update_page.dart +++ b/kitchenowl/lib/pages/recipe_add_update_page.dart @@ -206,19 +206,6 @@ class _AddUpdateRecipePageState extends State { slivers: [ SliverList( delegate: SliverChildListDelegate([ - BlocBuilder( - 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, - ), - ), BlocBuilder( bloc: cubit, @@ -245,34 +232,30 @@ class _AddUpdateRecipePageState extends State { runSpacing: 8, children: [ ...state.additionalImages.map( - (image) => ClipRRect( - borderRadius: - BorderRadius.circular(12), - child: SizedBox( - width: 88, - height: 88, - child: Image( - fit: BoxFit.cover, - image: getImageProvider( - context, - image, - maxWidth: 256, - ), + (image) => _RecipeImageTile( + image: Image( + fit: BoxFit.cover, + image: getImageProvider( + context, + image, + maxWidth: 256, ), ), + onRemove: () => + cubit.removeAdditionalImage( + image, + ), ), ), ...state.additionalImageFiles.map( - (image) => ClipRRect( - borderRadius: - BorderRadius.circular(12), - child: SizedBox( - width: 88, - height: 88, - child: Image.memory( - image.bytes, - fit: BoxFit.cover, - ), + (image) => _RecipeImageTile( + image: Image.memory( + image.bytes, + fit: BoxFit.cover, + ), + onRemove: () => + cubit.removeAdditionalImageFile( + image, ), ), ), @@ -870,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, + ), + ), + ), + ], + ), + ), + ); + } +} From 95aaa9d98d37c5232cf679d471aca8e12dcf264e Mon Sep 17 00:00:00 2001 From: Jojo-05 Date: Thu, 4 Jun 2026 19:09:58 +0200 Subject: [PATCH 08/16] feat(importRecipes): add seperate parsers for Tandoor, Mealie and generic json files --- backend/app/service/importRecipes/json.py | 113 +++++ backend/app/service/importRecipes/mealie.py | 215 ++++++++ backend/app/service/importRecipes/tandoor.py | 204 ++++++++ backend/app/service/importRecipes/utils.py | 139 ++++++ backend/app/service/recipe_import_service.py | 105 ++-- backend/pyproject.toml | 1 + backend/uv.lock | 485 ++++++++++--------- 7 files changed, 997 insertions(+), 265 deletions(-) create mode 100644 backend/app/service/importRecipes/json.py create mode 100644 backend/app/service/importRecipes/mealie.py create mode 100644 backend/app/service/importRecipes/tandoor.py create mode 100644 backend/app/service/importRecipes/utils.py diff --git a/backend/app/service/importRecipes/json.py b/backend/app/service/importRecipes/json.py new file mode 100644 index 000000000..9a9585b7e --- /dev/null +++ b/backend/app/service/importRecipes/json.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from typing import Any +from app.service.importRecipes.utils import ( + _normalize_text, + _normalize_instruction_step, + _normalize_int, + _parse_minutes, + _normalize_items, +) + + +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("instructions") or raw.get("method") + if isinstance(instructions, list): + steps = [] + for entry in instructions: + if isinstance(entry, dict): + text = _normalize_instruction_step( + entry.get("text") or entry.get("instruction") or entry.get("value") + ) + if text: + steps.append(text) + else: + text = _normalize_instruction_step(entry) + if text: + steps.append(text) + if steps: + if description: + description += "\n\n" + description += "\n".join( + [f"{idx + 1}. {step}" for idx, step in enumerate(steps)] + ) + else: + instructions_text = _normalize_text(instructions) + if instructions_text: + if description: + description += "\n\n" + description += instructions_text + + recipe: dict[str, Any] = { + "name": name, + "description": description, + "time": _normalize_int(raw.get("time") or raw.get("total_time")), + "cook_time": _normalize_int(raw.get("cook_time")), + "prep_time": _normalize_int(raw.get("prep_time")), + "yields": _normalize_int( + raw.get("yields") or raw.get("servings") or raw.get("persons_served") + ), + "source": _normalize_text(raw.get("source") or raw.get("url")), + "photo": _normalize_text( + raw.get("photo") or raw.get("image") or raw.get("preview_picture") + ), + "photos": [ + _normalize_text(photo) + for photo in (raw.get("photos") or raw.get("images") or []) + if _normalize_text(photo) + ], + "items": _normalize_items(raw.get("items") or raw.get("ingredients")), + "tags": [ + _normalize_text(tag) + for tag in (raw.get("tags") or []) + if _normalize_text(tag) + ], + } + + if recipe["time"] is None: + recipe["time"] = _parse_minutes(raw.get("time") or raw.get("total_time")) + if recipe["cook_time"] is None: + recipe["cook_time"] = _parse_minutes(raw.get("cook_time")) + if recipe["prep_time"] is None: + recipe["prep_time"] = _parse_minutes(raw.get("prep_time")) + + if not recipe["tags"] and isinstance(raw.get("categories"), list): + recipe["tags"] = [ + _normalize_text(tag) + for tag in raw.get("categories", []) + if _normalize_text(tag) + ] + + if not recipe["photo"]: + recipe.pop("photo", None) + if not recipe["photos"]: + recipe.pop("photos", None) + return recipe + + +def json_extract_recipes(payload: Any) -> list[dict[str, Any]]: + recipes: list[dict[str, Any]] = [] + if isinstance(payload, dict): + candidates = payload.get("recipes") + if isinstance(candidates, list): + for entry in candidates: + if isinstance(entry, dict): + normalized = _normalize_recipe(entry) + if normalized: + recipes.append(normalized) + else: + normalized = _normalize_recipe(payload) + if normalized: + recipes.append(normalized) + elif isinstance(payload, list): + for entry in payload: + if isinstance(entry, dict): + normalized = _normalize_recipe(entry) + if normalized: + recipes.append(normalized) + return recipes diff --git a/backend/app/service/importRecipes/mealie.py b/backend/app/service/importRecipes/mealie.py new file mode 100644 index 000000000..d3ffe875b --- /dev/null +++ b/backend/app/service/importRecipes/mealie.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import os +import zipfile +from typing import Any + +from app.service.importRecipes.utils import ( + _maybe_decode_json_payload, + _normalize_text, + _parse_minutes, +) +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 [] + + def nid(v: Any) -> str | None: + if v is None: + return None + return str(v).lower().replace("-", "").strip() + + recipes_table = table("recipes") + instr_table = table("recipe_instructions") + ingred_table = ( + table("recipes_ingredients") + or table("recipe_ingredients") + or table("recipe_ingredient") + ) + foods_table = {nid(e.get("id")): e for e in table("ingredient_foods")} + units_table = {nid(e.get("id")): e for e in table("ingredient_units")} + nutrition_table = {nid(r.get("recipe_id")): r for r in table("recipe_nutrition")} + tags_table = {nid(t.get("id")): t for t in table("tags")} + recipes_to_tags = table("recipes_to_tags") + categories_table = {nid(c.get("id")): c for c in table("categories")} + recipes_to_categories = table("recipes_to_categories") + + recipes: list[dict[str, Any]] = [] + for r in recipes_table: + rid = r.get("id") + rid_key = nid(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_minutes( + r.get("prep_time") or r.get("prepTime") or r.get("prepTimeStr") + ) + recipe["cook_time"] = _parse_minutes( + r.get("cook_time") or r.get("cookTime") or r.get("cookTimeStr") + ) + recipe["perform_time"] = _parse_minutes(r.get("perform_time")) + if recipe["cook_time"] is None: + recipe["cook_time"] = recipe["perform_time"] + recipe["time"] = _parse_minutes( + r.get("total_time") or r.get("totalTime") or r.get("time") + ) + 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) 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")) + + steps = [e for e in instr_table if nid(e.get("recipe_id")) == 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: + instrs.append(f"{idx}. {text}") + if instrs: + recipe["description"] = ( + (recipe.get("description") + "\n\n") + if recipe.get("description") + else "" + ) + "\n".join(instrs) + + ing = [e for e in ingred_table if nid(e.get("recipe_id")) == 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(nid(it.get("food_id"))) + food_name = _normalize_text( + food.get("name") if isinstance(food, dict) else None + ) + unit = units_table.get(nid(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 + } + + tag_ids = [ + t.get("tag_id") + for t in recipes_to_tags + if nid(t.get("recipe_id")) == rid_key + ] + tags = [ + tags_table.get(nid(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 + + cat_ids = [ + c.get("category_id") + for c in recipes_to_categories + if nid(c.get("recipe_id")) == rid_key + ] + cats = [ + categories_table.get(nid(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 + + # image lookup across Mealie export versions + if rid: + rid_str = str(rid).lower() + for zip_key, entry in zip_entries.items(): + zip_key = zip_key.lower() + if ( + f"/recipes/{rid_str}/" in zip_key + and os.path.basename(zip_key).startswith("original.") + and allowed_file(entry) + ): + recipe["photo"] = entry + break + + 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..aba3e2f05 --- /dev/null +++ b/backend/app/service/importRecipes/tandoor.py @@ -0,0 +1,204 @@ +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_minutes, +) +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 + + working = payload.get("working_time") + waiting = payload.get("waiting_time") + rec["prep_time"] = ( + int(round(working)) + if isinstance(working, (int, float)) + else _parse_minutes(working) + ) + rec["cook_time"] = ( + int(round(waiting)) + if isinstance(waiting, (int, float)) + else _parse_minutes(waiting) + ) + prep = rec.get("prep_time") or 0 + cook = rec.get("cook_time") or 0 + rec["time"] = (prep + cook) if (prep + cook) > 0 else 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[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 = [ + p + for p in [ + str(amount) if amount is not None else None, + str(unit_name) if unit_name else None, + note if note else None, + ] + if p + ] + 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..477ec676a --- /dev/null +++ b/backend/app/service/importRecipes/utils.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import gzip +import json +import os +import re +from typing import Any + +from app.util.filename_validator import allowed_file + + +def _normalize_text(value: Any) -> str: + if value is None: + return "" + return str(value).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) + text = str(value) + digits = "".join([c for c in text if c.isdigit() or c == " "]) + parts = [p for p in digits.split(" ") if p] + if not parts: + return None + try: + return int(parts[0]) + except ValueError: + return None + + +def _normalize_items(value: Any) -> list[dict[str, Any]]: + if not value: + return [] + if isinstance(value, list): + 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") + ) + if not name: + continue + quantity = _normalize_text(entry.get("quantity") or entry.get("amount")) + note = _normalize_text(entry.get("note")) + description_parts = [p for p in [quantity, note] if p] + items.append( + { + "name": name, + "description": " ".join(description_parts).strip(), + "optional": bool(entry.get("optional", False)), + } + ) + else: + name = _normalize_text(entry) + if name: + items.append( + { + "name": name, + "description": "", + "optional": False, + } + ) + return items + return [] + + +def _normalize_step_image(entry: dict[str, Any]) -> str: + image_sources = [ + entry.get("image"), + entry.get("photo"), + entry.get("picture"), + entry.get("preview_picture"), + entry.get("step_image"), + entry.get("stepImage"), + entry.get("img"), + ] + images = entry.get("images") + if isinstance(images, list): + image_sources.extend(images) + for source in image_sources: + if isinstance(source, dict): + source = source.get("url") or source.get("src") or source.get("path") + source_text = _normalize_text(source) + if source_text: + return source_text + return "" + + +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 + + +def _normalize_zip_path(path: str) -> str: + return path.replace("\\", "/").strip("/").lower() + + +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 diff --git a/backend/app/service/recipe_import_service.py b/backend/app/service/recipe_import_service.py index 4af239841..d9a15ae9c 100644 --- a/backend/app/service/recipe_import_service.py +++ b/backend/app/service/recipe_import_service.py @@ -22,6 +22,9 @@ 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 @@ -235,26 +238,31 @@ def _normalize_recipe(raw: dict[str, Any]) -> dict[str, Any] | None: def _extract_recipes(payload: Any) -> list[dict[str, Any]]: - recipes: list[dict[str, Any]] = [] - if isinstance(payload, dict): - candidates = payload.get("recipes") - if isinstance(candidates, list): - for entry in candidates: - if isinstance(entry, dict): - normalized = _normalize_recipe(entry) - if normalized: - recipes.append(normalized) - else: - normalized = _normalize_recipe(payload) - if normalized: - recipes.append(normalized) - elif isinstance(payload, list): - for entry in payload: - if isinstance(entry, dict): - normalized = _normalize_recipe(entry) - if normalized: - recipes.append(normalized) - return recipes + 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: @@ -487,26 +495,45 @@ def preview_recipe_import( } ) recipe_images = _collect_zip_images(entries) - 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 + # 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", "") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5c4e25d99..d2782138f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -42,6 +42,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/uv.lock b/backend/uv.lock index 143ef5a4a..50576c7e0 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -18,7 +18,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.5" +version = "3.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -29,42 +29,49 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, - { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, - { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, - { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, - { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, - { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, - { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, - { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, - { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, - { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, - { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, - { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, - { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, - { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, - { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, - { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, - { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, - { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, - { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, - { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, - { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/03/5f36ab196a88ba5e9648ae5643e6531e67a3a8c0e96f9c6510ff41540fec/aiohttp-3.14.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:363ef9e91014e7891679bfb2ac0a7c6ea93435dbbfd10ecf41b9f06fcf506c5f", size = 503330, upload-time = "2026-06-01T19:39:18.195Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ce/8b49ec2f30f68e02f314f4832186cd45e583360a5a386058be36855d23b6/aiohttp-3.14.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:884a4edbdad77be9d0ef36142c8b504351b170df0bf62b51e784fadabf311c42", size = 509822, upload-time = "2026-06-01T19:39:20.396Z" }, + { url = "https://files.pythonhosted.org/packages/1a/fe/6edbf5d39bf29322b6816365b17ed8ede4dace164a3aea1abcd30110eb78/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:70ea956f6cc4a37620966b56c2e205d88ca3e6d85ec063277e414b1035cddad3", size = 483329, upload-time = "2026-06-01T19:39:22.607Z" }, + { url = "https://files.pythonhosted.org/packages/1b/5a/fae531bdbc6456fb6241f46b7b81e4d8a0dd3fc09118a0055dc7141ac1ec/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:ea3b9806c89f61da22fddf1f12dd524fb368e5e28f1261fbdafe5c3cd8ce893b", size = 489502, upload-time = "2026-06-01T19:39:24.881Z" }, + { url = "https://files.pythonhosted.org/packages/36/f4/48a7b0414db7fed77a03d5dde34508c026afd83510ab6bca08c313855776/aiohttp-3.14.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a071be341c2bd9b0188e62d173509f024e0a35b1c342c53c50f8daaeda8c3bd8", size = 497357, upload-time = "2026-06-01T19:39:27.197Z" }, + { url = "https://files.pythonhosted.org/packages/75/75/e85a13a370acc007fca5feb1fd1b88ac2d8426e6dadd625479b7cadd55a3/aiohttp-3.14.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:198cfe61bf253b19da1fb3e0fa122249dc4f14c12709493fed8054aa0411cc76", size = 750898, upload-time = "2026-06-01T19:39:29.563Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/3d637f800c724eff0e2bed64df72557444482366fd0a35b0cec0e6968f6c/aiohttp-3.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc203d6ce6b9106d54e2a93f41dfdfebfbca2d99962ba503bfd3e5921a6549e", size = 506986, upload-time = "2026-06-01T19:39:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/1d/df/35161f3598bf7501d2b2a805b41ab4f45a2e34150c421bcb4ef8c0d281a7/aiohttp-3.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e19d17ab02bf16832a2c8c0d55a486792c5b1645665652ee9531aebcc30cb72", size = 508033, upload-time = "2026-06-01T19:39:34.137Z" }, + { url = "https://files.pythonhosted.org/packages/e5/39/b36e5d3d31e850fb4691dd3e941684ac490a2559249f6fa634b6b0fdf020/aiohttp-3.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d925fba0c14d5b498a8028b0107beebdfd16c5d48d702ff54f879cb017aaaca3", size = 1746213, upload-time = "2026-06-01T19:39:36.654Z" }, + { url = "https://files.pythonhosted.org/packages/b1/28/24e1409e605a9aa5d84abe0e2acb365354b70ae56d40948101cabe3341ab/aiohttp-3.14.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d33e61021222ce7f9792bcac870d6f58d8adfceda33ab857b01264f4560f2c5f", size = 1705862, upload-time = "2026-06-01T19:39:38.968Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d0/e5eb3ff1daeaf644c7e36a957517672494122628e067c38b263fa04eda77/aiohttp-3.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:44eca38755d0105bb32f47d085f5dd449846a449e1245fc105889e3279dcf8e3", size = 1798909, upload-time = "2026-06-01T19:39:41.334Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ba/8943f906f0570342886ababb9a722a44e360f786a028c5e0b0e29e3f735b/aiohttp-3.14.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f13087e06f68fea4941c21a0c541c00553aa16e4f8fd7bbe2b198df761e964d6", size = 1868892, upload-time = "2026-06-01T19:39:43.807Z" }, + { url = "https://files.pythonhosted.org/packages/3a/05/27df32c844b2156e1675a8d8ec22d963e3c8ba469ed7ceb1863320c7b521/aiohttp-3.14.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff82be7f1ef73634cb77890a770743239bc3d487b848669be1c599889336dc0a", size = 1751659, upload-time = "2026-06-01T19:39:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/7f/62/da182e5910ab912b2e88aa919b61a16046a37a95714a5795b02eb57b2d18/aiohttp-3.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a150c0875ac8fd87f1c398650841308a30d65facf7416b12dbdb9cfdcbe5a48c", size = 1578775, upload-time = "2026-06-01T19:39:48.902Z" }, + { url = "https://files.pythonhosted.org/packages/66/e3/53c67097e8a5ce98625e91e3fa7f43c9c6940de680345d03b3509a72a078/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:edc01ea4e1ec5a1649a28866262bf24195889ff7b27bdd947029a6086741de9b", size = 1710090, upload-time = "2026-06-01T19:39:51.392Z" }, + { url = "https://files.pythonhosted.org/packages/dd/55/0e2732ca598c7a4dfe8a775662376d0ca2977cb1030e48386d4da5d9a456/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:540632bf882ff8fc88f2e1697be0761578e89e0d79fb4a8a6d65dc5da7e729d4", size = 1715016, upload-time = "2026-06-01T19:39:53.807Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/f0b73730798c9ca525afc30b39f1f81bbe24e245d9654c54d3b39d63212d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:860a86bc2c80237f5dff52edcf427e10a8d8352271fd84845429a3e60199e02c", size = 1763810, upload-time = "2026-06-01T19:39:56.31Z" }, + { url = "https://files.pythonhosted.org/packages/71/cc/11acb6c4518f448323405a7312b6f255d0f974a34373ad1db7633c4aadc8/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5cbd50e6a50d6b99283a826b18cbdebf65b0797689a7535cb0e9dd37be0f63c3", size = 1573064, upload-time = "2026-06-01T19:39:58.718Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/28c31dde0a7dc98c0ee7d0da2ddcec3f7688c4fc131e5989e278d0c03c0a/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:20144819e99db593e22bbd2f3f2691a5e149f879142d6b8670254708853ff4fb", size = 1775765, upload-time = "2026-06-01T19:40:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/b8/69/155c4ef3aec96417d47024800472b33b16c5d8a665371dcd044c2afdf25d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:26b6d79aa54cb4ed50cc7d41ed14e99e0f1fc8e7c2d42f2e05b37aea897b2b52", size = 1733716, upload-time = "2026-06-01T19:40:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/5f/44/6126116fd8a316b712bb615660b855c78466bb67ba1bb1742427eafcf7ac/aiohttp-3.14.0-cp314-cp314-win32.whl", hash = "sha256:106ed074a856f3e21d186b8579e2c8afb6da598e267cdaab01059e13db2fc44d", size = 453684, upload-time = "2026-06-01T19:40:06.277Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d7/eff4c58a88c5cac5e38b55f44fb8a6d3929c3cbd77356e383e094d3220bd/aiohttp-3.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f770846edae8f00ecc57af825bce811f787f87a7dcf0e90d191790efe5b31f7", size = 481758, upload-time = "2026-06-01T19:40:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ed/17b5bd9fbcb46e688f02e572f517754a9a75831e7b54702f027761dc4fa5/aiohttp-3.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:acf1581c4f21ed4b80a2dded504d87b055a071a84d5737ea966435f768275ac6", size = 450557, upload-time = "2026-06-01T19:40:11.03Z" }, + { url = "https://files.pythonhosted.org/packages/12/34/6180103ce9aabc8ebff3f7bb55a1228ffe60f61042823031d9692cb7b101/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6aa1a40f9cbb3da9f80714c5966b8946c21e6a2530d809b9498b33161e3c8733", size = 787878, upload-time = "2026-06-01T19:40:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/92/e9/08954a40e8b7baa3d8beadd2b074b186e9b1e9c8ddabc288678a6265de50/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b62af5a8cc96a194eaa01a9ed7b34a3ffa58d3d8daaa1a0d7a749353ad12d228", size = 524400, upload-time = "2026-06-01T19:40:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/08/6a/b5965a634ac4d5ba99a463314cf4ab214ca073fcdc38a15e0294273701fc/aiohttp-3.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6eb63b1417efaf7d1002a6ad034a40d44376afcc16508a57f8e74b49ad26a095", size = 527904, upload-time = "2026-06-01T19:40:18.28Z" }, + { url = "https://files.pythonhosted.org/packages/06/b4/932bcdd850c354d9bcca30f360e475d7852e30413fbbd44b182782ed5432/aiohttp-3.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c20b9ad156a79eb97be5cf9e069eec01d2f0dc8472ffbd75299a8b2d4c2cbbde", size = 1912162, upload-time = "2026-06-01T19:40:20.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/85/ce79bab0310d2e3fd2d7bc7e44412abeff7c8338f8a21dd0f2f1714989e5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:40ae7b0642c25632c7eabc4a04754012691864d2a1b93becf7cddb76027b838a", size = 1778813, upload-time = "2026-06-01T19:40:23.726Z" }, + { url = "https://files.pythonhosted.org/packages/05/54/ba62ac2d1bc87e010aad23751e383b8794e45d931df67677313a2da78823/aiohttp-3.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95f5217e76a046b9f228a101717ef8d42b1eb3d9d196d15202db5bf41df88936", size = 1899969, upload-time = "2026-06-01T19:40:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/dc/82/7cc7907725d83a19f31551334061e1ab8e108b1d7ac52632a2a844a4acb5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a4a9f17e85b80878c176695c1998c790e83731d8271881e5d356488652a1f9e", size = 1991771, upload-time = "2026-06-01T19:40:29.061Z" }, + { url = "https://files.pythonhosted.org/packages/d0/1c/a57de71a4508c93a830b77c28af3d08cd97f606dedfc6b94275347744508/aiohttp-3.14.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:145262119b07d7f95abc1839add35ba2bfc84551d4b4660ca11542c0b215455b", size = 1868606, upload-time = "2026-06-01T19:40:31.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ae/3839726cd49150a53ed340cc24ce5ba09d4c2117020ef9d45542bec5eb2f/aiohttp-3.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:49a33ded29b0b2fa7a367a02cf0fb89af602bb87542a16177ec8ce1c9c51d12a", size = 1665437, upload-time = "2026-06-01T19:40:35.01Z" }, + { url = "https://files.pythonhosted.org/packages/35/1e/c237923232c7da7f0392ea25d89fc5e60c0e93f685f4ebca8e7bcdd5271c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cc736a9c9fc2bc4dd71fd404815741b6573df27c3f985948ec4076989ac57de", size = 1834090, upload-time = "2026-06-01T19:40:37.733Z" }, + { url = "https://files.pythonhosted.org/packages/98/02/a5a7a2524f92d3911761b405a7c067c751891942144adc13e2ad79611e39/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4141a3e5342ee3053a9cab54d25b64ed28289c1041e4c54b3d99839314d90ce", size = 1816907, upload-time = "2026-06-01T19:40:40.46Z" }, + { url = "https://files.pythonhosted.org/packages/fa/76/a8b9f0d09234d516af9f2d7dd715557f33b5da3b0b56ead41d1170e86e3c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e30871b2d58996cb81aac52d2b1d15ac05257131ef0f90f18c2115a380fbfe7c", size = 1840382, upload-time = "2026-06-01T19:40:43.48Z" }, + { url = "https://files.pythonhosted.org/packages/c9/8e/140e715a0a4bbc211979ea30ec8396ad2ed5bf90ab87d8058fc4668b1923/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:667b881d083ccae3900ea5a241e17e5007ca78844c53ed389bb63d48f729d9c7", size = 1659497, upload-time = "2026-06-01T19:40:46.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/c7/7ba5de8af9650b9767b063c675427b8685f43fa7ce563673a7bc3af60f08/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:b584dfe615d151e9b8f0a8ecb3aee6147f2927ec5b95ba25fe621f5377510928", size = 1870829, upload-time = "2026-06-01T19:40:49.583Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bc/2aaab2f85cadb26ea59c091fa2b8e370d625154b5c14b478f1b489d07551/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6199707cc40e0e9cd39c36fbc97bec416c704e1d0ddce03412bb3b3e6a90ccd0", size = 1832281, upload-time = "2026-06-01T19:40:52.303Z" }, + { url = "https://files.pythonhosted.org/packages/39/98/31b9ad9fbc01f0075ee7221002df5fd2d10b647f451ca5f30edc802d9dd6/aiohttp-3.14.0-cp314-cp314t-win32.whl", hash = "sha256:a8d93334d4961c9d566b1f046c81dee475b7c21eb730728d38237bfa70d1c8e6", size = 490597, upload-time = "2026-06-01T19:40:54.937Z" }, + { url = "https://files.pythonhosted.org/packages/59/1f/299b21441c8de42ff70fddc7cfe65e92f810abcf740739a09b56f7835364/aiohttp-3.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d2ffe9b614f50f069068b3b52e73414e4107fc10b7efc939a76acff9251fdd2", size = 525789, upload-time = "2026-06-01T19:40:57.306Z" }, + { url = "https://files.pythonhosted.org/packages/70/11/7f83fcba9ee05d4c54d61b3f8104da0d43a59adac44dd28effc0c9a10422/aiohttp-3.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7a3fc4358e65826c515350f199c210de747cf669998211b1ee6c2e46de364b24", size = 467399, upload-time = "2026-06-01T19:40:59.993Z" }, ] [[package]] @@ -495,41 +502,41 @@ wheels = [ [[package]] name = "coverage" -version = "7.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, - { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, - { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, - { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, - { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, - { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, - { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, - { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, - { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, - { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, - { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, - { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, - { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, - { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, - { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, - { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, - { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, - { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, - { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, - { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, - { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, - { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, - { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, - { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, - { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, ] [[package]] @@ -617,11 +624,11 @@ wheels = [ [[package]] name = "distlib" -version = "0.4.0" +version = "0.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/86/b2/d6fc3f2347f43dada79e5ff118493e8109c98400a0e29a1d5264a3aa479b/distlib-0.4.1.tar.gz", hash = "sha256:c3804d0d2d4b5fcd44036eb860cb6660485fcdf5c2aba53dc324d805837ea65b", size = 610526, upload-time = "2026-06-02T11:17:40.691Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, + { url = "https://files.pythonhosted.org/packages/25/18/3497c4fa83a76dcb154923fd2075522e8dd6995ecee4093c00ae18160046/distlib-0.4.1-py2.py3-none-any.whl", hash = "sha256:9c2c552c68cbadc619f2d0ed3a69e27c351a3f4c9baa9ffb7df9e9cdc3d19a97", size = 469216, upload-time = "2026-06-02T11:17:38.779Z" }, ] [[package]] @@ -673,11 +680,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.29.0" +version = "3.29.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/f9/f38573ed5844586db374d085911740a501ccfa373b455fc9413f09f85237/filelock-3.29.1.tar.gz", hash = "sha256:d97e6b1b9757569626c58caa07dc4beb1613f4a2938b1e8cc81afca398906c9e", size = 59335, upload-time = "2026-06-03T15:19:04.053Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a0/614c5fe402fd88951df45f4dda2fa3b4e17a99ecd92340771929169b3b95/filelock-3.29.1-py3-none-any.whl", hash = "sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b", size = 40750, upload-time = "2026-06-03T15:19:02.959Z" }, ] [[package]] @@ -1064,9 +1071,10 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.16.1" +version = "1.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "click" }, { name = "filelock" }, { name = "fsspec" }, { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, @@ -1077,9 +1085,9 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/0f/ed994dbade67a54407c28cab96ef845e0e6d25500be56aca6394f8bfc9dd/huggingface_hub-1.16.1.tar.gz", hash = "sha256:7f1dc4c5ec21aed69be630ad0c3378616be16f3de1a47b141c0e812965d9c832", size = 792534, upload-time = "2026-05-21T18:40:00.908Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/65/9826515abb600b5722bcf53f8b4a2fb58340b1f8bfcaee19f83561c13a44/huggingface_hub-1.17.0.tar.gz", hash = "sha256:fad842b6763ef70ebc3919665b1b9273645203185400a7d6c5eddc2323cc3435", size = 797082, upload-time = "2026-05-28T15:12:13.347Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/79/621a7dbb80c70974f73a597275351ebe03ce5bc65cb5f8f4acb5859252bc/huggingface_hub-1.16.1-py3-none-any.whl", hash = "sha256:64340de934b9ce37857ef85a82de72f5629e8a270f9119eabb12bf495eb53c22", size = 668176, upload-time = "2026-05-21T18:39:58.596Z" }, + { url = "https://files.pythonhosted.org/packages/02/28/d7cef5e477b855c25d415b8f57e5bc7347c7a90cad3acf1725d0c92ca294/huggingface_hub-1.17.0-py3-none-any.whl", hash = "sha256:3b8156d23118e87f6a587648bfbc04f04a12a757ccb4ed298b35c4ae638bf24c", size = 671546, upload-time = "2026-05-28T15:12:11.441Z" }, ] [[package]] @@ -1093,11 +1101,11 @@ wheels = [ [[package]] name = "idna" -version = "3.16" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -1114,17 +1122,16 @@ wheels = [ [[package]] name = "ingredient-parser-nlp" -version = "2.6.0" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nltk" }, { name = "numpy" }, { name = "pint" }, - { name = "python-crfsuite" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/3e/4b13bcd97b6ab8f8089f3dd1f8b992e1a5b22d13d3504ebca339087463f8/ingredient_parser_nlp-2.6.0.tar.gz", hash = "sha256:4159fa7b59e8fe29cc6c1af339209bc6608efa2e085dab6be9402417a1657881", size = 4310819, upload-time = "2026-03-20T15:30:28.293Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/4c/a1a7a8d724b2e12da6e32ca56f40142cc367b15bb9c9342743e9c701cfec/ingredient_parser_nlp-2.7.0.tar.gz", hash = "sha256:1ea3b8f95aae7e1b82542aa91c482fb2edff713e06bf4045dac78d3f7513e030", size = 3957689, upload-time = "2026-05-25T15:13:52.725Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/19/c727195de7d8fbcc83a1f8092cc4a0c1bdcd4813397c6146dacd6820f4ab/ingredient_parser_nlp-2.6.0-py3-none-any.whl", hash = "sha256:fdcf13534545a0df36f68fddd44cfd407f567504fde610bc6eaa8cd440bad70e", size = 4316441, upload-time = "2026-03-20T15:30:24.652Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/1f9bc3da4c266b43c7b50dff0ac1885700fd1556cb136d2cc972be02d0b6/ingredient_parser_nlp-2.7.0-py3-none-any.whl", hash = "sha256:cdb287a1e43ab7429ea96c98638c094fd99fcd8d124dd2d485debf2b328d7b5b", size = 3964058, upload-time = "2026-05-25T15:13:50.225Z" }, ] [[package]] @@ -1276,6 +1283,7 @@ dependencies = [ { name = "prometheus-client" }, { name = "prometheus-flask-exporter" }, { name = "psycopg2-binary" }, + { name = "pyright" }, { name = "python-socketio" }, { name = "recipe-scrapers" }, { name = "requests" }, @@ -1326,6 +1334,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" }, @@ -1410,7 +1419,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.85.1" +version = "1.87.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -1426,9 +1435,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/55/aebffceaa08688a989e9c68b3edc3a520a1f8338eb0346668774bd66ad88/litellm-1.85.1.tar.gz", hash = "sha256:3b8ef0c89ff2736cbd27109f17ff31f1bd0ab59dee9be8cadb28ec3cb167ce0d", size = 15346324, upload-time = "2026-05-21T02:30:38.185Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/e5/d0ac1c8f55e2c8d8799589e831bef0d450e69e02ecb511901ffc8de054d9/litellm-1.87.1.tar.gz", hash = "sha256:70ac9d6b25f56ad30de6ff95d26fac3b3fc697a95da582b6072d25d8dc73d493", size = 15455709, upload-time = "2026-06-04T16:23:23.339Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/a0/263a13c2253201aa11563a69d9a87f3510030aa765a16f57fc40ceefcdf5/litellm-1.85.1-py3-none-any.whl", hash = "sha256:c89eb5dfd18cce3d40b59e79c74f7f645bc7814a417c6ab25e53c786f0a6ab7b", size = 16980080, upload-time = "2026-05-21T02:30:35.096Z" }, + { url = "https://files.pythonhosted.org/packages/ff/18/8275c95ef09e81ab0c01a162c7b780ce3fbc49066b5d532c6b6ab3dc0118/litellm-1.87.1-py3-none-any.whl", hash = "sha256:dd4e00278cdb846d52e99a09d732575a897273540b54eb044247ecbc0d98f67c", size = 17105482, upload-time = "2026-06-04T16:23:20.769Z" }, ] [[package]] @@ -1668,6 +1677,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] +[[package]] +name = "narwhals" +version = "2.22.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/1c/c80cb7719721a44846c6301ef118434bae30a423924bfad3a47f16bdc064/narwhals-2.22.0.tar.gz", hash = "sha256:6486282bb7e4b4ab55963efbd8be1451b764cc4874b74d1fd625eba9dc60b86f", size = 417565, upload-time = "2026-06-01T13:34:36.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b6/e7cdde7b8e90d5dff25b622f95833ef26567ad184c977278b93a1cbd5717/narwhals-2.22.0-py3-none-any.whl", hash = "sha256:1421797ede01789cc1537619dbc3f36f840737240f748fdb24a60a0225fc80be", size = 453815, upload-time = "2026-06-01T13:34:34.127Z" }, +] + [[package]] name = "nltk" version = "3.9.4" @@ -1741,7 +1759,7 @@ wheels = [ [[package]] name = "openai" -version = "2.38.0" +version = "2.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1753,9 +1771,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/12/cfa322c5f5dd8fa21aab9a7a8e979e7a11123800f86ca8d82eb68a83d213/openai-2.38.0.tar.gz", hash = "sha256:798694c6cf74145541fda94325b6f8f72d8e1fd0262cc137c8d728177a6a4ce3", size = 772764, upload-time = "2026-05-21T21:23:42.105Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/a6/5815fe2e2aca74b36c650d1bd43b69827cee568073d0d2d9b6fc5aaac80c/openai-2.41.0.tar.gz", hash = "sha256:db5c362acd6604b84f076abbefa66826ea4b46ecba2954ed866e6a149a1352c0", size = 783525, upload-time = "2026-06-03T22:39:40.719Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/bf/ccff9be562e24207716d04ef9dc931c76aff0c89a7265da43e2104d7fe06/openai-2.38.0-py3-none-any.whl", hash = "sha256:ec6661c57b2dcc47414a767e6e3335c7ed3d19c9696999283a3c82e95c756a3c", size = 1344910, upload-time = "2026-05-21T21:23:39.636Z" }, + { url = "https://files.pythonhosted.org/packages/be/51/d82bb424e8aa372190c5233253a2ceb399a778747d18b42cff487411e663/openai-2.41.0-py3-none-any.whl", hash = "sha256:20cc7952e8501c7e5773dd2ef7be437bae9cb549044902e1041a83a54516e375", size = 1353378, upload-time = "2026-06-03T22:39:38.964Z" }, ] [[package]] @@ -1846,11 +1864,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.9.6" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] @@ -2130,15 +2148,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.409" +version = "1.1.410" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/53/e4d8ea1391bd4355231be6f91bf239479aa0014260ed3fb5526eeb12a1f2/pyright-1.1.410.tar.gz", hash = "sha256:07a073b8ba6749826773c1269773efa11b93440d9a6aa60419d9a3172d6dc488", size = 4062013, upload-time = "2026-06-01T17:35:48.894Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" }, + { url = "https://files.pythonhosted.org/packages/d7/33/288b5868fa00846dacf249633719d747893e54aebd196b9968ac1878a5d3/pyright-1.1.410-py3-none-any.whl", hash = "sha256:5e961bed37cacf96b3f7cd7b1da39b350a9239aa2e69138d0e88f728cfaf296c", size = 6082448, upload-time = "2026-06-01T17:35:46.387Z" }, ] [[package]] @@ -2171,28 +2189,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] -[[package]] -name = "python-crfsuite" -version = "0.9.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/bb/946c0f96b4d3f7916f0558e19245d2248caebb3f470bcffae8fbf8d862e9/python_crfsuite-0.9.12.tar.gz", hash = "sha256:db37fccc3bd8f0c49c28a7697ca79c89d67b3fd5bf119122866169240ac4c480", size = 488298, upload-time = "2025-12-23T19:07:21.423Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/1f/79f885a496da25f61508ec87156b465b11931812b1400428c0dfb9e25c84/python_crfsuite-0.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2e18bb1d7b4913bc321a5768284c8e86b5eefcd583462bfed5875671223451a8", size = 324173, upload-time = "2025-12-23T19:07:01.417Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4b/cf49ed0c20fb0f95920bfdea2c0401276bc773f882d4f15e715bea5ff2cb/python_crfsuite-0.9.12-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30028c9b6cd06cafb43861f2577d4ef5c57f90a59908efb3df38be9e6e7c1c98", size = 1203650, upload-time = "2025-12-23T19:07:02.729Z" }, - { url = "https://files.pythonhosted.org/packages/3b/72/eea7c742783c9aa15e9505b0361c9e40c4e3ba86ba7976179a590a8b1ab6/python_crfsuite-0.9.12-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2fe0e6760365d7288e63661c4ab3c1110ae0cb1c36fbbbed23e5e889c138eb1", size = 1237142, upload-time = "2025-12-23T19:07:04.424Z" }, - { url = "https://files.pythonhosted.org/packages/b1/3a/fb5822e0b07a2c15ac4b4ad41a64f00dde8235609f21036a2975eb034d65/python_crfsuite-0.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d8d0e416ae999f9ff8a183383d9b917d4818f677dd3e370d19b6d1f9786af4bf", size = 2166273, upload-time = "2025-12-23T19:07:05.782Z" }, - { url = "https://files.pythonhosted.org/packages/43/9e/b5d5a34a559f6afa41062f59962c7916c32a567abfa760ce384421d4c8f1/python_crfsuite-0.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1b7204fdadf596a968d115b94c419899f3299fbd9c753abfced933b831e1ace3", size = 2264115, upload-time = "2025-12-23T19:07:07.147Z" }, - { url = "https://files.pythonhosted.org/packages/99/d8/5c328763a0561677be72b64e992098d3086e2c93dc56d1042124c2326711/python_crfsuite-0.9.12-cp314-cp314-win32.whl", hash = "sha256:be282686a90134851aa636d38ea520ab73aabb8103e79de458fffd49ff016bd2", size = 288459, upload-time = "2025-12-23T19:07:08.232Z" }, - { url = "https://files.pythonhosted.org/packages/34/e4/a856fdd7c9047aee9b265fab3e72314c6ee4c15eb8338ab42bd6c685aeb4/python_crfsuite-0.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:94ab3f1666ec4244d8190b7e624505bc6e845d54b4faa0dacd9ea8fd1ac7eef8", size = 309861, upload-time = "2025-12-23T19:07:09.449Z" }, - { url = "https://files.pythonhosted.org/packages/21/f7/7dd9e0d739e8a8fdc16e2508593172234bf6aaa1c9f48526320c099056b0/python_crfsuite-0.9.12-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1fac24a04ebe58fcfd8e6a3c48e1e03021427852b7495573d7fc41d0d9ee297d", size = 330693, upload-time = "2025-12-23T19:07:11.365Z" }, - { url = "https://files.pythonhosted.org/packages/8b/de/a50c52b0220fb8f6cdb9ba2a873003760ba2e11d4c0c8b5a8dd504d95ae4/python_crfsuite-0.9.12-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:532cfbeffe8c8b0a0bf360a31f6486e655b29703a02ecefaf86d519f12fd470b", size = 1232670, upload-time = "2025-12-23T19:07:12.653Z" }, - { url = "https://files.pythonhosted.org/packages/58/d4/771f3622c220660daed3796ef641ec5398fc8dbb22a9990ee0a757525647/python_crfsuite-0.9.12-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fa2a20a74a094bb80b76af7937f68b710d60539a2905d942ba655d90d5c90677", size = 1230571, upload-time = "2025-12-23T19:07:14.209Z" }, - { url = "https://files.pythonhosted.org/packages/6f/99/32126ca6c2ce1dba2fceeee7a5f1e0f51303b71e366a6faf5feb390d62a4/python_crfsuite-0.9.12-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff3b8e8c524b952e0dd85aa3bc34f24b502507411e776739d9e4ad4b46e61f51", size = 2183590, upload-time = "2025-12-23T19:07:15.986Z" }, - { url = "https://files.pythonhosted.org/packages/00/31/ecb009bb74943fbc84788fe728d2e88797969e78f21864734039654c8bcd/python_crfsuite-0.9.12-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15bd6bbc4bf893e84084e9be010f350e8dfcc716b40bd6e5243d34cbc7dfb61", size = 2257120, upload-time = "2025-12-23T19:07:17.914Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ff/3381915d6713aa04af06b8fd542fe685b27f2a0d7f4abc3eb57356a7b65f/python_crfsuite-0.9.12-cp314-cp314t-win32.whl", hash = "sha256:e6f8d0b71329b015a6d167305c2c00097e39a947644f0cbdc7fb4f9ffc1dc28e", size = 301710, upload-time = "2025-12-23T19:07:19.024Z" }, - { url = "https://files.pythonhosted.org/packages/e7/75/45ebbe7884fad92d80566cc5842ff158d2fb42c0c702d730bb06407935ba/python_crfsuite-0.9.12-cp314-cp314t-win_amd64.whl", hash = "sha256:9a74ea7c043e0b12a68175502b948bb58153dafd3e90f69d63de3c4a37ce4f4b", size = 326819, upload-time = "2025-12-23T19:07:20.104Z" }, -] - [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -2207,15 +2203,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.3.1" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/12/38c1a0b1e64806780c9563e3fc9f6e472251839662587cfbe9bfaf2ae10a/python_discovery-1.4.0.tar.gz", hash = "sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3", size = 68455, upload-time = "2026-05-28T01:15:37.639Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" }, ] [[package]] @@ -2387,90 +2383,120 @@ wheels = [ [[package]] name = "rpds-py" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, ] [[package]] name = "ruff" -version = "0.15.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, - { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, - { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, - { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, - { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, - { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, - { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, - { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, - { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, +version = "0.15.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, + { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, ] [[package]] name = "scikit-learn" -version = "1.8.0" +version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "joblib" }, + { name = "narwhals" }, { name = "numpy" }, { name = "scipy" }, { name = "threadpoolctl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, - { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, - { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, - { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, - { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, - { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, - { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, - { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, - { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, - { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, + { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, + { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, + { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, ] [[package]] @@ -2545,37 +2571,38 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.3" +version = "2.8.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] [[package]] name = "sqlalchemy" -version = "2.0.49" +version = "2.0.50" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, - { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, - { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, - { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, - { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, - { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, - { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, - { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, - { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, - { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, - { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" }, + { url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" }, + { url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" }, + { url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" }, ] [[package]] @@ -2750,7 +2777,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.3.3" +version = "21.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -2758,9 +2785,9 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/0d/4e93c8e6d1001a75763f87d8f5ecda8ebc7f4aa2153dddfaf4ae8892821a/virtualenv-21.4.2.tar.gz", hash = "sha256:38e6ee0a555615c0ea9da2ac7e9998fe8dc3b911dd33ad8eaad2020957653b0c", size = 7613326, upload-time = "2026-05-31T17:01:22.827Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl", hash = "sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae", size = 7594079, upload-time = "2026-05-31T17:01:20.735Z" }, ] [[package]] @@ -2882,15 +2909,21 @@ wheels = [ [[package]] name = "zope-interface" -version = "8.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/65/34a6e6e4dfa260c4c55ee02bb2fc53625e126ff0181485286cf0c9d453d6/zope_interface-8.4.tar.gz", hash = "sha256:9dbee7925a23aa6349738892c911019d4095a96cff487b743482073ecbc174a8", size = 257736, upload-time = "2026-04-25T07:22:10.439Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/d5/ca60c8b404b303d9490e1417430a5198a77557dbeb17c1cb31616e432318/zope_interface-8.4-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:7cbb887fdbfaacb4c362dbb487033551646e28013ad5ffe72e96eb260003a1a1", size = 212012, upload-time = "2026-04-25T07:28:36.88Z" }, - { url = "https://files.pythonhosted.org/packages/83/64/6bb9f54250c817e24b39e986f173b6cd21ff658bec6c6cc0baad05d761e4/zope_interface-8.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a5638c6be715116d3453e6d099c299c6844d54810de7445ce116424e905ede06", size = 212071, upload-time = "2026-04-25T07:28:38.742Z" }, - { url = "https://files.pythonhosted.org/packages/c6/cf/42851262e102723058019dc7d0b48210b85a935f79ae32ce60ddccc2e8fb/zope_interface-8.4-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b8147b40bfcd53803870a9519e0879ff066aeecc2fcff8295663c1b17fc38dc2", size = 266075, upload-time = "2026-04-25T07:28:41.084Z" }, - { url = "https://files.pythonhosted.org/packages/d2/a7/e48c79b836f6f0a2c219288e2ec343517f90e95c93de5435a8a23918bf20/zope_interface-8.4-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:049ba3c7b38cc400ae08e011617635706e0f442e1d075db1b015246fcbf6091e", size = 269127, upload-time = "2026-04-25T07:28:42.868Z" }, - { url = "https://files.pythonhosted.org/packages/6a/40/0e26f24d3a2f34f0de2cfeaab6458a865284d9d1fa317ab78913aa1f7322/zope_interface-8.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9c4ac009c2c8e43283842f80387c4d4b41bcbc293391c3b9ab71532ae1ccc301", size = 269446, upload-time = "2026-04-25T07:28:44.97Z" }, - { url = "https://files.pythonhosted.org/packages/91/d5/20310601450367fc35fa28b0544c98d0347b8cc25eaf106a2c4cc36841e1/zope_interface-8.4-cp314-cp314-win_amd64.whl", hash = "sha256:4713bf651ec36e7eea49d2ace4f0e89bec2b33a339674874b1121f2537edc62a", size = 215199, upload-time = "2026-04-25T07:28:47.146Z" }, - { url = "https://files.pythonhosted.org/packages/5b/00/0d22ce75126e31f81baa5889e2a40aad37c8e34d1220cf8b18d744f2b5d9/zope_interface-8.4-cp314-cp314-win_arm64.whl", hash = "sha256:d934497c4b72d5f528d2b5ebe9b8b5a7004b5877948ebd4ea00c2432fb27178f", size = 213178, upload-time = "2026-04-25T07:28:48.868Z" }, +version = "8.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/dc/50550cfcbb2ea3cbca5f1d7ed05c8aa840f831a0f2d63aec0a953f7c590e/zope_interface-8.5.tar.gz", hash = "sha256:7a3ba1c5877f0f3e3906b02ddf793abed2becc2948116414ce0e1dd820b68d6d", size = 257957, upload-time = "2026-05-26T06:50:14.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/19/5032e954827fdf02db2d2f49737ac4378bb9cfc2cd95a8f2e2a5ae2ec01a/zope_interface-8.5-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:ffaecf013251a89d0de6feb49a46eba48ad8cbbf8a40aeb6045e459e7bec6784", size = 212597, upload-time = "2026-05-26T06:49:51.63Z" }, + { url = "https://files.pythonhosted.org/packages/f1/53/3ef644012cf8a6a234a2d6134aab5a5c65ac5467c86296865501d4fbc406/zope_interface-8.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:126fa9d1c52295ae076d4cf968634f0a1826afa408a20808b57ff72877b8f69f", size = 212626, upload-time = "2026-05-26T06:49:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/32/67/bc8b4f465d388039255003e230c284a175cedf1203c692f23cb7bff64efe/zope_interface-8.5-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:3090e3a663d20194756a59a272e0c8508b889341e31d5894223331fe6b4f9b21", size = 266827, upload-time = "2026-05-26T06:49:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/a7/eb/37d05b935ede53d79690fecc8d201440084418e590bcfc05f384451c7593/zope_interface-8.5-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9342fb74e2afefdb081bf1df727d209ea56995c6e13f5a0540e6d7aff4beafb8", size = 270139, upload-time = "2026-05-26T06:49:57.116Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/fd0c54579e2ce8dc6cf1a757903f3374bc6fbda929a46af9e0f53cb0e5f0/zope_interface-8.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c54725d818f1b57a7efb8b16528326e1f3c257b602b32393fd255c45af8799d", size = 270338, upload-time = "2026-05-26T06:49:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1d/c420dcd777bb761067ea92879ac766694a5ca78608185f1aecea64cbfc11/zope_interface-8.5-cp314-cp314-win_amd64.whl", hash = "sha256:29d74febbae1afeb6834c4ccbf42e242a673c860060f09e53142825270456140", size = 215789, upload-time = "2026-05-26T06:50:00.405Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/50b5eb8f94e527edceac14f9955e58917424ea79bb572ddc18548561cbc2/zope_interface-8.5-cp314-cp314-win_arm64.whl", hash = "sha256:633c8c49396f38df030340797c533e9fe460d1b5d1e42d88e55e938e525f548c", size = 213757, upload-time = "2026-05-26T06:50:01.973Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/5d5f32c4dfcdb16ce2ec5363da686840f13c13e1a1214cb70b49e1cd6d9f/zope_interface-8.5-cp314-cp314t-macosx_10_9_x86_64.whl", hash = "sha256:133999820fdbae513c36c03d6f29ef87317aaa3edef39112222b155083664714", size = 213591, upload-time = "2026-05-26T06:50:03.529Z" }, + { url = "https://files.pythonhosted.org/packages/f3/55/de0c3459ff717fce3342f9a29464c281fdeb0d36c3171ee88d119d5f0650/zope_interface-8.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8bd75c96966e573232f0599deaff717564828031c7f05563ccc1ac35c5ee0304", size = 213733, upload-time = "2026-05-26T06:50:05.101Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/d97430abd5ae9677e8b9295b58720c0064a5b557dbb6b8bf5928484cf0d8/zope_interface-8.5-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:14b0e9799351d4c34fe99afd67f0cdd76e55ba15c66a98699d5fc22ea8241e08", size = 294905, upload-time = "2026-05-26T06:50:07.384Z" }, + { url = "https://files.pythonhosted.org/packages/41/ec/a0f8f3dad6e74992f4654bdd94802be0929eabca7b871cac3b6fbb5e961b/zope_interface-8.5-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cd6a732ac84b94eb1ef9222a117347a27efd294ee16810ffdf7ecd307677ed5", size = 300885, upload-time = "2026-05-26T06:50:08.997Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/6881b48803a0ee8d23eb5efa30fce3ed218a2bd9de5758ce489d224fee81/zope_interface-8.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:798b7c87d0e59a7d5d086d642208d0d8700ff0d55c4029134b3c479c3bfb110f", size = 304672, upload-time = "2026-05-26T06:50:10.563Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0e/b4c01320859ff1d585438bc231fd60bd258d096359bccf6654fecdf0cffb/zope_interface-8.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0fc3a9d45f114d27eaa1e53beeb144533689edca8a9f66505b1e8e8b3f075e42", size = 217241, upload-time = "2026-05-26T06:50:12.171Z" }, ] From 97eec24220c96925f8e7090fa69796eecc1ba8d5 Mon Sep 17 00:00:00 2001 From: Jojo-05 Date: Thu, 4 Jun 2026 19:38:53 +0200 Subject: [PATCH 09/16] feat(recipe-import): enhance Mealie parser --- backend/app/service/importRecipes/mealie.py | 86 ++++++++++++++------- 1 file changed, 59 insertions(+), 27 deletions(-) diff --git a/backend/app/service/importRecipes/mealie.py b/backend/app/service/importRecipes/mealie.py index d3ffe875b..f17c45e10 100644 --- a/backend/app/service/importRecipes/mealie.py +++ b/backend/app/service/importRecipes/mealie.py @@ -1,7 +1,9 @@ from __future__ import annotations import os +import re import zipfile +from collections import defaultdict from typing import Any from app.service.importRecipes.utils import ( @@ -22,6 +24,7 @@ def parse_mealie_zip( data = zf.read(database_file) except Exception: return [] + payload = _maybe_decode_json_payload(data) or {} if not isinstance(payload, dict): return [] @@ -35,6 +38,7 @@ def nid(v: Any) -> str | None: return None return str(v).lower().replace("-", "").strip() + # Base tables recipes_table = table("recipes") instr_table = table("recipe_instructions") ingred_table = ( @@ -42,13 +46,51 @@ def nid(v: Any) -> str | None: or table("recipe_ingredients") or table("recipe_ingredient") ) + + # Lookup dictionaries foods_table = {nid(e.get("id")): e for e in table("ingredient_foods")} units_table = {nid(e.get("id")): e for e in table("ingredient_units")} nutrition_table = {nid(r.get("recipe_id")): r for r in table("recipe_nutrition")} tags_table = {nid(t.get("id")): t for t in table("tags")} - recipes_to_tags = table("recipes_to_tags") categories_table = {nid(c.get("id")): c for c in table("categories")} - recipes_to_categories = table("recipes_to_categories") + + instr_by_recipe = defaultdict(list) + for step in instr_table: + r_id = nid(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 = nid(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 = nid(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 = nid(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[nid(rid_part)] = entry + except IndexError: + pass recipes: list[dict[str, Any]] = [] for r in recipes_table: @@ -98,7 +140,8 @@ def nid(v: Any) -> str | None: else: recipe["yields"] = _normalize_text(r.get("recipe_yield")) - steps = [e for e in instr_table if nid(e.get("recipe_id")) == rid_key] + # 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): @@ -109,7 +152,9 @@ def nid(v: Any) -> str | None: if title: instrs.append(title) if text: - instrs.append(f"{idx}. {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") @@ -117,7 +162,8 @@ def nid(v: Any) -> str | None: else "" ) + "\n".join(instrs) - ing = [e for e in ingred_table if nid(e.get("recipe_id")) == rid_key] + # 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: @@ -171,11 +217,8 @@ def nid(v: Any) -> str | None: if k in nut } - tag_ids = [ - t.get("tag_id") - for t in recipes_to_tags - if nid(t.get("recipe_id")) == rid_key - ] + # Grab pre-grouped tags + tag_ids = tags_by_recipe.get(rid_key, []) tags = [ tags_table.get(nid(tid) if tid is not None else None, {}).get("name") for tid in tag_ids @@ -184,11 +227,8 @@ def nid(v: Any) -> str | None: if tags: recipe["tags"] = tags - cat_ids = [ - c.get("category_id") - for c in recipes_to_categories - if nid(c.get("recipe_id")) == rid_key - ] + # Grab pre-grouped categories + cat_ids = cats_by_recipe.get(rid_key, []) cats = [ categories_table.get(nid(cid) if cid is not None else None, {}).get("name") for cid in cat_ids @@ -197,18 +237,10 @@ def nid(v: Any) -> str | None: if cats: recipe["categories"] = cats - # image lookup across Mealie export versions - if rid: - rid_str = str(rid).lower() - for zip_key, entry in zip_entries.items(): - zip_key = zip_key.lower() - if ( - f"/recipes/{rid_str}/" in zip_key - and os.path.basename(zip_key).startswith("original.") - and allowed_file(entry) - ): - recipe["photo"] = entry - break + # Grab pre-mapped image + mapped_photo = images_by_recipe.get(rid_key) + if mapped_photo: + recipe["photo"] = mapped_photo recipes.append(recipe) From c1741c3500505b5a34cfc07604c1a0d5c95715df Mon Sep 17 00:00:00 2001 From: Jojo-05 Date: Thu, 4 Jun 2026 19:50:41 +0200 Subject: [PATCH 10/16] feat(recipe-import): enhance Tandoor parser --- backend/app/service/importRecipes/tandoor.py | 33 ++++++-------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/backend/app/service/importRecipes/tandoor.py b/backend/app/service/importRecipes/tandoor.py index aba3e2f05..bfeeda1ae 100644 --- a/backend/app/service/importRecipes/tandoor.py +++ b/backend/app/service/importRecipes/tandoor.py @@ -34,21 +34,15 @@ def _parse_tandoor_recipe( else: rec["yields"] = _normalize_text(payload.get("servings_text")) or None - working = payload.get("working_time") - waiting = payload.get("waiting_time") - rec["prep_time"] = ( - int(round(working)) - if isinstance(working, (int, float)) - else _parse_minutes(working) - ) - rec["cook_time"] = ( - int(round(waiting)) - if isinstance(waiting, (int, float)) - else _parse_minutes(waiting) - ) + def parse_time(val: Any) -> int | None: + return int(round(val)) if isinstance(val, (int, float)) else _parse_minutes(val) + + rec["prep_time"] = parse_time(payload.get("working_time")) + rec["cook_time"] = parse_time(payload.get("waiting_time")) + prep = rec.get("prep_time") or 0 cook = rec.get("cook_time") or 0 - rec["time"] = (prep + cook) if (prep + cook) > 0 else None + rec["time"] = (prep + cook) or None kws = payload.get("keywords") or [] tags = [] @@ -63,7 +57,7 @@ def _parse_tandoor_recipe( steps = [s for s in steps if isinstance(s, dict)] steps.sort(key=lambda s: s.get("order") or 0) - instr_lines: list[str] = [] + instr_lines: list[tuple[str, str]] = [] items: list[dict[str, Any]] = [] for s in steps: @@ -90,15 +84,8 @@ def _parse_tandoor_recipe( 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 = [ - p - for p in [ - str(amount) if amount is not None else None, - str(unit_name) if unit_name else None, - note if note else None, - ] - if p - ] + parts = [str(p) for p in (amount, unit_name, note) if p not in (None, "")] + items.append( { "name": food_name, From 8f3ac0e1f9a04ff9572d26f7c00b1031a3619e7a Mon Sep 17 00:00:00 2001 From: Jojo-05 Date: Thu, 4 Jun 2026 20:14:19 +0200 Subject: [PATCH 11/16] feat(recipe-import): enhance generic json parser and add Schema.org support --- backend/app/service/importRecipes/json.py | 143 ++++++++++++---------- 1 file changed, 78 insertions(+), 65 deletions(-) diff --git a/backend/app/service/importRecipes/json.py b/backend/app/service/importRecipes/json.py index 9a9585b7e..a61dc8d0b 100644 --- a/backend/app/service/importRecipes/json.py +++ b/backend/app/service/importRecipes/json.py @@ -16,98 +16,111 @@ def _normalize_recipe(raw: dict[str, Any]) -> dict[str, Any] | None: return None description = _normalize_text(raw.get("description") or "") - instructions = raw.get("instructions") or raw.get("method") + instructions = ( + raw.get("recipeInstructions") or raw.get("instructions") or raw.get("method") + ) if isinstance(instructions, list): steps = [] for entry in instructions: - if isinstance(entry, dict): - text = _normalize_instruction_step( - entry.get("text") or entry.get("instruction") or entry.get("value") - ) - if text: - steps.append(text) - else: - text = _normalize_instruction_step(entry) - if text: - steps.append(text) + 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: - if description: - description += "\n\n" - description += "\n".join( - [f"{idx + 1}. {step}" for idx, step in enumerate(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: - if description: - description += "\n\n" - description += instructions_text + description = ( + description + "\n\n" if description else "" + ) + instructions_text + + def parse_time(*keys: str) -> int | None: + for k in keys: + val = raw.get(k) + if val: + res = _normalize_int(val) or _parse_minutes(val) + if res is not None: + return res + return None recipe: dict[str, Any] = { "name": name, "description": description, - "time": _normalize_int(raw.get("time") or raw.get("total_time")), - "cook_time": _normalize_int(raw.get("cook_time")), - "prep_time": _normalize_int(raw.get("prep_time")), + "time": parse_time("time", "total_time", "totalTime"), + "cook_time": parse_time("cook_time", "cookTime"), + "prep_time": parse_time("prep_time", "prepTime"), "yields": _normalize_int( - raw.get("yields") or raw.get("servings") or raw.get("persons_served") + 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")), - "photo": _normalize_text( - raw.get("photo") or raw.get("image") or raw.get("preview_picture") + "cuisine": _normalize_text(raw.get("cuisine") or raw.get("recipeCuisine")), + "items": _normalize_items( + raw.get("items") or raw.get("ingredients") or raw.get("recipeIngredient") ), - "photos": [ - _normalize_text(photo) - for photo in (raw.get("photos") or raw.get("images") or []) - if _normalize_text(photo) - ], - "items": _normalize_items(raw.get("items") or raw.get("ingredients")), - "tags": [ - _normalize_text(tag) - for tag in (raw.get("tags") or []) - if _normalize_text(tag) - ], } - if recipe["time"] is None: - recipe["time"] = _parse_minutes(raw.get("time") or raw.get("total_time")) - if recipe["cook_time"] is None: - recipe["cook_time"] = _parse_minutes(raw.get("cook_time")) - if recipe["prep_time"] is None: - recipe["prep_time"] = _parse_minutes(raw.get("prep_time")) - - if not recipe["tags"] and isinstance(raw.get("categories"), list): - recipe["tags"] = [ - _normalize_text(tag) - for tag in raw.get("categories", []) - if _normalize_text(tag) - ] - - if not recipe["photo"]: - recipe.pop("photo", None) - if not recipe["photos"]: - recipe.pop("photos", None) + 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") + candidates = ( + payload.get("recipes") or payload.get("data") or payload.get("@graph") + ) if isinstance(candidates, list): for entry in candidates: - if isinstance(entry, dict): - normalized = _normalize_recipe(entry) - if normalized: - recipes.append(normalized) + add_if_valid(entry) else: - normalized = _normalize_recipe(payload) - if normalized: - recipes.append(normalized) + add_if_valid(payload) + elif isinstance(payload, list): for entry in payload: - if isinstance(entry, dict): - normalized = _normalize_recipe(entry) - if normalized: - recipes.append(normalized) + add_if_valid(entry) + return recipes From b348efbb28b98559639cc0c1bf7c1ed7e34ec593 Mon Sep 17 00:00:00 2001 From: Jojo-05 Date: Thu, 4 Jun 2026 21:51:26 +0200 Subject: [PATCH 12/16] feat(recipe-import): refactor normalization functions --- backend/app/service/importRecipes/json.py | 55 ++++---- backend/app/service/importRecipes/mealie.py | 90 ++++++------ backend/app/service/importRecipes/tandoor.py | 37 +++-- backend/app/service/importRecipes/utils.py | 138 ++++++++----------- 4 files changed, 145 insertions(+), 175 deletions(-) diff --git a/backend/app/service/importRecipes/json.py b/backend/app/service/importRecipes/json.py index a61dc8d0b..744704b15 100644 --- a/backend/app/service/importRecipes/json.py +++ b/backend/app/service/importRecipes/json.py @@ -2,20 +2,20 @@ from typing import Any from app.service.importRecipes.utils import ( - _normalize_text, - _normalize_instruction_step, - _normalize_int, - _parse_minutes, - _normalize_items, + 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")) + 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 "") + description = normalize_text(raw.get("description") or "") instructions = ( raw.get("recipeInstructions") or raw.get("instructions") or raw.get("method") ) @@ -27,7 +27,7 @@ def _normalize_recipe(raw: dict[str, Any]) -> dict[str, Any] | None: if isinstance(entry, dict) else entry ) - text = _normalize_instruction_step(raw_text) + text = normalize_instruction_step(raw_text) if text: steps.append(text) @@ -36,49 +36,46 @@ def _normalize_recipe(raw: dict[str, Any]) -> dict[str, Any] | None: f"{idx + 1}. {step}" for idx, step in enumerate(steps) ) else: - instructions_text = _normalize_text(instructions) + instructions_text = normalize_text(instructions) if instructions_text: description = ( description + "\n\n" if description else "" ) + instructions_text - def parse_time(*keys: str) -> int | None: - for k in keys: - val = raw.get(k) - if val: - res = _normalize_int(val) or _parse_minutes(val) - if res is not None: - return res - return None - recipe: dict[str, Any] = { "name": name, "description": description, - "time": parse_time("time", "total_time", "totalTime"), - "cook_time": parse_time("cook_time", "cookTime"), - "prep_time": parse_time("prep_time", "prepTime"), - "yields": _normalize_int( + "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( + "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") ), } - photo = _normalize_text( + 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) + normalize_text(photo) for photo in (raw.get("photos") or raw.get("images") or []) - if _normalize_text(photo) + if normalize_text(photo) ] if photos: recipe["photos"] = photos @@ -89,7 +86,7 @@ def parse_time(*keys: str) -> int | None: 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)] + tags = [normalize_text(tag) for tag in raw_tags if normalize_text(tag)] if tags: recipe["tags"] = tags diff --git a/backend/app/service/importRecipes/mealie.py b/backend/app/service/importRecipes/mealie.py index f17c45e10..4bd1fcab5 100644 --- a/backend/app/service/importRecipes/mealie.py +++ b/backend/app/service/importRecipes/mealie.py @@ -7,9 +7,10 @@ from typing import Any from app.service.importRecipes.utils import ( - _maybe_decode_json_payload, - _normalize_text, - _parse_minutes, + maybe_decode_json_payload, + normalize_text, + normalize_id, + parse_time, ) from app.util.filename_validator import allowed_file @@ -25,7 +26,7 @@ def parse_mealie_zip( except Exception: return [] - payload = _maybe_decode_json_payload(data) or {} + payload = maybe_decode_json_payload(data) or {} if not isinstance(payload, dict): return [] @@ -33,11 +34,6 @@ def table(name: str) -> list[dict[str, Any]]: v = payload.get(name) return v if isinstance(v, list) else [] - def nid(v: Any) -> str | None: - if v is None: - return None - return str(v).lower().replace("-", "").strip() - # Base tables recipes_table = table("recipes") instr_table = table("recipe_instructions") @@ -48,33 +44,35 @@ def nid(v: Any) -> str | None: ) # Lookup dictionaries - foods_table = {nid(e.get("id")): e for e in table("ingredient_foods")} - units_table = {nid(e.get("id")): e for e in table("ingredient_units")} - nutrition_table = {nid(r.get("recipe_id")): r for r in table("recipe_nutrition")} - tags_table = {nid(t.get("id")): t for t in table("tags")} - categories_table = {nid(c.get("id")): c for c in table("categories")} + 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 = nid(step.get("recipe_id")) + 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 = nid(ing.get("recipe_id")) + 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 = nid(t.get("recipe_id")) + 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 = nid(c.get("recipe_id")) + r_id = normalize_id(c.get("recipe_id")) if r_id: cats_by_recipe[r_id].append(c.get("category_id")) @@ -88,18 +86,18 @@ def nid(v: Any) -> str | None: ): try: rid_part = zip_key_lower.split("/recipes/")[1].split("/")[0] - images_by_recipe[nid(rid_part)] = entry + 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 = nid(rid) - name_val = _normalize_text(r.get("name") or r.get("title")) + 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")) + description = normalize_text(r.get("description")) if "demo.mealie.io" in description.lower(): continue @@ -107,29 +105,27 @@ def nid(v: Any) -> str | None: recipe["id"] = rid recipe["name"] = name_val recipe["description"] = description - recipe["source"] = _normalize_text( + 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["slug"] = normalize_text(r.get("slug")) recipe["rating"] = r.get("rating") - recipe["cuisine"] = _normalize_text(r.get("recipeCuisine") or r.get("cuisine")) + recipe["cuisine"] = normalize_text(r.get("recipeCuisine") or r.get("cuisine")) - recipe["prep_time"] = _parse_minutes( - r.get("prep_time") or r.get("prepTime") or r.get("prepTimeStr") - ) - recipe["cook_time"] = _parse_minutes( - r.get("cook_time") or r.get("cookTime") or r.get("cookTimeStr") + 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" ) - recipe["perform_time"] = _parse_minutes(r.get("perform_time")) if recipe["cook_time"] is None: recipe["cook_time"] = recipe["perform_time"] - recipe["time"] = _parse_minutes( - r.get("total_time") or r.get("totalTime") or r.get("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) else None + recipe["time"] = (prep + cook) if (prep + cook) > 0 else None servings = r.get("recipe_servings") if servings is not None: @@ -138,15 +134,15 @@ def nid(v: Any) -> str | None: except Exception: recipe["yields"] = None else: - recipe["yields"] = _normalize_text(r.get("recipe_yield")) + 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( + title = normalize_text(s.get("title")) + text = normalize_text( s.get("text") or s.get("value") or s.get("instruction") ) if title: @@ -168,13 +164,13 @@ def nid(v: Any) -> str | None: 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(nid(it.get("food_id"))) - food_name = _normalize_text( + 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(nid(it.get("unit_id"))) + unit = units_table.get(normalize_id(it.get("unit_id"))) unit_name = None if isinstance(unit, dict): use_abbr = unit.get("use_abbreviation") @@ -190,7 +186,7 @@ def nid(v: Any) -> str | None: name = ( food_name or orig - or _normalize_text(it.get("name") or it.get("ingredient")) + or normalize_text(it.get("name") or it.get("ingredient")) ) if not name and note: name = note @@ -220,7 +216,9 @@ def nid(v: Any) -> str | None: # Grab pre-grouped tags tag_ids = tags_by_recipe.get(rid_key, []) tags = [ - tags_table.get(nid(tid) if tid is not None else None, {}).get("name") + 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] @@ -230,7 +228,9 @@ def nid(v: Any) -> str | None: # Grab pre-grouped categories cat_ids = cats_by_recipe.get(rid_key, []) cats = [ - categories_table.get(nid(cid) if cid is not None else None, {}).get("name") + 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] diff --git a/backend/app/service/importRecipes/tandoor.py b/backend/app/service/importRecipes/tandoor.py index bfeeda1ae..c3157751e 100644 --- a/backend/app/service/importRecipes/tandoor.py +++ b/backend/app/service/importRecipes/tandoor.py @@ -8,10 +8,10 @@ from typing import Any from app.service.importRecipes.utils import ( - _maybe_decode_json_payload, - _normalize_text, - _normalize_instruction_step, - _parse_minutes, + maybe_decode_json_payload, + normalize_text, + normalize_instruction_step, + parse_time, ) from app import app @@ -19,26 +19,23 @@ 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")) + 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")) + 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["yields"] = normalize_text(payload.get("servings_text")) or None - def parse_time(val: Any) -> int | None: - return int(round(val)) if isinstance(val, (int, float)) else _parse_minutes(val) - - rec["prep_time"] = parse_time(payload.get("working_time")) - rec["cook_time"] = parse_time(payload.get("waiting_time")) + 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 @@ -47,7 +44,7 @@ def parse_time(val: Any) -> int | None: kws = payload.get("keywords") or [] tags = [] for k in kws: - tag = _normalize_text(k.get("name") if isinstance(k, dict) else k) + tag = normalize_text(k.get("name") if isinstance(k, dict) else k) if tag: tags.append(tag) if tags: @@ -61,10 +58,10 @@ def parse_time(val: Any) -> int | None: items: list[dict[str, Any]] = [] for s in steps: - section = _normalize_instruction_step(s.get("name")) + section = normalize_instruction_step(s.get("name")) if section: instr_lines.append(("header", section)) - raw_instruction = _normalize_instruction_step(s.get("instruction")) + 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)) @@ -75,7 +72,7 @@ def parse_time(val: Any) -> int | None: if ing.get("is_header"): continue food = ing.get("food") or {} - food_name = _normalize_text( + food_name = normalize_text( food.get("name") if isinstance(food, dict) else ing.get("name") ) if not food_name: @@ -83,7 +80,7 @@ def parse_time(val: Any) -> int | None: 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 "") + note = normalize_text(ing.get("note") or "") parts = [str(p) for p in (amount, unit_name, note) if p not in (None, "")] items.append( @@ -152,7 +149,7 @@ def parse_tandoor_zip( flat_entry = zip_entries.get("recipes.json") if flat_entry: try: - payload = _maybe_decode_json_payload(zf.read(flat_entry)) + payload = maybe_decode_json_payload(zf.read(flat_entry)) if isinstance(payload, list): for item in payload: if isinstance(item, dict): @@ -176,7 +173,7 @@ def parse_tandoor_zip( ) if not recipe_json_name: continue - payload = _maybe_decode_json_payload(inner.read(recipe_json_name)) + 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) diff --git a/backend/app/service/importRecipes/utils.py b/backend/app/service/importRecipes/utils.py index 477ec676a..d857a85c4 100644 --- a/backend/app/service/importRecipes/utils.py +++ b/backend/app/service/importRecipes/utils.py @@ -2,27 +2,30 @@ import gzip import json -import os import re from typing import Any -from app.util.filename_validator import allowed_file - -def _normalize_text(value: Any) -> str: +def normalize_text(value: Any) -> str: if value is None: return "" return str(value).strip() -def _normalize_instruction_step(value: Any) -> str: - text = _normalize_text(value) +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: +def normalize_int(value: Any) -> int | None: if value is None: return None try: @@ -36,73 +39,60 @@ def _parse_minutes(value: Any) -> int | None: return None if isinstance(value, (int, float)): return int(value) - text = str(value) - digits = "".join([c for c in text if c.isdigit() or c == " "]) - parts = [p for p in digits.split(" ") if p] - if not parts: - return None - try: - return int(parts[0]) - except ValueError: - return None + 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 value: +def normalize_items(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): return [] - if isinstance(value, list): - 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") - ) - if not name: - continue - quantity = _normalize_text(entry.get("quantity") or entry.get("amount")) - note = _normalize_text(entry.get("note")) - description_parts = [p for p in [quantity, note] if p] + + 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": " ".join(description_parts).strip(), - "optional": bool(entry.get("optional", False)), + "description": "", + "optional": False, } ) - else: - name = _normalize_text(entry) - if name: - items.append( - { - "name": name, - "description": "", - "optional": False, - } - ) - return items - return [] - - -def _normalize_step_image(entry: dict[str, Any]) -> str: - image_sources = [ - entry.get("image"), - entry.get("photo"), - entry.get("picture"), - entry.get("preview_picture"), - entry.get("step_image"), - entry.get("stepImage"), - entry.get("img"), - ] - images = entry.get("images") - if isinstance(images, list): - image_sources.extend(images) - for source in image_sources: - if isinstance(source, dict): - source = source.get("url") or source.get("src") or source.get("path") - source_text = _normalize_text(source) - if source_text: - return source_text - return "" + return items def _is_gzip_bytes(data: bytes) -> bool: @@ -113,7 +103,7 @@ def _load_json_bytes(data: bytes) -> Any: return json.loads(data.decode("utf-8")) -def _maybe_decode_json_payload(data: bytes) -> Any | None: +def maybe_decode_json_payload(data: bytes) -> Any | None: if _is_gzip_bytes(data): try: data = gzip.decompress(data) @@ -123,17 +113,3 @@ def _maybe_decode_json_payload(data: bytes) -> Any | None: return _load_json_bytes(data) except Exception: return None - - -def _normalize_zip_path(path: str) -> str: - return path.replace("\\", "/").strip("/").lower() - - -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 From 4c1bf9d0d43678b04b4d2d2611b142c980eb7219 Mon Sep 17 00:00:00 2001 From: Jojo-05 Date: Fri, 12 Jun 2026 21:02:35 +0200 Subject: [PATCH 13/16] feat(recipe-import): improve and cleanup import process --- .../exportimport/import_controller.py | 4 +- .../controller/recipe/recipe_controller.py | 2 +- .../service/importServices/import_recipe.py | 6 +- backend/app/service/recipe_import_service.py | 221 +----------------- 4 files changed, 11 insertions(+), 222 deletions(-) diff --git a/backend/app/controller/exportimport/import_controller.py b/backend/app/controller/exportimport/import_controller.py index d20ebbb70..d287b022e 100644 --- a/backend/app/controller/exportimport/import_controller.py +++ b/backend/app/controller/exportimport/import_controller.py @@ -10,7 +10,7 @@ from app.service.recalculate_balances import recalculateBalances from .schemas import ImportSchema, RecipeImportCommitSchema from app.helpers import validate_args, authorize_household -from flask import jsonify, Blueprint, request +from flask import jsonify, Blueprint, request, abort from flask_jwt_extended import jwt_required from app.service.recipe_import_service import ( preview_recipe_import, @@ -96,5 +96,5 @@ def commitRecipeImport(args, household_id): def getRecipeImportCommitStatus(household_id, token): res = get_recipe_import_job(token) if res is None: - return jsonify({"imported": 0, "skipped": 0, "failed": 0, "complete": True}) + abort(404, description="Import job not found or token has expired") return jsonify(res) diff --git a/backend/app/controller/recipe/recipe_controller.py b/backend/app/controller/recipe/recipe_controller.py index 1dfe3530a..b21bcf753 100644 --- a/backend/app/controller/recipe/recipe_controller.py +++ b/backend/app/controller/recipe/recipe_controller.py @@ -177,8 +177,8 @@ def updateRecipe(args, id): # noqa: C901 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/service/importServices/import_recipe.py b/backend/app/service/importServices/import_recipe.py index 8c5bfeed1..2d295d6cf 100644 --- a/backend/app/service/importServices/import_recipe.py +++ b/backend/app/service/importServices/import_recipe.py @@ -1,5 +1,4 @@ import re - from typing import Any from app.models import Recipe, RecipeTags, RecipeItems, Item, Tag from app.service.file_has_access_or_download import file_has_access_or_download @@ -71,6 +70,9 @@ def importRecipe( 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() recipe.name = args["name"] + ( f" ({recipeNameCount + 1})" if recipeNameCount > 0 else "" ) @@ -99,6 +101,8 @@ def importRecipe( 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 = [] diff --git a/backend/app/service/recipe_import_service.py b/backend/app/service/recipe_import_service.py index d9a15ae9c..eb1320f5f 100644 --- a/backend/app/service/recipe_import_service.py +++ b/backend/app/service/recipe_import_service.py @@ -4,7 +4,7 @@ import json import gzip import os -import re +import shutil import time import uuid import zipfile @@ -61,182 +61,6 @@ def _store_image_bytes(file_bytes: bytes, filename: str, user=None) -> str | Non return stored_filename -def _normalize_text(value: Any) -> str: - if value is None: - return "" - return str(value).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) - text = str(value) - digits = "".join([c for c in text if c.isdigit() or c == " "]) - parts = [p for p in digits.split(" ") if p] - if not parts: - return None - try: - return int(parts[0]) - except ValueError: - return None - - -def _normalize_items(value: Any) -> list[dict[str, Any]]: - if not value: - return [] - if isinstance(value, list): - 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") - ) - if not name: - continue - quantity = _normalize_text(entry.get("quantity") or entry.get("amount")) - note = _normalize_text(entry.get("note")) - description_parts = [p for p in [quantity, note] if p] - items.append( - { - "name": name, - "description": " ".join(description_parts).strip(), - "optional": bool(entry.get("optional", False)), - } - ) - else: - name = _normalize_text(entry) - if name: - items.append( - { - "name": name, - "description": "", - "optional": False, - } - ) - return items - return [] - - -def _normalize_step_image(entry: dict[str, Any]) -> str: - image_sources = [ - entry.get("image"), - entry.get("photo"), - entry.get("picture"), - entry.get("preview_picture"), - entry.get("step_image"), - entry.get("stepImage"), - entry.get("img"), - ] - images = entry.get("images") - if isinstance(images, list): - image_sources.extend(images) - for source in image_sources: - if isinstance(source, dict): - source = source.get("url") or source.get("src") or source.get("path") - source_text = _normalize_text(source) - if source_text: - return source_text - return "" - - -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("instructions") or raw.get("method") - if isinstance(instructions, list): - steps = [] - for entry in instructions: - if isinstance(entry, dict): - text = _normalize_instruction_step( - entry.get("text") or entry.get("instruction") or entry.get("value") - ) - if text: - steps.append(text) - else: - text = _normalize_instruction_step(entry) - if text: - steps.append(text) - if steps: - if description: - description += "\n\n" - description += "\n".join( - [f"{idx + 1}. {step}" for idx, step in enumerate(steps)] - ) - else: - instructions_text = _normalize_text(instructions) - if instructions_text: - if description: - description += "\n\n" - description += instructions_text - - recipe: dict[str, Any] = { - "name": name, - "description": description, - "time": _normalize_int(raw.get("time") or raw.get("total_time")), - "cook_time": _normalize_int(raw.get("cook_time")), - "prep_time": _normalize_int(raw.get("prep_time")), - "yields": _normalize_int( - raw.get("yields") or raw.get("servings") or raw.get("persons_served") - ), - "source": _normalize_text(raw.get("source") or raw.get("url")), - "photo": _normalize_text( - raw.get("photo") or raw.get("image") or raw.get("preview_picture") - ), - "photos": [ - _normalize_text(photo) - for photo in (raw.get("photos") or raw.get("images") or []) - if _normalize_text(photo) - ], - "items": _normalize_items(raw.get("items") or raw.get("ingredients")), - "tags": [ - _normalize_text(tag) - for tag in (raw.get("tags") or []) - if _normalize_text(tag) - ], - } - - if recipe["time"] is None: - recipe["time"] = _parse_minutes(raw.get("time") or raw.get("total_time")) - if recipe["cook_time"] is None: - recipe["cook_time"] = _parse_minutes(raw.get("cook_time")) - if recipe["prep_time"] is None: - recipe["prep_time"] = _parse_minutes(raw.get("prep_time")) - - if not recipe["tags"] and isinstance(raw.get("categories"), list): - recipe["tags"] = [ - _normalize_text(tag) - for tag in raw.get("categories", []) - if _normalize_text(tag) - ] - - if not recipe["photo"]: - recipe.pop("photo", None) - if not recipe["photos"]: - recipe.pop("photos", None) - return recipe - - def _extract_recipes(payload: Any) -> list[dict[str, Any]]: try: return json_extract_recipes(payload) @@ -427,45 +251,11 @@ def _cleanup_old_tmp(max_age_seconds: int = 60 * 60 * 24) -> None: created = float(meta.get("created", 0)) if created and now - created > max_age_seconds: try: - for root, dirs, files in os.walk(entry.path, topdown=False): - for file in files: - os.remove(os.path.join(root, file)) - for dir_name in dirs: - os.rmdir(os.path.join(root, dir_name)) - os.rmdir(entry.path) + shutil.rmtree(entry.path) except Exception: continue -def _resolve_zip_photo( - photo_value: str, - zip_entries: dict[str, str], - zip_file: zipfile.ZipFile, - images_dir: str, -) -> str | None: - if not photo_value: - return None - photo_key = photo_value.strip().lower().replace("\\", "/") - if photo_key in zip_entries: - entry_name = zip_entries[photo_key] - else: - base_name = os.path.basename(photo_key) - entry_name = zip_entries.get(base_name) - 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 preview_recipe_import( household_id: int, data: bytes, @@ -725,12 +515,7 @@ def _run_recipe_import_job( ) try: - for root, dirs, files in os.walk(base_dir, topdown=False): - for file in files: - os.remove(os.path.join(root, file)) - for dir_name in dirs: - os.rmdir(os.path.join(root, dir_name)) - os.rmdir(base_dir) + shutil.rmtree(base_dir) except Exception: app.logger.warning("Failed to cleanup recipe import temp dir") From d75908c32b382392e2511b4ffab83d5d0d27553f Mon Sep 17 00:00:00 2001 From: Jojo-05 Date: Fri, 12 Jun 2026 21:29:50 +0200 Subject: [PATCH 14/16] fix(recipe-import): database migration and localization --- .../migrations/versions/6950c7b662d1_recipe_photos_list.py | 4 ++-- kitchenowl/lib/l10n/app_de.arb | 4 ++-- kitchenowl/lib/l10n/app_en.arb | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/migrations/versions/6950c7b662d1_recipe_photos_list.py b/backend/migrations/versions/6950c7b662d1_recipe_photos_list.py index 36c216713..6810b95a1 100644 --- a/backend/migrations/versions/6950c7b662d1_recipe_photos_list.py +++ b/backend/migrations/versions/6950c7b662d1_recipe_photos_list.py @@ -1,7 +1,7 @@ """recipe photos list Revision ID: 6950c7b662d1 -Revises: bd383e73ef4d +Revises: 0b10d67750be Create Date: 2026-05-29 21:32:23.419806 """ @@ -10,7 +10,7 @@ # revision identifiers, used by Alembic. revision = '6950c7b662d1' -down_revision = 'bd383e73ef4d' +down_revision = '0b10d67750be' branch_labels = None depends_on = None diff --git a/kitchenowl/lib/l10n/app_de.arb b/kitchenowl/lib/l10n/app_de.arb index 73aa0efe4..db9c5dbfb 100644 --- a/kitchenowl/lib/l10n/app_de.arb +++ b/kitchenowl/lib/l10n/app_de.arb @@ -383,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", @@ -405,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", diff --git a/kitchenowl/lib/l10n/app_en.arb b/kitchenowl/lib/l10n/app_en.arb index f532b12c8..c9bbf2194 100644 --- a/kitchenowl/lib/l10n/app_en.arb +++ b/kitchenowl/lib/l10n/app_en.arb @@ -667,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", @@ -689,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", From f12634629f2948affafeb0fab4218974dc94c134 Mon Sep 17 00:00:00 2001 From: Jojo-05 Date: Fri, 12 Jun 2026 22:32:40 +0200 Subject: [PATCH 15/16] fix(recipe-import): commit database session --- backend/app/service/importServices/import_recipe.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/app/service/importServices/import_recipe.py b/backend/app/service/importServices/import_recipe.py index 2d295d6cf..ff521f441 100644 --- a/backend/app/service/importServices/import_recipe.py +++ b/backend/app/service/importServices/import_recipe.py @@ -1,5 +1,6 @@ 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 @@ -73,6 +74,7 @@ def importRecipe( 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 "" ) From 69c0c83a9415079888812d11956f3b63f662b55d Mon Sep 17 00:00:00 2001 From: Jojo-05 Date: Sun, 5 Jul 2026 21:03:38 +0200 Subject: [PATCH 16/16] fix(recipe-import): increase recipe import timeout --- backend/app/config.py | 4 ++-- kitchenowl/default.conf.template | 4 ++-- kitchenowl/lib/services/api/api_service.dart | 8 ++++++-- kitchenowl/lib/services/api/import_export.dart | 1 + 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index ec2a1ee6d..f6c0498cf 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -53,7 +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 = 128 +DEFAULT_MAX_CONTENT_LENGTH_MB = 32 ALLOWED_FILE_EXTENSIONS = {"txt", "pdf", "png", "jpg", "jpeg", "gif", "webp", "jxl"} FRONT_URL = os.getenv("FRONT_URL") @@ -155,7 +155,7 @@ def get_secret(env_var: str, default: str = None) -> str | None: ) except (TypeError, ValueError): max_upload_mb = DEFAULT_MAX_CONTENT_LENGTH_MB -app.config["MAX_CONTENT_LENGTH"] = max_upload_mb * 1000 * 1000 +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/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/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 ca0e9cbd3..95a11c251 100644 --- a/kitchenowl/lib/services/api/import_export.dart +++ b/kitchenowl/lib/services/api/import_export.dart @@ -40,6 +40,7 @@ extension ImportExportApi on ApiService { 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;