diff --git a/src/pages/recipe/backupModel.ts b/src/pages/recipe/backupModel.ts index 871cdd1..d17398a 100644 --- a/src/pages/recipe/backupModel.ts +++ b/src/pages/recipe/backupModel.ts @@ -1,6 +1,7 @@ +import { Category } from "../../services/category"; import { Recipe } from "../../services/recipe"; -export class BackupModel extends Recipe { +export class RecipeBackupModel extends Recipe { constructor() { super(); @@ -10,4 +11,11 @@ export class BackupModel extends Recipe { } media?: Array<{type: string, url: string}>; + category?: string; +} + +export class BackupModel { + recipes: RecipeBackupModel[] = []; + categories: Category[] = []; + version: number = 2; } \ No newline at end of file diff --git a/src/pages/recipe/import-backup.vue b/src/pages/recipe/import-backup.vue index b099172..bf01fa2 100644 --- a/src/pages/recipe/import-backup.vue +++ b/src/pages/recipe/import-backup.vue @@ -2,18 +2,18 @@ import { onMounted, ref } from "vue"; import { useState } from "../../services/store"; import { fileOpen } from "browser-fs-access"; -import { saveRecipe, saveRecipeMedia } from "../../services/dataService"; +import { saveCategory, saveRecipe, saveRecipeMedia } from "../../services/dataService"; import { notify } from "notiwind"; import { Recipe, RecipeMedia, RecipeNutrition } from "../../services/recipe"; import { useTranslation } from "i18next-vue"; import BusyIndicator from "../../components/BusyIndicator.vue"; -import { fetchWithRetry } from "../../services/fetchWithRetry"; import i18next from "i18next"; const state = useState()!; const importItemsDisplay = ref([] as Array<{ isSelected: boolean, title: string }>); const canSave = ref(false); -let importItems = [] as Array; +let importRecipes = [] as Array; +let importCategories = [] as Array; const { t } = useTranslation(); const isBusy = ref(false); @@ -23,8 +23,11 @@ onMounted(() => { state.menuOptions = []; }); -function saveRecipes() { - importItems.forEach(async (recipe, index) => { +function saveBackup() { + importCategories.forEach(async (category) => { + await saveCategory(category); + }); + importRecipes.forEach(async (recipe, index) => { if (!importItemsDisplay.value[index].isSelected) { return; } @@ -40,6 +43,7 @@ function saveRecipes() { ?? new RecipeNutrition(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); parsedRecipe.language = recipe.language ?? i18next.language; + parsedRecipe.categoryId = recipe.categoryId || 0; const id = await saveRecipe(parsedRecipe); @@ -63,12 +67,12 @@ function saveRecipes() { async function pickFile() { importItemsDisplay.value = []; - importItems = []; + importRecipes = []; canSave.value = false; // Open a file. const filePicked = await fileOpen({ - mimeTypes: ["application/zip", "application/json"], + mimeTypes: ["application/json"], }); let success = true; @@ -76,36 +80,27 @@ async function pickFile() { try { let result; - if (filePicked.name.endsWith(".zip")) { - isBusy.value = true; - const data = new FormData(); - data.append('file', filePicked); - - const response = await fetchWithRetry("/api/process-backup", { - method: "POST", - body: data - }); - - success = response.ok; - if (!success) { - return; - } + const textResult = await filePicked.text(); + result = JSON.parse(textResult); - result = await response.json(); + if (Array.isArray(result)) { + // version 1 of the backup file + importRecipes = result; + importCategories = []; } else { - const textResult = await filePicked.text(); - result = JSON.parse(textResult); + // version 2 (new) of the backup file + importRecipes = result.recipes; + importCategories = result.categories; } - importItems = result; - importItemsDisplay.value = importItems.map(item => { + importItemsDisplay.value = importRecipes.map(item => { return { isSelected: true, title: item.title }; }); canSave.value = true; state.menuOptions = [{ text: "Save", - action: saveRecipes, + action: saveBackup, svg: ` `, }]; } diff --git a/src/services/dataService.ts b/src/services/dataService.ts index 7d094ba..8184927 100644 --- a/src/services/dataService.ts +++ b/src/services/dataService.ts @@ -1,5 +1,5 @@ import { Dexie, Table } from "dexie"; -import { BackupModel } from "../pages/recipe/backupModel" +import { BackupModel, RecipeBackupModel } from "../pages/recipe/backupModel" import { Recipe, RecipeImage, RecipeMedia, RecipeNutrition } from "./recipe"; import { Setting } from "./setting"; import { Category } from "./category"; @@ -174,39 +174,47 @@ export async function getSetting(name: string, defaultValue: string): Promise> { +export async function prepareBackup(): Promise { const allRecipes = await db.recipes.toArray(); const allMedia = await db.recipeMedia.toArray(); + const allCategories = await db.categories.toArray(); - const result = []; + const result = new BackupModel(); for (const recipe of allRecipes) { - const model = getBackupModel(recipe, allMedia); + const category = allCategories.find(item => item.id == recipe.categoryId); + const model = getBackupModel(recipe, category, allMedia); - result.push(model); + result.recipes.push(model); } + result.categories = allCategories; + return result; } - -export async function prepareRecipeBackup(id: number): Promise> { +export async function prepareRecipeBackup(id: number): Promise { const recipe = await db.recipes.get(id); const allMedia = await db.recipeMedia.where("recipeId").equals(id).toArray(); + const category = await db.categories.get(recipe?.categoryId || 0); - const result: Array = []; + const result = new BackupModel(); if (!recipe) { return result; } - const model = getBackupModel(recipe, allMedia); - result.push(model); + const model = getBackupModel(recipe, category, allMedia); + result.recipes.push(model); + + if (category) { + result.categories.push(category); + } return result; } -function getBackupModel(recipe: Recipe, allMedia: RecipeMedia[]): BackupModel { - const model = new BackupModel(); +function getBackupModel(recipe: Recipe, category: Category | undefined, allMedia: RecipeMedia[]): RecipeBackupModel { + const model = new RecipeBackupModel(); model.id = recipe.id; model.title = recipe.title; model.ingredients = recipe.ingredients; @@ -225,7 +233,7 @@ function getBackupModel(recipe: Recipe, allMedia: RecipeMedia[]): BackupModel { }; }); model.categoryId = recipe.categoryId; - // TODO: how to export folders? + model.category = category?.name; return model; } diff --git a/tests/display.spec.ts b/tests/display.spec.ts index 6ec9487..fdc37b2 100644 --- a/tests/display.spec.ts +++ b/tests/display.spec.ts @@ -123,14 +123,14 @@ Bake it for 30 min`; test('share as file', async ({ page, browserName }) => { test.skip(browserName === 'webkit', 'not applicable'); await page.addInitScript(() => { - const comparer = '[{"id":2,"title":"New Bread","score":5,"ingredients":["100g flour"],"steps":["Bake it for 30 min"],"notes":"","multiplier":1,"nutrition":{"servingSize":0,"totalFat":0,"saturatedFat":0,"sodium":0,"protein":0,"cholesterol":0,"calories":0,"carbohydrates":0,"fiber":0,"sugar":0,"transFat":0,"unsaturatedFat":0},"categoryId":0,"media":[]}]'; + const comparer = '{"recipes":[{"id":2,"title":"New Bread","score":5,"ingredients":["100g flour"],"steps":["Bake it for 30 min"],"notes":"","multiplier":1,"nutrition":{"servingSize":0,"totalFat":0,"saturatedFat":0,"sodium":0,"protein":0,"cholesterol":0,"calories":0,"carbohydrates":0,"fiber":0,"sugar":0,"transFat":0,"unsaturatedFat":0},"categoryId":0,"media":[]}],"categories":[],"version":2}'; const stream = new WritableStream({ write(chunk) { return new Promise(async (resolve, reject) => { const blob = new Blob([chunk]); const result = await blob.text() const json = JSON.parse(result); - delete json[0].changedOn; + delete json.recipes[0].changedOn; if (JSON.stringify(json) !== comparer) { console.error(JSON.stringify(json)); diff --git a/tests/import-backup.spec.ts b/tests/import-backup.spec.ts index 8955a36..f439f0d 100644 --- a/tests/import-backup.spec.ts +++ b/tests/import-backup.spec.ts @@ -6,7 +6,7 @@ test.beforeEach(async ({ page }) => { }); }); -test('Restore json backup old format', async ({ page }) => { +test('Restore v1 json backup old format', async ({ page }) => { await page.addInitScript(() => { const blob = new Blob([`[ @@ -47,7 +47,7 @@ test('Restore json backup old format', async ({ page }) => { expect(await page.getByText('New Bread Recipe').textContent()).toEqual("New Bread Recipe"); }); -test('Restore json backup new format', async ({ page }) => { +test('Restore v1 json backup new format', async ({ page }) => { await page.addInitScript(() => { const blob = new Blob([`[ @@ -86,7 +86,7 @@ test('Restore json backup new format', async ({ page }) => { expect(await page.getByText('New Bread Recipe').textContent()).toEqual("New Bread Recipe"); }); -test('Restore json backup with video', async ({ page }) => { +test('Restore v1 json backup with video', async ({ page }) => { await page.addInitScript(() => { const blob = new Blob([`[ @@ -128,53 +128,77 @@ test('Restore json backup with video', async ({ page }) => { .toHaveAttribute("src", "https://www.youtube.com/embed/0YY7K7Xa5rE"); }); -test('Restore zip backup', async ({ page, browserName }) => { - test.skip(browserName === 'webkit', 'not applicable'); +test('Restore v2 json backup new format', async ({ page }) => { await page.addInitScript(() => { - const blob = new Blob([], { type: 'application/zip' }); - const file = new File([blob], "file.zip", { type: "application/zip" }); - - const fileHandle = { - getFile: async () => { return file; } - }; - - (window as any).showOpenFilePicker = async (param: any) => { - return [fileHandle]; - }; - }); - - const response = `[ + const blob = new Blob([`{ + "recipes": [ { - "id": 1, + "id": 2, "title": "New Bread Recipe", "score": 5, - "changedOn": "2022-12-29T00:35:42.073Z", - "source": "Breadtopia", "ingredients": [ - "142g whole wheat flour" + "142g whole wheat flour", + "312g white bread flour", + "7.1g salt", + "354g purified water", + "80g starter" ], "steps": [ - "Mix together the dry ingredients" + "bake" ], - "notes": "May replace whole wheat flour with rye for added taste", + "notes": "", "multiplier": 1, - "image": "/bread.jpg", - "imageAvailable": true, - "images": [], - "mutrition": {"servingSize": 1, "calories": 0} + "nutrition": { + "servingSize": 0, + "totalFat": 0, + "saturatedFat": 0, + "sodium": 0, + "protein": 0, + "cholesterol": 0, + "calories": 0, + "carbohydrates": 0, + "fiber": 0, + "sugar": 0, + "transFat": 0, + "unsaturatedFat": 0 + }, + "categoryId": 1, + "media": [], + "category": "Bread" } - ]`; + ], + "categories": [ + { + "name": "Bread", + "image": "https://via.placeholder.com/150", + "id": 1 + } + ], + "version": 2 +}`], { type: 'application/json' }); + const file = new File([blob], "file.json", { type: "application/json" }); - await page.route(/.*\/api\/process-backup/, async route => { - const json = JSON.parse(response); - await route.fulfill({ json }); + const fileHandle = { + getFile: async () => { return file; } + }; + + (window as any).showOpenFilePicker = async (param: any) => { + return [fileHandle]; + }; }); + await page.goto('#/preview-features'); + await page.getByTestId('enable-category-toggle').click(); + await page.goto('#/recipe/import-backup'); await page.getByTestId("import-button").click(); await page.getByTestId("topbar-single-button").click(); - await page.goto('#/recipe/1'); + + await page.goto('/'); + expect(await page.getByText('Bread').textContent()).toEqual("Bread"); + + await page.getByText('Bread').click(); expect(await page.getByText('New Bread Recipe').textContent()).toEqual("New Bread Recipe"); -}); \ No newline at end of file +}); diff --git a/tests/options.spec.ts b/tests/options.spec.ts index d946d25..2e29db7 100644 --- a/tests/options.spec.ts +++ b/tests/options.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { createRecipe } from './helpers'; +import { createCategory, createRecipe } from './helpers'; test.beforeEach(async ({ page }) => { await page.addInitScript(() => { @@ -11,16 +11,17 @@ test('create backup', async ({ page, browserName }) => { test.skip(browserName === 'webkit', 'not applicable'); await page.addInitScript(() => { - const comparer = '[{"id":1,"title":"Sourdough Bread","score":5,"ingredients":["142g whole wheat flour","312g white bread flour","7.1g salt","354g purified water","80g starter"],"steps":["Mix together the dry ingredients","Dissolve the starter into water","Add wet into dry ingredients and stir until incorporated","Cover with plastic or airtight lid and reserve for 15 minutes","Perform the first set of folds and reserve for another 15 minutes","Perform the second set of folds and reserve for another 15 minutes","Perform the third set of folds and make a window pane test. If gluten is not developed yet, repeat this step","Ferment for 10-14 hours at room temperature (68F - 72F)","Shape and proof for about 2 hours","Bake in covered dutch oven ou La Cloche at 420F for 30 minutes","Uncover and bake for another 15 minutes","Let it cool completely on cooling rack before carving"],"notes":"Whole wheat flour may be replaced with rye flour for added taste","multiplier":1,"source":"Breadtopia","nutrition":{"servingSize":0,"calories":0,"totalFat":0,"saturatedFat":0,"unsaturatedFat":0,"transFat":0,"carbohydrates":0,"sugar":0,"cholesterol":0,"sodium":0,"protein":0,"fiber":0},"media":[{"type":"img","url":"/bread.jpg"}]}]'; + const comparer = '{"recipes":[{"id":1,"title":"Sourdough Bread","score":5,"ingredients":["142g whole wheat flour","312g white bread flour","7.1g salt","354g purified water","80g starter"],"steps":["Mix together the dry ingredients","Dissolve the starter into water","Add wet into dry ingredients and stir until incorporated","Cover with plastic or airtight lid and reserve for 15 minutes","Perform the first set of folds and reserve for another 15 minutes","Perform the second set of folds and reserve for another 15 minutes","Perform the third set of folds and make a window pane test. If gluten is not developed yet, repeat this step","Ferment for 10-14 hours at room temperature (68F - 72F)","Shape and proof for about 2 hours","Bake in covered dutch oven ou La Cloche at 420F for 30 minutes","Uncover and bake for another 15 minutes","Let it cool completely on cooling rack before carving"],"notes":"Whole wheat flour may be replaced with rye flour for added taste","multiplier":1,"source":"Breadtopia","nutrition":{"servingSize":0,"calories":0,"totalFat":0,"saturatedFat":0,"unsaturatedFat":0,"transFat":0,"carbohydrates":0,"sugar":0,"cholesterol":0,"sodium":0,"protein":0,"fiber":0},"media":[{"type":"img","url":"/bread.jpg"}]}],"categories":[],"version":2}'; const stream = new WritableStream({ write(chunk) { return new Promise(async (resolve, reject) => { const blob = new Blob([chunk]); const result = await blob.text() const json = JSON.parse(result); - delete json[0].changedOn; + delete json.recipes[0].changedOn; if (JSON.stringify(json) !== comparer) { + console.error(JSON.stringify(json)); console.error("File doesn't match expectation"); } else { console.info("All good"); @@ -52,6 +53,56 @@ test('create backup', async ({ page, browserName }) => { await consoleWaiter; }); +test('create backup with categories', async ({ page, browserName }) => { + test.skip(browserName === 'webkit', 'not applicable'); + + await page.addInitScript(() => { + const comparer = '{"recipes":[{"id":1,"title":"Sourdough Bread","score":5,"ingredients":["142g whole wheat flour","312g white bread flour","7.1g salt","354g purified water","80g starter"],"steps":["Mix together the dry ingredients","Dissolve the starter into water","Add wet into dry ingredients and stir until incorporated","Cover with plastic or airtight lid and reserve for 15 minutes","Perform the first set of folds and reserve for another 15 minutes","Perform the second set of folds and reserve for another 15 minutes","Perform the third set of folds and make a window pane test. If gluten is not developed yet, repeat this step","Ferment for 10-14 hours at room temperature (68F - 72F)","Shape and proof for about 2 hours","Bake in covered dutch oven ou La Cloche at 420F for 30 minutes","Uncover and bake for another 15 minutes","Let it cool completely on cooling rack before carving"],"notes":"Whole wheat flour may be replaced with rye flour for added taste","multiplier":1,"source":"Breadtopia","nutrition":{"servingSize":0,"calories":0,"totalFat":0,"saturatedFat":0,"unsaturatedFat":0,"transFat":0,"carbohydrates":0,"sugar":0,"cholesterol":0,"sodium":0,"protein":0,"fiber":0},"media":[{"type":"img","url":"/bread.jpg"}]},{"id":2,"title":"Sourdough Bread","score":5,"ingredients":["142g whole wheat flour","312g white bread flour","7.1g salt","354g purified water","80g starter"],"steps":["bake"],"notes":"","multiplier":1,"nutrition":{"servingSize":0,"totalFat":0,"saturatedFat":0,"sodium":0,"protein":0,"cholesterol":0,"calories":0,"carbohydrates":0,"fiber":0,"sugar":0,"transFat":0,"unsaturatedFat":0},"categoryId":1,"media":[],"category":"Bread"}],"categories":[{"name":"Bread","image":"https://via.placeholder.com/150","id":1}],"version":2}'; + const stream = new WritableStream({ + write(chunk) { + return new Promise(async (resolve, reject) => { + const blob = new Blob([chunk]); + const result = await blob.text() + const json = JSON.parse(result); + delete json.recipes[0].changedOn; + delete json.recipes[1].changedOn; + + if (JSON.stringify(json) !== comparer) { + console.error(JSON.stringify(json)); + console.error("File doesn't match expectation"); + } else { + console.info("All good"); + } + + resolve(); + }); + }, + close() { }, + abort(err) { + console.error("Sink error:", err); + } + }); + (window as any).showSaveFilePicker = async (param: any) => { + return { createWritable: async () => { return stream } }; + }; + }); + + page.on('console', msg => { + test.fail(msg.type() == "error", "Share failed"); + }); + + await createCategory(page, 1, "Bread"); + await createRecipe(page, 2, "Sourdough Bread", 5, ["142g whole wheat flour", "312g white bread flour", "7.1g salt", "354g purified water", "80g starter"], ["bake"], true, "Bread"); + + await page.goto('/'); + await page.getByTestId('topbar-options').click(); + await page.getByRole('menuitem', { name: 'Options' }).click(); + + const consoleWaiter = page.waitForEvent("console", item => item.type() == "error" || item.type() == "info") + await page.getByText('Take a backup').click(); + await consoleWaiter; +}); + test('restore backup', async ({ page }) => { await page.goto('/'); await page.getByTestId('topbar-options').click();