Compare commits

..

8 Commits

Author SHA1 Message Date
Hayden
4e767b25d3 New translations en-us.json (Ukrainian)
[ci skip]
2026-05-14 13:26:20 -05:00
Hayden
ff783a766e New translations en-us.json (Polish)
[ci skip]
2026-05-14 13:26:19 -05:00
Hayden
3ff732439a New translations en-us.json (Polish)
[ci skip]
2026-05-14 13:26:17 -05:00
Hayden
5f72724d2d New translations en-us.json (Norwegian)
[ci skip]
2026-05-13 12:39:01 -05:00
Hayden
5dc98e8e7e New translations en-us.json (Norwegian)
[ci skip]
2026-05-13 12:38:59 -05:00
Hayden
eaf3a1b9bd New translations en-us.json (Slovenian)
[ci skip]
2026-05-13 00:32:09 -05:00
Hayden
f7e4d5ad99 New translations en-us.json (Norwegian)
[ci skip]
2026-05-13 00:32:07 -05:00
Hayden
04b975b779 New translations en-us.json (Norwegian)
[ci skip]
2026-05-13 00:32:06 -05:00
26 changed files with 92 additions and 227 deletions

View File

@@ -321,7 +321,7 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
const householdsWithFood = subIng.food?.householdsWithIngredientFood || []; const householdsWithFood = subIng.food?.householdsWithIngredientFood || [];
ownIngs.push({ ownIngs.push({
checked: !householdsWithFood.includes(currentHouseholdSlug.value), checked: !householdsWithFood.includes(currentHouseholdSlug.value),
ingredient: subIng, ingredient: { ...subIng, quantity: (ing.quantity || 1) * (subIng.quantity || 1) },
}); });
} }
} }

View File

@@ -13,10 +13,9 @@ describe("useStoreActions", () => {
const mockStore = ref([]); const mockStore = ref([]);
const mockLoading = ref(false); const mockLoading = ref(false);
const mockInitialized = ref(false);
test("deleteMany calls deleteOne for each ID and refreshes once", async () => { test("deleteMany calls deleteOne for each ID and refreshes once", async () => {
const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading, mockInitialized); const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading);
mockApi.deleteOne = vi.fn().mockResolvedValue({ response: { data: {} } }); mockApi.deleteOne = vi.fn().mockResolvedValue({ response: { data: {} } });
mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } }); mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } });
@@ -33,7 +32,7 @@ describe("useStoreActions", () => {
}); });
test("deleteMany handles empty array", async () => { test("deleteMany handles empty array", async () => {
const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading, mockInitialized); const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading);
mockApi.deleteOne = vi.fn(); mockApi.deleteOne = vi.fn();
mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } }); mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } });
@@ -45,7 +44,7 @@ describe("useStoreActions", () => {
}); });
test("deleteMany sets loading state", async () => { test("deleteMany sets loading state", async () => {
const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading, mockInitialized); const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading);
mockApi.deleteOne = vi.fn().mockResolvedValue({}); mockApi.deleteOne = vi.fn().mockResolvedValue({});
mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } }); mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } });
@@ -56,25 +55,4 @@ describe("useStoreActions", () => {
await promise; await promise;
expect(mockLoading.value).toBe(false); expect(mockLoading.value).toBe(false);
}); });
test("refresh sets initialized to true even when store returns empty results", async () => {
const localInitialized = ref(false);
const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading, localInitialized);
mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } });
expect(localInitialized.value).toBe(false);
await actions.refresh();
expect(localInitialized.value).toBe(true);
});
test("refresh sets initialized to true when store returns items", async () => {
const localInitialized = ref(false);
const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading, localInitialized);
mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [{ id: "1", name: "item" }] } });
await actions.refresh();
expect(localInitialized.value).toBe(true);
});
}); });

View File

