Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
68e7891
feat(backend): add recipe import from external apps
Jojo-05 May 29, 2026
4c4315b
feat(frontend): add recipe import functionality and multi-image support
Jojo-05 May 30, 2026
e7585cf
fix(recipe-import): add step normalization to avoid duplicates
Jojo-05 May 30, 2026
aef85aa
fix(recipe-import): progress tracking and improve UI
Jojo-05 May 31, 2026
136dd38
fix(recipe-import): remove unnecessary snackbars and add localization
Jojo-05 May 31, 2026
f9e6029
fix(recipe-import): prevent duplicate tags
Jojo-05 May 31, 2026
675d111
fix(recipe-import): image handling and gallery
Jojo-05 Jun 1, 2026
95aaa9d
feat(importRecipes): add seperate parsers for Tandoor, Mealie and gen…
Jojo-05 Jun 4, 2026
97eec24
feat(recipe-import): enhance Mealie parser
Jojo-05 Jun 4, 2026
c1741c3
feat(recipe-import): enhance Tandoor parser
Jojo-05 Jun 4, 2026
8f3ac0e
feat(recipe-import): enhance generic json parser and add Schema.org s…
Jojo-05 Jun 4, 2026
b348efb
feat(recipe-import): refactor normalization functions
Jojo-05 Jun 4, 2026
4c1bf9d
feat(recipe-import): improve and cleanup import process
Jojo-05 Jun 12, 2026
93051d6
Merge remote-tracking branch 'origin/main' into feature/import-extern…
Jojo-05 Jun 12, 2026
d75908c
fix(recipe-import): database migration and localization
Jojo-05 Jun 12, 2026
f126346
fix(recipe-import): commit database session
Jojo-05 Jun 12, 2026
69c0c83
fix(recipe-import): increase recipe import timeout
Jojo-05 Jul 5, 2026
94e976c
Merge branch 'main' into feature/import-external-recipes
Jojo-05 Jul 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def get_secret(env_var: str, default: str = None) -> str | None:

STORAGE_PATH = os.getenv("STORAGE_PATH", PROJECT_DIR)
UPLOAD_FOLDER = STORAGE_PATH + "/upload"
DEFAULT_MAX_CONTENT_LENGTH_MB = 32
ALLOWED_FILE_EXTENSIONS = {"txt", "pdf", "png", "jpg", "jpeg", "gif", "webp", "jxl"}

FRONT_URL = os.getenv("FRONT_URL")
Expand Down Expand Up @@ -153,7 +154,13 @@ def get_secret(env_var: str, default: str = None) -> str | None:
jwt_secret = get_secret("JWT_SECRET_KEY", "super-secret")

app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
app.config["MAX_CONTENT_LENGTH"] = 32 * 1000 * 1000 # 32MB max upload
try:
max_upload_mb = int(
os.getenv("MAX_CONTENT_LENGTH_MB", str(DEFAULT_MAX_CONTENT_LENGTH_MB))
)
except (TypeError, ValueError):
max_upload_mb = DEFAULT_MAX_CONTENT_LENGTH_MB
app.config["MAX_CONTENT_LENGTH"] = max_upload_mb * 1024 * 1024
app.config["SECRET_KEY"] = jwt_secret
# SQLAlchemy
app.config["SQLALCHEMY_DATABASE_URI"] = DB_URL
Expand Down
51 changes: 49 additions & 2 deletions backend/app/controller/exportimport/import_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@
importShoppinglist,
)
from app.service.recalculate_balances import recalculateBalances
from .schemas import ImportSchema
from .schemas import ImportSchema, RecipeImportCommitSchema
from app.helpers import validate_args, authorize_household
from flask import jsonify, Blueprint
from flask import jsonify, Blueprint, request, abort
from flask_jwt_extended import jwt_required
from app.service.recipe_import_service import (
preview_recipe_import,
commit_recipe_import,
get_recipe_import_job,
)

importBP = Blueprint("import", __name__)

Expand Down Expand Up @@ -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/<token>", methods=["GET"])
@jwt_required()
@authorize_household()
def getRecipeImportCommitStatus(household_id, token):
res = get_recipe_import_job(token)
if res is None:
abort(404, description="Import job not found or token has expired")
return jsonify(res)
10 changes: 10 additions & 0 deletions backend/app/controller/exportimport/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down Expand Up @@ -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={},
)
48 changes: 37 additions & 11 deletions backend/app/controller/mcp_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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]}


Expand Down Expand Up @@ -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 = ""
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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),
},
},
)

Expand Down
22 changes: 19 additions & 3 deletions backend/app/controller/recipe/recipe_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,16 @@ def addRecipe(args, household_id):
sourceRecipe.save()
if "visibility" in args:
recipe.visibility = RecipeVisibility(args["visibility"])
if "photo" in args and args["photo"] != recipe.photo:
if "photos" in args:
recipe.photos = [
file_has_access_or_download(photo, user=current_user)
for photo in args["photos"]
if photo
]
recipe.photo = recipe.photos[0] if recipe.photos else None
elif "photo" in args:
recipe.photo = file_has_access_or_download(args["photo"], recipe.photo)
recipe.photos = [recipe.photo] if recipe.photo else []
if "server_curated" in args and current_user.admin:
recipe.server_curated = args["server_curated"]
recipe.save()
Expand Down Expand Up @@ -155,14 +163,22 @@ def updateRecipe(args, id): # noqa: C901
recipe.source = args["source"]
if "visibility" in args:
recipe.visibility = RecipeVisibility(args["visibility"])
if "photo" in args and args["photo"] != recipe.photo:
if "photos" in args:
recipe.photos = [
file_has_access_or_download(photo, user=current_user)
for photo in args["photos"]
if photo
]
recipe.photo = recipe.photos[0] if recipe.photos else None
elif "photo" in args:
recipe.photo = file_has_access_or_download(args["photo"], recipe.photo)
recipe.photos = [recipe.photo] if recipe.photo else []
if "server_curated" in args and current_user.admin:
recipe.server_curated = args["server_curated"]
recipe.save()
if "items" in args:
item_names = [e["name"] for e in args["items"]]
for con in recipe.items:
item_names = [e["name"] for e in args["items"]]
if con.item.name not in item_names:
con.delete()
for recipeItem in args["items"]:
Expand Down
2 changes: 2 additions & 0 deletions backend/app/controller/recipe/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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())
Expand Down
12 changes: 7 additions & 5 deletions backend/app/models/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
7 changes: 6 additions & 1 deletion backend/app/models/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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,
Expand Down
Loading