diff --git a/frontend/app/lang/messages/en-US.json b/frontend/app/lang/messages/en-US.json index 82de408c6..80707e356 100644 --- a/frontend/app/lang/messages/en-US.json +++ b/frontend/app/lang/messages/en-US.json @@ -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.", "plantoeat": { "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": { "title": "My Recipe Box", diff --git a/frontend/app/pages/group/migrations.vue b/frontend/app/pages/group/migrations.vue index 21bab2d12..e228c865e 100644 --- a/frontend/app/pages/group/migrations.vue +++ b/frontend/app/pages/group/migrations.vue @@ -337,16 +337,8 @@ const _content: Record = { }, [MIGRATIONS.plantoeat]: { text: i18n.t("migration.plantoeat.description-long"), - acceptedFileType: ".zip", - tree: [ - { - 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 }, - ], - }, - ], + acceptedFileType: ".zip,.csv,.txt", + tree: false, }, [MIGRATIONS.recipekeeper]: { text: i18n.t("migration.recipekeeper.description-long"), diff --git a/mealie/services/migrations/plantoeat.py b/mealie/services/migrations/plantoeat.py index 5b2771645..118d0d2bf 100644 --- a/mealie/services/migrations/plantoeat.py +++ b/mealie/services/migrations/plantoeat.py @@ -7,6 +7,7 @@ from pathlib import Path from slugify import slugify from mealie.pkgs.cache import cache_key +from mealie.schema.reports.reports import ReportEntryCreate from mealie.services.scraper import cleaner from ._migration_base import BaseMigrator @@ -15,15 +16,23 @@ from .utils.migration_helpers import scrape_image, split_by_comma def plantoeat_recipes(file: Path): - """Yields all recipes inside the export file as dict""" - with tempfile.TemporaryDirectory() as tmpdir: - with zipfile.ZipFile(file) as zip_file: - zip_file.extractall(tmpdir) + """Yields all recipes inside the export file as dict. - for name in Path(tmpdir).glob("**/[!.]*.csv"): - with open(name, newline="") as csvfile: - reader = csv.DictReader(csvfile) - yield from reader + Accepts a ZIP archive containing a CSV, or a raw CSV/TXT file. + """ + if zipfile.is_zipfile(file): + with tempfile.TemporaryDirectory() as tmpdir: + with zipfile.ZipFile(file) as zip_file: + zip_file.extractall(tmpdir) + + for name in Path(tmpdir).glob("**/[!.]*.csv"): + with open(name, newline="") as csvfile: + reader = csv.DictReader(csvfile) + 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): @@ -112,7 +121,32 @@ class PlanToEatMigrator(BaseMigrator): 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: + if not self._validate_archive(): + return + recipe_image_urls = {} recipes = [] diff --git a/tests/data/__init__.py b/tests/data/__init__.py index dc23abb4a..65f0d9227 100644 --- a/tests/data/__init__.py +++ b/tests/data/__init__.py @@ -45,6 +45,8 @@ migrations_tandoor = CWD / "migrations/tandoor.zip" migrations_plantoeat = CWD / "migrations/plantoeat.zip" +migrations_plantoeat_csv = CWD / "migrations/plantoeat.csv" + migrations_myrecipebox = CWD / "migrations/myrecipebox.csv" migrations_recipekeeper = CWD / "migrations/recipekeeper.zip" diff --git a/tests/data/migrations/plantoeat.csv b/tests/data/migrations/plantoeat.csv new file mode 100644 index 000000000..e8ad52a7f --- /dev/null +++ b/tests/data/migrations/plantoeat.csv @@ -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 \ No newline at end of file diff --git a/tests/integration_tests/recipe_migration_tests/test_recipe_migrations.py b/tests/integration_tests/recipe_migration_tests/test_recipe_migrations.py index 8fbc57d7d..e95160961 100644 --- a/tests/integration_tests/recipe_migration_tests/test_recipe_migrations.py +++ b/tests/integration_tests/recipe_migration_tests/test_recipe_migrations.py @@ -94,6 +94,15 @@ test_cases = [ "transFatContent", }, ), + MigrationTestData( + typ=SupportedMigrations.plantoeat, + archive=test_data.migrations_plantoeat_csv, + search_slug="test-recipe", + nutrition_filter={ + "unsaturatedFatContent", + "transFatContent", + }, + ), MigrationTestData( typ=SupportedMigrations.myrecipebox, archive=test_data.migrations_myrecipebox, @@ -124,6 +133,7 @@ test_ids = [ "mealie_alpha_archive", "tandoor_archive", "plantoeat_archive", + "plantoeat_csv", "myrecipebox_csv", "recipekeeper_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 +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): with TemporaryDirectory() as tmpdir: with ZipFile(test_data.migrations_mealie) as zf: