Compare commits

..

2 Commits

Author SHA1 Message Date
Hayden
23cb4bdf36 fix: use unique temp filename for migration archive uploads 2026-05-13 17:57:55 -05:00
Hayden
4039ff6655 fix: support CSV/TXT upload and add validation for Plan to Eat import (#6360)
Plan to Eat exports CSV or TXT files directly, but the importer only accepted
ZIP archives. This caused a silent failure when users uploaded CSV files.

- Extend plantoeat_recipes() to detect ZIP vs CSV/TXT by magic bytes and
  process raw CSV/TXT files directly without requiring a ZIP wrapper
- Add _validate_archive() to return a clear error report entry when the
  uploaded file is neither a ZIP nor valid UTF-8 text
- Update frontend file input to accept .zip, .csv, and .txt
- Update i18n description to mention all accepted formats
- Add plantoeat.csv test fixture and integration tests for CSV import
  and invalid file type rejection
2026-05-13 17:24:08 -05:00
11 changed files with 100 additions and 119 deletions

View File

@@ -427,7 +427,7 @@
"mealie-text": "Mealie can import recipes from the Mealie application from a pre v1.0 release. Export your recipes from your old instance, and upload the zip file below. Note that only recipes can be imported from the export.", "mealie-text": "Mealie can import recipes from the Mealie application from a pre v1.0 release. Export your recipes from your old instance, and upload the zip file below. Note that only recipes can be imported from the export.",
"plantoeat": { "plantoeat": {
"title": "Plan to Eat", "title": "Plan to Eat",
"description-long": "Mealie can import recipies from Plan to Eat." "description-long": "Mealie can import recipes from Plan to Eat. Upload a ZIP archive, CSV, or TXT file exported from Plan to Eat."
}, },
"myrecipebox": { "myrecipebox": {
"title": "My Recipe Box", "title": "My Recipe Box",

View File

@@ -337,16 +337,8 @@ const _content: Record<string, MigrationContent> = {
}, },
[MIGRATIONS.plantoeat]: { [MIGRATIONS.plantoeat]: {
text: i18n.t("migration.plantoeat.description-long"), text: i18n.t("migration.plantoeat.description-long"),
acceptedFileType: ".zip", acceptedFileType: ".zip,.csv,.txt",
tree: [ tree: false,
{
icon: $globals.icons.zip,
title: "plantoeat-recipes-508318_10-13-2023.zip",
children: [
{ title: "plantoeat-recipes-508318_10-13-2023.csv", icon: $globals.icons.codeJson },
],
},
],
}, },
[MIGRATIONS.recipekeeper]: { [MIGRATIONS.recipekeeper]: {
text: i18n.t("migration.recipekeeper.description-long"), text: i18n.t("migration.recipekeeper.description-long"),

View File

@@ -179,7 +179,7 @@ def validate_file_token(token: str | None = None) -> Path:
@contextmanager @contextmanager
def get_temporary_zip_path(auto_unlink=True) -> Generator[Path, None, None]: def get_temporary_zip_path(auto_unlink=True) -> Generator[Path, None, None]:
app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True) app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True)
temp_path = app_dirs.TEMP_DIR.joinpath("my_zip_archive.zip") temp_path = app_dirs.TEMP_DIR / f"{uuid4().hex}.zip"
try: try:
yield temp_path yield temp_path
finally: finally:

View File

@@ -5,13 +5,6 @@
"recipe": { "recipe": {
"unique-name-error": "Recipe names must be unique", "unique-name-error": "Recipe names must be unique",
"recipe-created": "Recipe Created", "recipe-created": "Recipe Created",
"made-this-as-side": "{name} made this as a side",
"made-this-for-breakfast": "{name} made this for breakfast",
"made-this-for-lunch": "{name} made this for lunch",
"made-this-for-dinner": "{name} made this for dinner",
"made-this-for-snack": "{name} made this for a snack",
"made-this-for-drink": "{name} made this for a drink",
"made-this-for-dessert": "{name} made this for dessert",
"recipe-image-deleted": "Recipe image deleted", "recipe-image-deleted": "Recipe image deleted",
"recipe-defaults": { "recipe-defaults": {
"ingredient-note": "1 Cup Flour", "ingredient-note": "1 Cup Flour",

View File

@@ -4,7 +4,6 @@ from functools import cached_property
from fastapi import Depends, File, Form, HTTPException from fastapi import Depends, File, Form, HTTPException
from pydantic import UUID4 from pydantic import UUID4
from mealie.lang.providers import get_locale_provider
from mealie.repos.all_repositories import get_repositories from mealie.repos.all_repositories import get_repositories
from mealie.routes._base import BaseCrudController, controller from mealie.routes._base import BaseCrudController, controller
from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.mixins import HttpRepo
@@ -16,7 +15,6 @@ from mealie.schema.recipe.recipe_timeline_events import (
RecipeTimelineEventPagination, RecipeTimelineEventPagination,
RecipeTimelineEventUpdate, RecipeTimelineEventUpdate,
TimelineEventImage, TimelineEventImage,
TimelineEventType,
) )
from mealie.schema.recipe.request_helpers import UpdateImageResponse from mealie.schema.recipe.request_helpers import UpdateImageResponse
from mealie.schema.response.pagination import PaginationQuery from mealie.schema.response.pagination import PaginationQuery
@@ -45,21 +43,6 @@ class RecipeTimelineEventsController(BaseCrudController):
self.registered_exceptions, self.registered_exceptions,
) )
def _translate_event_subject(self, event: RecipeTimelineEventOut) -> None:
"""Translate auto-generated event subjects stored as i18n key references.
Subjects are stored as ``<i18n-key>|<name>`` (e.g. ``recipe.made-this-for-dinner|Alice``).
Falls back to en-US when the requested locale has not yet been translated.
"""
if event.event_type == TimelineEventType.info.value and "|" in event.subject:
key, _, name = event.subject.partition("|")
if key.startswith("recipe."):
translated = self.t(key, name=name)
if translated == key:
translated = get_locale_provider("en-US").t(key, name=name)
if translated != key:
event.subject = translated
@router.get("", response_model=RecipeTimelineEventPagination) @router.get("", response_model=RecipeTimelineEventPagination)
def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
response = self.repo.page_all( response = self.repo.page_all(
@@ -67,9 +50,6 @@ class RecipeTimelineEventsController(BaseCrudController):
override=RecipeTimelineEventOut, override=RecipeTimelineEventOut,
) )
for event in response.items:
self._translate_event_subject(event)
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump()) response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
return response return response
@@ -103,9 +83,7 @@ class RecipeTimelineEventsController(BaseCrudController):
@router.get("/{item_id}", response_model=RecipeTimelineEventOut) @router.get("/{item_id}", response_model=RecipeTimelineEventOut)
def get_one(self, item_id: UUID4): def get_one(self, item_id: UUID4):
event = self.mixins.get_one(item_id) return self.mixins.get_one(item_id)
self._translate_event_subject(event)
return event
@router.put("/{item_id}", response_model=RecipeTimelineEventOut) @router.put("/{item_id}", response_model=RecipeTimelineEventOut)
def update_one(self, item_id: UUID4, data: RecipeTimelineEventUpdate): def update_one(self, item_id: UUID4, data: RecipeTimelineEventUpdate):

View File

@@ -7,6 +7,7 @@ from pathlib import Path
from slugify import slugify from slugify import slugify
from mealie.pkgs.cache import cache_key from mealie.pkgs.cache import cache_key
from mealie.schema.reports.reports import ReportEntryCreate
from mealie.services.scraper import cleaner from mealie.services.scraper import cleaner
from ._migration_base import BaseMigrator from ._migration_base import BaseMigrator
@@ -15,7 +16,11 @@ from .utils.migration_helpers import scrape_image, split_by_comma
def plantoeat_recipes(file: Path): def plantoeat_recipes(file: Path):
"""Yields all recipes inside the export file as dict""" """Yields all recipes inside the export file as dict.
Accepts a ZIP archive containing a CSV, or a raw CSV/TXT file.
"""
if zipfile.is_zipfile(file):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
with zipfile.ZipFile(file) as zip_file: with zipfile.ZipFile(file) as zip_file:
zip_file.extractall(tmpdir) zip_file.extractall(tmpdir)
@@ -24,6 +29,10 @@ def plantoeat_recipes(file: Path):
with open(name, newline="") as csvfile: with open(name, newline="") as csvfile:
reader = csv.DictReader(csvfile) reader = csv.DictReader(csvfile)
yield from reader yield from reader
else:
with open(file, newline="", encoding="utf-8", errors="ignore") as csvfile:
reader = csv.DictReader(csvfile)
yield from reader
def get_value_as_string_or_none(dictionary: dict, key: str): def get_value_as_string_or_none(dictionary: dict, key: str):
@@ -112,7 +121,32 @@ class PlanToEatMigrator(BaseMigrator):
return recipe_dict return recipe_dict
def _validate_archive(self) -> bool:
"""Returns False and appends a failure report entry if the file is not a ZIP, CSV, or TXT."""
if zipfile.is_zipfile(self.archive):
return True
try:
with open(self.archive, encoding="utf-8", errors="strict") as f:
f.read(512)
return True
except UnicodeDecodeError:
pass
self.report_entries.append(
ReportEntryCreate(
report_id=self.report_id,
success=False,
message="Unsupported file format. Please upload a ZIP archive, CSV file, or TXT file.",
exception="",
)
)
return False
def _migrate(self) -> None: def _migrate(self) -> None:
if not self._validate_archive():
return
recipe_image_urls = {} recipe_image_urls = {}
recipes = [] recipes = []

View File

@@ -43,10 +43,12 @@ def _create_mealplan_timeline_events_for_household(
if not user: if not user:
continue continue
# TODO: make this translatable
if mealplan.entry_type == PlanEntryType.side: if mealplan.entry_type == PlanEntryType.side:
event_subject = f"recipe.made-this-as-side|{user.full_name}" event_subject = f"{user.full_name} made this as a side"
else: else:
event_subject = f"recipe.made-this-for-{mealplan.entry_type.value}|{user.full_name}" event_subject = f"{user.full_name} made this for {mealplan.entry_type.value}"
query_start_time = datetime.combine(datetime.now(UTC).date(), time.min) query_start_time = datetime.combine(datetime.now(UTC).date(), time.min)
query_end_time = query_start_time + timedelta(days=1) query_end_time = query_start_time + timedelta(days=1)

View File

@@ -45,6 +45,8 @@ migrations_tandoor = CWD / "migrations/tandoor.zip"
migrations_plantoeat = CWD / "migrations/plantoeat.zip" migrations_plantoeat = CWD / "migrations/plantoeat.zip"
migrations_plantoeat_csv = CWD / "migrations/plantoeat.csv"
migrations_myrecipebox = CWD / "migrations/myrecipebox.csv" migrations_myrecipebox = CWD / "migrations/myrecipebox.csv"
migrations_recipekeeper = CWD / "migrations/recipekeeper.zip" migrations_recipekeeper = CWD / "migrations/recipekeeper.zip"

View File

@@ -0,0 +1,13 @@
Title,Course,Cuisine,Main Ingredient,Description,Source,Url,Url Host,Prep Time,Cook Time,Total Time,Servings,Yield,Ingredients,Directions,Tags,Rating,Public Url,Photo Url,Private,Nutritional Score (generic),Calories,Fat,Saturated Fat,Cholesterol,Sodium,Sugar,Carbohydrate,Fiber,Protein,Cost,Created At,Updated At
Test Recipe,Main Course,American,Beans,"This is a description.
Here is new line.",Manually entered source,https://eatwithclarity.com/sushi-bowl-with-sesame-tofu/,,75,75,150,7,1 loaf,", Heading
2 itm Test, note
, Heading2
3 pkg Two, note2
","Directions.
Will go here.","Allergen-Friendly, Cheap, Test",3,https://app.plantoeat.com/recipes/38843883,https://plantoeat.s3.amazonaws.com/recipes/29516709/470292506c8d9b71582487a7879ab7b197d06490-large.jpg?1628205591,yes,,13,16,17,18,19,22,20,21,23,,2023-10-13 20:29:29,2023-10-13 20:32:48
Test Recipe2,,,,,,,,,,,,,"2 itm Test, note
3 pkg Two, note2
","Directions.
Will go here.",,,,,,,,,,,,,,,,,2023-10-13 20:29:29,2023-10-13 20:32:48
1 Title Course Cuisine Main Ingredient Description Source Url Url Host Prep Time Cook Time Total Time Servings Yield Ingredients Directions Tags Rating Public Url Photo Url Private Nutritional Score (generic) Calories Fat Saturated Fat Cholesterol Sodium Sugar Carbohydrate Fiber Protein Cost Created At Updated At
2 Test Recipe Main Course American Beans This is a description. Here is new line. Manually entered source https://eatwithclarity.com/sushi-bowl-with-sesame-tofu/ 75 75 150 7 1 loaf , Heading 2 itm Test, note , Heading2 3 pkg Two, note2 Directions. Will go here. Allergen-Friendly, Cheap, Test 3 https://app.plantoeat.com/recipes/38843883 https://plantoeat.s3.amazonaws.com/recipes/29516709/470292506c8d9b71582487a7879ab7b197d06490-large.jpg?1628205591 yes 13 16 17 18 19 22 20 21 23 2023-10-13 20:29:29 2023-10-13 20:32:48
3 Test Recipe2 2 itm Test, note 3 pkg Two, note2 Directions. Will go here. 2023-10-13 20:29:29 2023-10-13 20:32:48

View File

@@ -94,6 +94,15 @@ test_cases = [
"transFatContent", "transFatContent",
}, },
), ),
MigrationTestData(
typ=SupportedMigrations.plantoeat,
archive=test_data.migrations_plantoeat_csv,
search_slug="test-recipe",
nutrition_filter={
"unsaturatedFatContent",
"transFatContent",
},
),
MigrationTestData( MigrationTestData(
typ=SupportedMigrations.myrecipebox, typ=SupportedMigrations.myrecipebox,
archive=test_data.migrations_myrecipebox, archive=test_data.migrations_myrecipebox,
@@ -124,6 +133,7 @@ test_ids = [
"mealie_alpha_archive", "mealie_alpha_archive",
"tandoor_archive", "tandoor_archive",
"plantoeat_archive", "plantoeat_archive",
"plantoeat_csv",
"myrecipebox_csv", "myrecipebox_csv",
"recipekeeper_archive", "recipekeeper_archive",
"cookn_archive", "cookn_archive",
@@ -190,6 +200,30 @@ def test_recipe_migration(api_client: TestClient, unique_user_fn_scoped: TestUse
# TODO: validate other types of content # TODO: validate other types of content
def test_plantoeat_rejects_invalid_file_type(api_client: TestClient, unique_user: TestUser) -> None:
# Simulate uploading a binary file (e.g. PDF) that is neither ZIP nor CSV/TXT
binary_content = bytes(range(256)) * 4 # arbitrary binary data that is not valid UTF-8
payload = {"migration_type": SupportedMigrations.plantoeat.value}
file_payload = {"archive": binary_content}
response = api_client.post(
api_routes.groups_migrations,
data=payload,
files=file_payload,
headers=unique_user.token,
)
assert response.status_code == 200
report_id = response.json()["id"]
response = api_client.get(api_routes.groups_reports_item_id(report_id), headers=unique_user.token)
assert response.status_code == 200
report = response.json()
assert report["entries"]
assert not report["entries"][0]["success"]
assert "ZIP" in report["entries"][0]["message"] or "CSV" in report["entries"][0]["message"]
def test_bad_mealie_alpha_data_is_ignored(api_client: TestClient, unique_user: TestUser): def test_bad_mealie_alpha_data_is_ignored(api_client: TestClient, unique_user: TestUser):
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
with ZipFile(test_data.migrations_mealie) as zf: with ZipFile(test_data.migrations_mealie) as zf:

View File

@@ -13,49 +13,6 @@ from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser from tests.utils.fixture_schemas import TestUser
def _create_recipe_and_mealplan(api_client: TestClient, user: TestUser, entry_type: str) -> tuple[RecipeSummary, int]:
recipe_name = random_string(length=25)
response = api_client.post(api_routes.recipes, json={"name": recipe_name}, headers=user.token)
assert response.status_code == 201
response = api_client.get(api_routes.recipes_slug(recipe_name), headers=user.token)
recipe = RecipeSummary.model_validate(response.json())
params = {"queryFilter": f"recipe_id={recipe.id}"}
response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=user.token)
initial_event_count = len(response.json()["items"])
new_plan = CreatePlanEntry(date=datetime.now(UTC).date(), entry_type=entry_type, recipe_id=recipe.id).model_dump(
by_alias=True
)
new_plan["date"] = datetime.now(UTC).date().isoformat()
new_plan["recipeId"] = str(recipe.id)
response = api_client.post(api_routes.households_mealplans, json=new_plan, headers=user.token)
assert response.status_code == 201
return recipe, initial_event_count
def _get_mealplan_event(
api_client: TestClient, user: TestUser, recipe: RecipeSummary, initial_count: int, extra_headers: dict
) -> dict:
create_mealplan_timeline_events()
params = {
"page": "1",
"perPage": "-1",
"orderBy": "created_at",
"orderDirection": "desc",
"queryFilter": f"recipe_id={recipe.id}",
}
response = api_client.get(
api_routes.recipes_timeline_events, headers={**user.token, **extra_headers}, params=params
)
items = response.json()["items"]
assert len(items) == initial_count + 1
return items[0]
def test_no_mealplans(): def test_no_mealplans():
# make sure this task runs successfully even if it doesn't do anything # make sure this task runs successfully even if it doesn't do anything
create_mealplan_timeline_events() create_mealplan_timeline_events()
@@ -294,27 +251,3 @@ def test_preserve_future_made_date(api_client: TestClient, unique_user: TestUser
response = api_client.get(api_routes.households_self_recipes_recipe_slug(recipe.slug), headers=h2_user.token) response = api_client.get(api_routes.households_self_recipes_recipe_slug(recipe.slug), headers=h2_user.token)
household_recipe = HouseholdRecipeSummary.model_validate(response.json()) household_recipe = HouseholdRecipeSummary.model_validate(response.json())
assert household_recipe.last_made is None assert household_recipe.last_made is None
def test_mealplan_event_subject_is_translated(api_client: TestClient, unique_user: TestUser):
"""Mealplan timeline event subjects are stored as i18n keys and translated at serve time."""
# --- dinner entry type ---
recipe, initial_count = _create_recipe_and_mealplan(api_client, unique_user, "dinner")
event = _get_mealplan_event(api_client, unique_user, recipe, initial_count, {"Accept-Language": "en-US"})
expected = f"{unique_user.full_name} made this for dinner"
assert event["subject"] == expected, f"expected {expected!r}, got {event['subject']!r}"
# --- side entry type uses a distinct phrase ---
recipe2, initial_count2 = _create_recipe_and_mealplan(api_client, unique_user, "side")
event2 = _get_mealplan_event(api_client, unique_user, recipe2, initial_count2, {"Accept-Language": "en-US"})
expected2 = f"{unique_user.full_name} made this as a side"
assert event2["subject"] == expected2, f"expected {expected2!r}, got {event2['subject']!r}"
# --- locale fallback: fr-FR doesn't have these keys yet, should fall back to en-US ---
recipe3, initial_count3 = _create_recipe_and_mealplan(api_client, unique_user, "lunch")
event3 = _get_mealplan_event(api_client, unique_user, recipe3, initial_count3, {"Accept-Language": "fr-FR"})
expected3 = f"{unique_user.full_name} made this for lunch"
assert event3["subject"] == expected3, f"expected en-US fallback {expected3!r}, got {event3['subject']!r}"