@@ -26,7 +26,6 @@ export function useReadOnlyActions<T extends BoundT>(
api: BaseCRUDAPIReadOnly<T>, api: BaseCRUDAPIReadOnly<T>,
allRef: Ref<T[] | null> | null, allRef: Ref<T[] | null> | null,
loading: Ref<boolean>, loading: Ref<boolean>,
initialized: Ref<boolean>,
defaultQueryParams: Record<string, QueryValue> = {}, defaultQueryParams: Record<string, QueryValue> = {},
): ReadOnlyStoreActions<T> { ): ReadOnlyStoreActions<T> {
function getAll(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) { function getAll(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
@@ -70,7 +69,6 @@ export function useReadOnlyActions<T extends BoundT>(
allRef.value = data.items; allRef.value = data.items;
} }
initialized.value = true;
loading.value = false; loading.value = false;
} }
@@ -91,7 +89,6 @@ export function useStoreActions<T extends BoundT>(
api: BaseCRUDAPI<unknown, T, unknown>, api: BaseCRUDAPI<unknown, T, unknown>,
allRef: Ref<T[] | null> | null, allRef: Ref<T[] | null> | null,
loading: Ref<boolean>, loading: Ref<boolean>,
initialized: Ref<boolean>,
defaultQueryParams: Record<string, QueryValue> = {}, defaultQueryParams: Record<string, QueryValue> = {},
): StoreActions<T> { ): StoreActions<T> {
function getAll(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) { function getAll(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
@@ -135,7 +132,6 @@ export function useStoreActions<T extends BoundT>(
allRef.value = data.items; allRef.value = data.items;
} }
initialized.value = true;
loading.value = false; loading.value = false;
} }

View File

@@ -16,11 +16,10 @@ export const useReadOnlyStore = function <T extends BoundT>(
storeKey: string, storeKey: string,
store: Ref<T[]>, store: Ref<T[]>,
loading: Ref<boolean>, loading: Ref<boolean>,
initialized: Ref<boolean>,
api: BaseCRUDAPIReadOnly<T>, api: BaseCRUDAPIReadOnly<T>,
params = {} as Record<string, QueryValue>, params = {} as Record<string, QueryValue>,
) { ) {
const storeActions = useReadOnlyActions(`${storeKey}-store-readonly`, api, store, loading, initialized); const storeActions = useReadOnlyActions(`${storeKey}-store-readonly`, api, store, loading);
const actions = { const actions = {
...storeActions, ...storeActions,
async refresh() { async refresh() {
@@ -28,12 +27,11 @@ export const useReadOnlyStore = function <T extends BoundT>(
}, },
flushStore() { flushStore() {
store.value = []; store.value = [];
initialized.value = false;
}, },
}; };
// initial hydration // initial hydration
if (!loading.value && !initialized.value) { if (!loading.value && !store.value.length) {
actions.refresh(); actions.refresh();
} }
@@ -44,11 +42,10 @@ export const useStore = function <T extends BoundT>(
storeKey: string, storeKey: string,
store: Ref<T[]>, store: Ref<T[]>,
loading: Ref<boolean>, loading: Ref<boolean>,
initialized: Ref<boolean>,
api: BaseCRUDAPI<unknown, T, unknown>, api: BaseCRUDAPI<unknown, T, unknown>,
params = {} as Record<string, QueryValue>, params = {} as Record<string, QueryValue>,
) { ) {
const storeActions = useStoreActions(`${storeKey}-store`, api, store, loading, initialized); const storeActions = useStoreActions(`${storeKey}-store`, api, store, loading);
const actions = { const actions = {
...storeActions, ...storeActions,
async refresh() { async refresh() {
@@ -56,12 +53,11 @@ export const useStore = function <T extends BoundT>(
}, },
flushStore() { flushStore() {
store.value = []; store.value = [];
initialized.value = false;
}, },
}; };
// initial hydration // initial hydration
if (!loading.value && !initialized.value) { if (!loading.value && !store.value.length) {
actions.refresh(); actions.refresh();
} }

View File

@@ -5,16 +5,12 @@ import { usePublicExploreApi, useUserApi } from "~/composables/api";
const store: Ref<RecipeCategory[]> = ref([]); const store: Ref<RecipeCategory[]> = ref([]);
const loading = ref(false); const loading = ref(false);
const initialized = ref(false);
const publicLoading = ref(false); const publicLoading = ref(false);
const publicInitialized = ref(false);
export function resetCategoryStore() { export function resetCategoryStore() {
store.value = []; store.value = [];
loading.value = false; loading.value = false;
initialized.value = false;
publicLoading.value = false; publicLoading.value = false;
publicInitialized.value = false;
} }
export const useCategoryData = function () { export const useCategoryData = function () {
@@ -27,10 +23,10 @@ export const useCategoryData = function () {
export const useCategoryStore = function (i18n?: Composer) { export const useCategoryStore = function (i18n?: Composer) {
const api = useUserApi(i18n); const api = useUserApi(i18n);
return useStore<RecipeCategory>("category", store, loading, initialized, api.categories); return useStore<RecipeCategory>("category", store, loading, api.categories);
}; };
export const usePublicCategoryStore = function (groupSlug: string, i18n?: Composer) { export const usePublicCategoryStore = function (groupSlug: string, i18n?: Composer) {
const api = usePublicExploreApi(groupSlug, i18n).explore; const api = usePublicExploreApi(groupSlug, i18n).explore;
return useReadOnlyStore<RecipeCategory>("category", store, publicLoading, publicInitialized, api.categories); return useReadOnlyStore<RecipeCategory>("category", store, publicLoading, api.categories);
}; };

View File

@@ -5,21 +5,17 @@ import { usePublicExploreApi, useUserApi } from "~/composables/api";
const cookbooks: Ref<ReadCookBook[]> = ref([]); const cookbooks: Ref<ReadCookBook[]> = ref([]);
const loading = ref(false); const loading = ref(false);
const initialized = ref(false);
const publicLoading = ref(false); const publicLoading = ref(false);
const publicInitialized = ref(false);
export function resetCookbookStore() { export function resetCookbookStore() {
cookbooks.value = []; cookbooks.value = [];
loading.value = false; loading.value = false;
initialized.value = false;
publicLoading.value = false; publicLoading.value = false;
publicInitialized.value = false;
} }
export const useCookbookStore = function (i18n?: Composer) { export const useCookbookStore = function (i18n?: Composer) {
const api = useUserApi(i18n); const api = useUserApi(i18n);
const store = useStore<ReadCookBook>("cookbook", cookbooks, loading, initialized, api.cookbooks); const store = useStore<ReadCookBook>("cookbook", cookbooks, loading, api.cookbooks);
const updateAll = async function (updateData: UpdateCookBook[]) { const updateAll = async function (updateData: UpdateCookBook[]) {
loading.value = true; loading.value = true;
@@ -35,5 +31,5 @@ export const useCookbookStore = function (i18n?: Composer) {
export const usePublicCookbookStore = function (groupSlug: string, i18n?: Composer) { export const usePublicCookbookStore = function (groupSlug: string, i18n?: Composer) {
const api = usePublicExploreApi(groupSlug, i18n).explore; const api = usePublicExploreApi(groupSlug, i18n).explore;
return useReadOnlyStore<ReadCookBook>("cookbook", cookbooks, publicLoading, publicInitialized, api.cookbooks); return useReadOnlyStore<ReadCookBook>("cookbook", cookbooks, publicLoading, api.cookbooks);
}; };

View File

@@ -5,16 +5,12 @@ import { usePublicExploreApi, useUserApi } from "~/composables/api";
const store: Ref<IngredientFood[]> = ref([]); const store: Ref<IngredientFood[]> = ref([]);
const loading = ref(false); const loading = ref(false);
const initialized = ref(false);
const publicLoading = ref(false); const publicLoading = ref(false);
const publicInitialized = ref(false);
export function resetFoodStore() { export function resetFoodStore() {
store.value = []; store.value = [];
loading.value = false; loading.value = false;
initialized.value = false;
publicLoading.value = false; publicLoading.value = false;
publicInitialized.value = false;
} }
export const useFoodData = function () { export const useFoodData = function () {
@@ -28,10 +24,10 @@ export const useFoodData = function () {
export const useFoodStore = function (i18n?: Composer) { export const useFoodStore = function (i18n?: Composer) {
const api = useUserApi(i18n); const api = useUserApi(i18n);
return useStore<IngredientFood>("food", store, loading, initialized, api.foods); return useStore<IngredientFood>("food", store, loading, api.foods);
}; };
export const usePublicFoodStore = function (groupSlug: string, i18n?: Composer) { export const usePublicFoodStore = function (groupSlug: string, i18n?: Composer) {
const api = usePublicExploreApi(groupSlug, i18n).explore; const api = usePublicExploreApi(groupSlug, i18n).explore;
return useReadOnlyStore<IngredientFood>("food", store, publicLoading, publicInitialized, api.foods); return useReadOnlyStore<IngredientFood>("food", store, publicLoading, api.foods);
}; };

View File

@@ -5,24 +5,20 @@ import { usePublicExploreApi, useUserApi } from "~/composables/api";
const store: Ref<HouseholdSummary[]> = ref([]); const store: Ref<HouseholdSummary[]> = ref([]);
const loading = ref(false); const loading = ref(false);
const initialized = ref(false);
const publicLoading = ref(false); const publicLoading = ref(false);
const publicInitialized = ref(false);
export function resetHouseholdStore() { export function resetHouseholdStore() {
store.value = []; store.value = [];
loading.value = false; loading.value = false;
initialized.value = false;
publicLoading.value = false; publicLoading.value = false;
publicInitialized.value = false;
} }
export const useHouseholdStore = function (i18n?: Composer) { export const useHouseholdStore = function (i18n?: Composer) {
const api = useUserApi(i18n); const api = useUserApi(i18n);
return useReadOnlyStore<HouseholdSummary>("household", store, loading, initialized, api.households); return useReadOnlyStore<HouseholdSummary>("household", store, loading, api.households);
}; };
export const usePublicHouseholdStore = function (groupSlug: string, i18n?: Composer) { export const usePublicHouseholdStore = function (groupSlug: string, i18n?: Composer) {
const api = usePublicExploreApi(groupSlug, i18n).explore; const api = usePublicExploreApi(groupSlug, i18n).explore;
return useReadOnlyStore<HouseholdSummary>("household-public", store, publicLoading, publicInitialized, api.households); return useReadOnlyStore<HouseholdSummary>("household-public", store, publicLoading, api.households);
}; };

View File

@@ -5,12 +5,10 @@ import { useUserApi } from "~/composables/api";
const store: Ref<MultiPurposeLabelOut[]> = ref([]); const store: Ref<MultiPurposeLabelOut[]> = ref([]);
const loading = ref(false); const loading = ref(false);
const initialized = ref(false);
export function resetLabelStore() { export function resetLabelStore() {
store.value = []; store.value = [];
loading.value = false; loading.value = false;
initialized.value = false;
} }
export const useLabelData = function () { export const useLabelData = function () {
@@ -24,5 +22,5 @@ export const useLabelData = function () {
export const useLabelStore = function (i18n?: Composer) { export const useLabelStore = function (i18n?: Composer) {
const api = useUserApi(i18n); const api = useUserApi(i18n);
return useStore<MultiPurposeLabelOut>("label", store, loading, initialized, api.multiPurposeLabels); return useStore<MultiPurposeLabelOut>("label", store, loading, api.multiPurposeLabels);
}; };

View File

@@ -5,16 +5,12 @@ import { usePublicExploreApi, useUserApi } from "~/composables/api";
const store: Ref<RecipeTag[]> = ref([]); const store: Ref<RecipeTag[]> = ref([]);
const loading = ref(false); const loading = ref(false);
const initialized = ref(false);
const publicLoading = ref(false); const publicLoading = ref(false);
const publicInitialized = ref(false);
export function resetTagStore() { export function resetTagStore() {
store.value = []; store.value = [];
loading.value = false; loading.value = false;
initialized.value = false;
publicLoading.value = false; publicLoading.value = false;
publicInitialized.value = false;
} }
export const useTagData = function () { export const useTagData = function () {
@@ -27,10 +23,10 @@ export const useTagData = function () {
export const useTagStore = function (i18n?: Composer) { export const useTagStore = function (i18n?: Composer) {
const api = useUserApi(i18n); const api = useUserApi(i18n);
return useStore<RecipeTag>("tag", store, loading, initialized, api.tags); return useStore<RecipeTag>("tag", store, loading, api.tags);
}; };
export const usePublicTagStore = function (groupSlug: string, i18n?: Composer) { export const usePublicTagStore = function (groupSlug: string, i18n?: Composer) {
const api = usePublicExploreApi(groupSlug, i18n).explore; const api = usePublicExploreApi(groupSlug, i18n).explore;
return useReadOnlyStore<RecipeTag>("tag", store, publicLoading, publicInitialized, api.tags); return useReadOnlyStore<RecipeTag>("tag", store, publicLoading, api.tags);
}; };

View File

@@ -9,16 +9,12 @@ interface RecipeToolWithOnHand extends RecipeTool {
const store: Ref<RecipeTool[]> = ref([]); const store: Ref<RecipeTool[]> = ref([]);
const loading = ref(false); const loading = ref(false);
const initialized = ref(false);
const publicLoading = ref(false); const publicLoading = ref(false);
const publicInitialized = ref(false);
export function resetToolStore() { export function resetToolStore() {
store.value = []; store.value = [];
loading.value = false; loading.value = false;
initialized.value = false;
publicLoading.value = false; publicLoading.value = false;
publicInitialized.value = false;
} }
export const useToolData = function () { export const useToolData = function () {
@@ -33,10 +29,10 @@ export const useToolData = function () {
export const useToolStore = function (i18n?: Composer) { export const useToolStore = function (i18n?: Composer) {
const api = useUserApi(i18n); const api = useUserApi(i18n);
return useStore<RecipeTool>("tool", store, loading, initialized, api.tools); return useStore<RecipeTool>("tool", store, loading, api.tools);
}; };
export const usePublicToolStore = function (groupSlug: string, i18n?: Composer) { export const usePublicToolStore = function (groupSlug: string, i18n?: Composer) {
const api = usePublicExploreApi(groupSlug, i18n).explore; const api = usePublicExploreApi(groupSlug, i18n).explore;
return useReadOnlyStore<RecipeTool>("tool", store, publicLoading, publicInitialized, api.tools); return useReadOnlyStore<RecipeTool>("tool", store, publicLoading, api.tools);
}; };

View File

@@ -5,12 +5,10 @@ import { useUserApi } from "~/composables/api";
const store: Ref<IngredientUnit[]> = ref([]); const store: Ref<IngredientUnit[]> = ref([]);
const loading = ref(false); const loading = ref(false);
const initialized = ref(false);
export function resetUnitStore() { export function resetUnitStore() {
store.value = []; store.value = [];
loading.value = false; loading.value = false;
initialized.value = false;
} }
export const useUnitData = function () { export const useUnitData = function () {
@@ -25,5 +23,5 @@ export const useUnitData = function () {
export const useUnitStore = function (i18n?: Composer) { export const useUnitStore = function (i18n?: Composer) {
const api = useUserApi(i18n); const api = useUserApi(i18n);
return useStore<IngredientUnit>("unit", store, loading, initialized, api.units); return useStore<IngredientUnit>("unit", store, loading, api.units);
}; };

View File

@@ -6,12 +6,10 @@ import { BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients";
const store: Ref<UserSummary[]> = ref([]); const store: Ref<UserSummary[]> = ref([]);
const loading = ref(false); const loading = ref(false);
const initialized = ref(false);
export function resetUserStore() { export function resetUserStore() {
store.value = []; store.value = [];
loading.value = false; loading.value = false;
initialized.value = false;
} }
class GroupUserAPIReadOnly extends BaseCRUDAPIReadOnly<UserSummary> { class GroupUserAPIReadOnly extends BaseCRUDAPIReadOnly<UserSummary> {
@@ -23,5 +21,5 @@ export const useUserStore = function (i18n?: Composer) {
const requests = useRequests(i18n); const requests = useRequests(i18n);
const api = new GroupUserAPIReadOnly(requests); const api = new GroupUserAPIReadOnly(requests);
return useReadOnlyStore<UserSummary>("user", store, loading, initialized, api, { orderBy: "full_name" }); return useReadOnlyStore<UserSummary>("user", store, loading, api, { orderBy: "full_name" });
}; };

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 recipes from Plan to Eat. Upload a ZIP archive, CSV, or TXT file exported from Plan to Eat." "description-long": "Mealie can import recipies from Plan to Eat."
}, },
"myrecipebox": { "myrecipebox": {
"title": "My Recipe Box", "title": "My Recipe Box",

View File

@@ -98,7 +98,7 @@
"dashboard": "Kontrollpanel", "dashboard": "Kontrollpanel",
"delete": "Slett", "delete": "Slett",
"disabled": "Deaktivert", "disabled": "Deaktivert",
"done": "Done", "done": "Ferdig",
"download": "Last ned", "download": "Last ned",
"duplicate": "Dupliser", "duplicate": "Dupliser",
"edit": "Rediger", "edit": "Rediger",
@@ -169,7 +169,7 @@
"token": "Token", "token": "Token",
"tuesday": "Tirsdag", "tuesday": "Tirsdag",
"type": "Type", "type": "Type",
"undo": "Undo", "undo": "Angre",
"update": "Oppdater", "update": "Oppdater",
"updated": "Oppdatert", "updated": "Oppdatert",
"upload": "Last opp", "upload": "Last opp",
@@ -334,7 +334,7 @@
"no-meal-plan-defined-yet": "Ingen måltidsplan er definert ennå", "no-meal-plan-defined-yet": "Ingen måltidsplan er definert ennå",
"no-meal-planned-for-today": "Ingen måltid planlagt i dag", "no-meal-planned-for-today": "Ingen måltid planlagt i dag",
"numberOfDaysPast-hint": "Number of days in the past on page load", "numberOfDaysPast-hint": "Number of days in the past on page load",
"numberOfDaysPast-label": "Default Days in the Past", "numberOfDaysPast-label": "Standard antall dager tilbake",
"numberOfDays-hint": "Antall dager på sideinnlasting", "numberOfDays-hint": "Antall dager på sideinnlasting",
"numberOfDays-label": "Standard antall dager", "numberOfDays-label": "Standard antall dager",
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Kun oppskrifter med disse kategoriene vil bli brukt i måltidsplaner", "only-recipes-with-these-categories-will-be-used-in-meal-plans": "Kun oppskrifter med disse kategoriene vil bli brukt i måltidsplaner",
@@ -392,7 +392,7 @@
"nextcloud": { "nextcloud": {
"description": "Overfør data fra en Nextcloud Cookbook-instans", "description": "Overfør data fra en Nextcloud Cookbook-instans",
"description-long": "Oppskrifter fra Nextcloud kan importeres fra en zip-fil som inneholder dataene lagret i Nextcloud. Se eksempelet på mappestrukture nedenfor for å sikre at oppskriftene kan importeres.", "description-long": "Oppskrifter fra Nextcloud kan importeres fra en zip-fil som inneholder dataene lagret i Nextcloud. Se eksempelet på mappestrukture nedenfor for å sikre at oppskriftene kan importeres.",
"title": "Nextcloud Cookbook" "title": "Nextcloud kokebok"
}, },
"copymethat": { "copymethat": {
"description-long": "Mealie kan importere oppskrifter fra Copy Me That. Eksporter oppskrifter i HTML-format, last deretter opp .zip-filen under.", "description-long": "Mealie kan importere oppskrifter fra Copy Me That. Eksporter oppskrifter i HTML-format, last deretter opp .zip-filen under.",
@@ -917,7 +917,7 @@
"quantity": "Antall: {0}", "quantity": "Antall: {0}",
"shopping-list": "Handleliste", "shopping-list": "Handleliste",
"shopping-lists": "Handlelister", "shopping-lists": "Handlelister",
"add-item": "Add item", "add-item": "Legg til produkt",
"food": "Matvare", "food": "Matvare",
"note": "Notat", "note": "Notat",
"label": "Etikett", "label": "Etikett",
@@ -943,7 +943,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Er du sikker på at du vil fjerne valg av alle elementer?", "are-you-sure-you-want-to-uncheck-all-items": "Er du sikker på at du vil fjerne valg av alle elementer?",
"are-you-sure-you-want-to-delete-checked-items": "Er du sikker på at du vil slette alle valgte elementer?", "are-you-sure-you-want-to-delete-checked-items": "Er du sikker på at du vil slette alle valgte elementer?",
"no-shopping-lists-found": "Ingen handlelister funnet", "no-shopping-lists-found": "Ingen handlelister funnet",
"item-checked-off": "Checked off {item}" "item-checked-off": "Avkrysset av {item}"
}, },
"sidebar": { "sidebar": {
"all-recipes": "Alle oppskrifter", "all-recipes": "Alle oppskrifter",
@@ -1478,10 +1478,10 @@
"max-length": "Må være minst minst {max} tegn må bestå av maks {max} tegn" "max-length": "Må være minst minst {max} tegn må bestå av maks {max} tegn"
}, },
"announcements": { "announcements": {
"announcements": "Announcements", "announcements": "Kunngjøringer",
"all-announcements": "All announcements", "all-announcements": "Alle kunngjøringer",
"mark-all-as-read": "Mark All as Read", "mark-all-as-read": "Marker alle som lest",
"show-announcements-from-mealie": "Show announcements from Mealie", "show-announcements-from-mealie": "Vis kunngjøringer fra Mealie",
"show-announcements-setting-description": "Whether or not you want to allow users to see announcements from Mealie. When enabled users can still opt-out from seeing them in their user settings" "show-announcements-setting-description": "Whether or not you want to allow users to see announcements from Mealie. When enabled users can still opt-out from seeing them in their user settings"
} }
} }

View File

@@ -51,7 +51,7 @@
"category": "Kategoria" "category": "Kategoria"
}, },
"events": { "events": {
"apprise-url": "Apprise URL", "apprise-url": "URL Apprise",
"database": "Baza danych", "database": "Baza danych",
"delete-event": "Usuń wydarzenie", "delete-event": "Usuń wydarzenie",
"event-delete-confirmation": "Czy na pewno chcesz usunąć to zdarzenie?", "event-delete-confirmation": "Czy na pewno chcesz usunąć to zdarzenie?",
@@ -98,7 +98,7 @@
"dashboard": "Panel główny", "dashboard": "Panel główny",
"delete": "Usuń", "delete": "Usuń",
"disabled": "Wyłączone", "disabled": "Wyłączone",
"done": "Done", "done": "Gotowe",
"download": "Pobierz", "download": "Pobierz",
"duplicate": "Duplikuj", "duplicate": "Duplikuj",
"edit": "Edytuj", "edit": "Edytuj",
@@ -169,7 +169,7 @@
"token": "Token", "token": "Token",
"tuesday": "Wtorek", "tuesday": "Wtorek",
"type": "Typ", "type": "Typ",
"undo": "Undo", "undo": "Cofnij",
"update": "Zaktualizuj", "update": "Zaktualizuj",
"updated": "Zaktualizowano", "updated": "Zaktualizowano",
"upload": "Prześlij", "upload": "Prześlij",
@@ -917,7 +917,7 @@
"quantity": "Ilość: {0}", "quantity": "Ilość: {0}",
"shopping-list": "Lista zakupów", "shopping-list": "Lista zakupów",
"shopping-lists": "Listy zakupów", "shopping-lists": "Listy zakupów",
"add-item": "Add item", "add-item": "Dodaj element",
"food": "Jedzenie", "food": "Jedzenie",
"note": "Notatka", "note": "Notatka",
"label": "Etykieta", "label": "Etykieta",
@@ -943,7 +943,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Czy na pewno chcesz odznaczyć wszystkie elementy?", "are-you-sure-you-want-to-uncheck-all-items": "Czy na pewno chcesz odznaczyć wszystkie elementy?",
"are-you-sure-you-want-to-delete-checked-items": "Czy jesteś pewien, że chcesz usunąć wszystkie zaznaczone elementy?", "are-you-sure-you-want-to-delete-checked-items": "Czy jesteś pewien, że chcesz usunąć wszystkie zaznaczone elementy?",
"no-shopping-lists-found": "Nie znaleziono list zakupów", "no-shopping-lists-found": "Nie znaleziono list zakupów",
"item-checked-off": "Checked off {item}" "item-checked-off": "Zaznaczono {item}"
}, },
"sidebar": { "sidebar": {
"all-recipes": "Wszystkie", "all-recipes": "Wszystkie",
@@ -1478,10 +1478,10 @@
"max-length": "Może zawierać co najwyżej {max} znak|Może zawierać co najwyżej {max} znaki|Może zawierać co najwyżej {max} znaków" "max-length": "Może zawierać co najwyżej {max} znak|Może zawierać co najwyżej {max} znaki|Może zawierać co najwyżej {max} znaków"
}, },
"announcements": { "announcements": {
"announcements": "Announcements", "announcements": "Ogłoszenia",
"all-announcements": "All announcements", "all-announcements": "Wszystkie ogłoszenia",
"mark-all-as-read": "Mark All as Read", "mark-all-as-read": "Oznacz wszystkie jako przeczytane",
"show-announcements-from-mealie": "Show announcements from Mealie", "show-announcements-from-mealie": "Pokazuj ogłoszenia z Mealie",
"show-announcements-setting-description": "Whether or not you want to allow users to see announcements from Mealie. When enabled users can still opt-out from seeing them in their user settings" "show-announcements-setting-description": "Czy chcesz by użytkownicy widzieli ogłoszenia z Mealie? Użytkownicy będą w dalszym ciągu mogli wyłączyć ogłoszenia w swoich ustawieniach użytkownika"
} }
} }

View File

@@ -943,7 +943,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Ali res ne želite izbrati vseh elementov?", "are-you-sure-you-want-to-uncheck-all-items": "Ali res ne želite izbrati vseh elementov?",
"are-you-sure-you-want-to-delete-checked-items": "Ali ste prepričani, da želite izbrisati vse izbrane elemente?", "are-you-sure-you-want-to-delete-checked-items": "Ali ste prepričani, da želite izbrisati vse izbrane elemente?",
"no-shopping-lists-found": "Ni nakupovalnih seznamov", "no-shopping-lists-found": "Ni nakupovalnih seznamov",
"item-checked-off": "Checked off {item}" "item-checked-off": "Odkljukano {item}"
}, },
"sidebar": { "sidebar": {
"all-recipes": "Vsi recepti", "all-recipes": "Vsi recepti",

View File

@@ -911,7 +911,7 @@
"all-lists": "Всі списки", "all-lists": "Всі списки",
"create-shopping-list": "Сторити список покупок", "create-shopping-list": "Сторити список покупок",
"from-recipe": "З рецепту", "from-recipe": "З рецепту",
"ingredient-of-recipe": "Ingredient of {recipe}", "ingredient-of-recipe": "Інгредієнт з {recipe}",
"list-name": "Назва списку", "list-name": "Назва списку",
"new-list": "Новий список", "new-list": "Новий список",
"quantity": "Кількість: {0}", "quantity": "Кількість: {0}",

View File

@@ -337,8 +337,16 @@ 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,.csv,.txt", acceptedFileType: ".zip",
tree: false, 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 },
],
},
],
}, },
[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 / f"{uuid4().hex}.zip" temp_path = app_dirs.TEMP_DIR.joinpath("my_zip_archive.zip")
try: try:
yield temp_path yield temp_path
finally: finally:

View File

@@ -571,8 +571,8 @@
"delicata squash": { "delicata squash": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "delicata squash", "name": "delikat squash",
"plural_name": "delicata squashes" "plural_name": "delikate squasher"
}, },
"Frisée": { "Frisée": {
"aliases": [ "aliases": [
@@ -980,7 +980,7 @@
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "kastanjepuré", "name": "kastanjepuré",
"plural_name": "chestnut purée" "plural_name": "kastanjepuré"
}, },
"prickly pear": { "prickly pear": {
"aliases": [], "aliases": [],
@@ -1045,7 +1045,7 @@
"sweet lime": { "sweet lime": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "sweet lime", "name": "søt lime",
"plural_name": "sweet limes" "plural_name": "sweet limes"
}, },
"custard-apple": { "custard-apple": {
@@ -1873,8 +1873,8 @@
"melon seed": { "melon seed": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "melon seed", "name": "melonfrø",
"plural_name": "melon seeds" "plural_name": "melonfrø"
}, },
"lotus seed": { "lotus seed": {
"aliases": [], "aliases": [],
@@ -2003,48 +2003,48 @@
"parmesan cheese": { "parmesan cheese": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "parmesan cheese", "name": "parmesanost",
"plural_name": "parmesan cheese" "plural_name": "parmesanost"
}, },
"cheddar cheese": { "cheddar cheese": {
"aliases": [ "aliases": [
"cheddar cheese" "cheddarost"
], ],
"description": "", "description": "",
"name": "cheddar cheese", "name": "cheddarost",
"plural_name": "cheddar cheese" "plural_name": "cheddarost"
}, },
"cream cheese": { "cream cheese": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "cream cheese", "name": "kremost",
"plural_name": "cream cheese" "plural_name": "kremost"
}, },
"sharp cheddar cheese": { "sharp cheddar cheese": {
"aliases": [ "aliases": [
"sharp cheddar" "skarp cheddarost"
], ],
"description": "", "description": "",
"name": "sharp cheddar cheese", "name": "skarp cheddarost",
"plural_name": "sharp cheddar cheese" "plural_name": "skarp cheddarost"
}, },
"cheese": { "cheese": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "cheese", "name": "ost",
"plural_name": "cheese" "plural_name": "ost"
}, },
"mozzarella cheese": { "mozzarella cheese": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "mozzarella cheese", "name": "mozzarellaost",
"plural_name": "mozzarella cheese" "plural_name": "mozzarellaost"
}, },
"feta cheese": { "feta cheese": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "feta cheese", "name": "fetaost",
"plural_name": "feta cheese" "plural_name": "fetaost"
}, },
"ricotta cheese": { "ricotta cheese": {
"aliases": [], "aliases": [],
@@ -2073,14 +2073,14 @@
"goat cheese": { "goat cheese": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "goat cheese", "name": "geitost",
"plural_name": "goat cheese" "plural_name": "geitost"
}, },
"fresh mozzarella cheese": { "fresh mozzarella cheese": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "fresh mozzarella cheese", "name": "fersk mozzarellaost",
"plural_name": "fresh mozzarella cheese" "plural_name": "fersk mozzarellaost"
}, },
"swis cheese": { "swis cheese": {
"aliases": [], "aliases": [],

View File

@@ -919,7 +919,7 @@
"jackfruit": { "jackfruit": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "jackfruit", "name": "owoc chlebowca",
"plural_name": "jackfruity" "plural_name": "jackfruity"
}, },
"dragon fruit": { "dragon fruit": {

View File

@@ -7,7 +7,6 @@ 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
@@ -16,23 +15,15 @@ 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"""
with tempfile.TemporaryDirectory() as tmpdir:
with zipfile.ZipFile(file) as zip_file:
zip_file.extractall(tmpdir)
Accepts a ZIP archive containing a CSV, or a raw CSV/TXT file. for name in Path(tmpdir).glob("**/[!.]*.csv"):
""" with open(name, newline="") as csvfile:
if zipfile.is_zipfile(file): reader = csv.DictReader(csvfile)
with tempfile.TemporaryDirectory() as tmpdir: yield from reader
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): def get_value_as_string_or_none(dictionary: dict, key: str):
@@ -121,32 +112,7 @@ 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

@@ -45,8 +45,6 @@ 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

@@ -1,13 +0,0 @@
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,15 +94,6 @@ 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,
@@ -133,7 +124,6 @@ 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",
@@ -200,30 +190,6 @@ 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: