Compare commits

..

8 Commits

37 changed files with 306 additions and 233 deletions

View File

@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do: We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
1. Take a backup just in case! 1. Take a backup just in case!
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.17.0` 2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.18.0`
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access. 3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
4. Restart the container 4. Restart the container

View File

@@ -10,7 +10,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
```yaml ```yaml
services: services:
mealie: mealie:
image: ghcr.io/mealie-recipes/mealie:v3.17.0 # (3) image: ghcr.io/mealie-recipes/mealie:v3.18.0 # (3)
container_name: mealie container_name: mealie
restart: always restart: always
ports: ports:

View File

@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
```yaml ```yaml
services: services:
mealie: mealie:
image: ghcr.io/mealie-recipes/mealie:v3.17.0 # (3) image: ghcr.io/mealie-recipes/mealie:v3.18.0 # (3)
container_name: mealie container_name: mealie
restart: always restart: always
ports: ports:

View File

@@ -38,7 +38,7 @@ export const LOCALES = [
{ {
name: "Svenska (Swedish)", name: "Svenska (Swedish)",
value: "sv-SE", value: "sv-SE",
progress: 74, progress: 75,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always", pluralFoodHandling: "always",
}, },
@@ -101,14 +101,14 @@ export const LOCALES = [
{ {
name: "Norsk (Norwegian)", name: "Norsk (Norwegian)",
value: "no-NO", value: "no-NO",
progress: 59, progress: 60,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always", pluralFoodHandling: "always",
}, },
{ {
name: "Nederlands (Dutch)", name: "Nederlands (Dutch)",
value: "nl-NL", value: "nl-NL",
progress: 97, progress: 98,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always", pluralFoodHandling: "always",
}, },

View File

@@ -51,7 +51,7 @@
"category": "Kategoria" "category": "Kategoria"
}, },
"events": { "events": {
"apprise-url": "Apprise URL", "apprise-url": "Apprise-url",
"database": "Tietokanta", "database": "Tietokanta",
"delete-event": "Poista tapahtuma", "delete-event": "Poista tapahtuma",
"event-delete-confirmation": "Haluatko varmasti poistaa tämän tapahtuman?", "event-delete-confirmation": "Haluatko varmasti poistaa tämän tapahtuman?",
@@ -98,7 +98,7 @@
"dashboard": "Hallintanäkymä", "dashboard": "Hallintanäkymä",
"delete": "Poista", "delete": "Poista",
"disabled": "Poistettu käytöstä", "disabled": "Poistettu käytöstä",
"done": "Done", "done": "Valmis",
"download": "Lataa", "download": "Lataa",
"duplicate": "Monista", "duplicate": "Monista",
"edit": "Muokkaa", "edit": "Muokkaa",
@@ -169,7 +169,7 @@
"token": "Tunniste", "token": "Tunniste",
"tuesday": "Tiistai", "tuesday": "Tiistai",
"type": "Tyyppi", "type": "Tyyppi",
"undo": "Undo", "undo": "Peru",
"update": "Päivitä", "update": "Päivitä",
"updated": "Päivitetty", "updated": "Päivitetty",
"upload": "Lähetä", "upload": "Lähetä",
@@ -333,8 +333,8 @@
"any-household": "Mikä tahansa kotitalous", "any-household": "Mikä tahansa kotitalous",
"no-meal-plan-defined-yet": "Ateriasuunnitelmaa ei ole vielä määritelty", "no-meal-plan-defined-yet": "Ateriasuunnitelmaa ei ole vielä määritelty",
"no-meal-planned-for-today": "Ei ateriasuunnitelmaa tälle päivälle", "no-meal-planned-for-today": "Ei ateriasuunnitelmaa tälle päivälle",
"numberOfDaysPast-hint": "Number of days in the past on page load", "numberOfDaysPast-hint": "Menneisyydestä ladattujen päivien määrä",
"numberOfDaysPast-label": "Default Days in the Past", "numberOfDaysPast-label": "Oletusarvo menneiden päivien lataukselle",
"numberOfDays-hint": "Sivun latauspäivien lukumäärä", "numberOfDays-hint": "Sivun latauspäivien lukumäärä",
"numberOfDays-label": "Oletuspäivät", "numberOfDays-label": "Oletuspäivät",
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Vain näiden luokkien reseptejä käytetään ateriasuunnitelmissa", "only-recipes-with-these-categories-will-be-used-in-meal-plans": "Vain näiden luokkien reseptejä käytetään ateriasuunnitelmissa",
@@ -392,7 +392,7 @@
"nextcloud": { "nextcloud": {
"description": "Tuo tiedot Nextcloudin Cookbookista", "description": "Tuo tiedot Nextcloudin Cookbookista",
"description-long": "Nextcloud reseptejä voidaan tuoda zip-tiedostosta, joka sisältää Nextcloudin tallennetut tiedot. Katso esimerkkikansiorakenne alla varmistaaksesi, että reseptisi voidaan tuoda.", "description-long": "Nextcloud reseptejä voidaan tuoda zip-tiedostosta, joka sisältää Nextcloudin tallennetut tiedot. Katso esimerkkikansiorakenne alla varmistaaksesi, että reseptisi voidaan tuoda.",
"title": "Nextcloud Cookbook" "title": "Nextcloud-keittokirja"
}, },
"copymethat": { "copymethat": {
"description-long": "Mealie voi tuoda reseptejä Copy Me That -sovelluksesta. Vie reseptisi HTML-muodossa ja lataa sitten zip-tiedosto.", "description-long": "Mealie voi tuoda reseptejä Copy Me That -sovelluksesta. Vie reseptisi HTML-muodossa ja lataa sitten zip-tiedosto.",
@@ -702,7 +702,7 @@
"confidence-score": "Varmuuspisteet", "confidence-score": "Varmuuspisteet",
"ingredient-parser-description": "Ainesosat on haettu onnistuneesti. Ole hyvä ja tarkista ainesosat joista emme ole varmoja.", "ingredient-parser-description": "Ainesosat on haettu onnistuneesti. Ole hyvä ja tarkista ainesosat joista emme ole varmoja.",
"ingredient-parser-final-review-description": "Kun kaikki ainesosat on tarkistettu, sinulla on vielä yksi mahdollisuus tarkistaa kaikki ainesosat ennen kuin muokkaat reseptiäsi.", "ingredient-parser-final-review-description": "Kun kaikki ainesosat on tarkistettu, sinulla on vielä yksi mahdollisuus tarkistaa kaikki ainesosat ennen kuin muokkaat reseptiäsi.",
"add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}", "add-text-as-alias-for-item": "Lisää \"{text}\" kohteen {item} aliakseksi",
"delete-item": "Poista kohde" "delete-item": "Poista kohde"
}, },
"reset-servings-count": "Palauta Annoksien Määrä", "reset-servings-count": "Palauta Annoksien Määrä",
@@ -893,17 +893,17 @@
"server-side-base-url-error-text": "`BASE_URL` on API-palvelimen oletusarvo. Tämä aiheuttaa ongelmia ilmoitusten linkkien kanssa, jotka on luotu palvelimella sähköposteja varten jne.", "server-side-base-url-error-text": "`BASE_URL` on API-palvelimen oletusarvo. Tämä aiheuttaa ongelmia ilmoitusten linkkien kanssa, jotka on luotu palvelimella sähköposteja varten jne.",
"server-side-base-url-success-text": "Palvelimen nettiosoite ei vastaa oletusta", "server-side-base-url-success-text": "Palvelimen nettiosoite ei vastaa oletusta",
"ldap-ready": "LDAP Valmis", "ldap-ready": "LDAP Valmis",
"ldap-not-ready": "LDAP Not Ready", "ldap-not-ready": "LDAP ei valmis",
"ldap-ready-error-text": "Kaikkia LDAP-arvoja ei ole määritetty. Tämä voidaan ohittaa, jos et käytä LDAP-todennusta.", "ldap-ready-error-text": "Kaikkia LDAP-arvoja ei ole määritetty. Tämä voidaan ohittaa, jos et käytä LDAP-todennusta.",
"ldap-ready-success-text": "Kaikki vaaditut LDAP-muuttujat on asetettu.", "ldap-ready-success-text": "Kaikki vaaditut LDAP-muuttujat on asetettu.",
"build": "Koonti", "build": "Koonti",
"recipe-scraper-version": "Reseptikaappaimen versio", "recipe-scraper-version": "Reseptikaappaimen versio",
"oidc-ready": "OIDC valmis", "oidc-ready": "OIDC valmis",
"oidc-not-ready": "OIDC Not Ready", "oidc-not-ready": "OIDC ei ole valmis",
"oidc-ready-error-text": "Kaikkia OIDC-arvoja ei ole määritelty. Jos et käytä OIDC-todennusta, voidaan asia jättää huomiotta.", "oidc-ready-error-text": "Kaikkia OIDC-arvoja ei ole määritelty. Jos et käytä OIDC-todennusta, voidaan asia jättää huomiotta.",
"oidc-ready-success-text": "Kaikki vaaditut OIDC-muuttujat asetettu.", "oidc-ready-success-text": "Kaikki vaaditut OIDC-muuttujat asetettu.",
"openai-ready": "OpenAI valmis", "openai-ready": "OpenAI valmis",
"openai-not-ready": "OpenAI Not Ready", "openai-not-ready": "OpenAI ei ole valmis",
"openai-ready-error-text": "Kaikkia OpenAI:n arvoja ei ole määritelty. Tämä voidaan sivuuttaa, mikäli et käytä OpenAI:n toimintoja.", "openai-ready-error-text": "Kaikkia OpenAI:n arvoja ei ole määritelty. Tämä voidaan sivuuttaa, mikäli et käytä OpenAI:n toimintoja.",
"openai-ready-success-text": "Vaadittavat OpenAI-muuttujat ovat asetetut." "openai-ready-success-text": "Vaadittavat OpenAI-muuttujat ovat asetetut."
}, },
@@ -917,7 +917,7 @@
"quantity": "Määrä: {0}", "quantity": "Määrä: {0}",
"shopping-list": "Ostoslista", "shopping-list": "Ostoslista",
"shopping-lists": "Ostoslistat", "shopping-lists": "Ostoslistat",
"add-item": "Add item", "add-item": "Lisää kohde",
"food": "Elintarvikkeet", "food": "Elintarvikkeet",
"note": "Muistiinpano", "note": "Muistiinpano",
"label": "Tunnus", "label": "Tunnus",
@@ -962,7 +962,7 @@
"language": "Kieli", "language": "Kieli",
"maintenance": "Ylläpito", "maintenance": "Ylläpito",
"background-tasks": "Taustatehtävät", "background-tasks": "Taustatehtävät",
"parser": "Parser", "parser": "Jäsentäjä",
"developer": "Kehittäjä", "developer": "Kehittäjä",
"cookbook": "Keittokirja", "cookbook": "Keittokirja",
"create-cookbook": "Luo uusi keittokirja" "create-cookbook": "Luo uusi keittokirja"
@@ -1351,7 +1351,7 @@
"ingredient-text": "Ainesosan Teksti", "ingredient-text": "Ainesosan Teksti",
"average-confident": "{0} Luottamus", "average-confident": "{0} Luottamus",
"try-an-example": "Kokeile esimerkkiä", "try-an-example": "Kokeile esimerkkiä",
"parser": "Parser", "parser": "Jäsentäjä",
"background-tasks": "Taustatehtävät", "background-tasks": "Taustatehtävät",
"background-tasks-description": "Täältä voit tarkastella kaikkia käynnissä olevia taustatehtäviä ja niiden tilaa", "background-tasks-description": "Täältä voit tarkastella kaikkia käynnissä olevia taustatehtäviä ja niiden tilaa",
"no-logs-found": "Lokeja Ei Löytynyt", "no-logs-found": "Lokeja Ei Löytynyt",
@@ -1481,7 +1481,7 @@
"announcements": "Announcements", "announcements": "Announcements",
"all-announcements": "All announcements", "all-announcements": "All announcements",
"mark-all-as-read": "Mark All as Read", "mark-all-as-read": "Mark All as Read",
"show-announcements-from-mealie": "Show announcements from Mealie", "show-announcements-from-mealie": "Näytä Mealien ilmoitukset",
"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

@@ -943,7 +943,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Biztos, hogy minden elem kijelölését visszavonja?", "are-you-sure-you-want-to-uncheck-all-items": "Biztos, hogy minden elem kijelölését visszavonja?",
"are-you-sure-you-want-to-delete-checked-items": "Biztosan törölni akarja az összes bejelölt elemet?", "are-you-sure-you-want-to-delete-checked-items": "Biztosan törölni akarja az összes bejelölt elemet?",
"no-shopping-lists-found": "Nem találhatók bevásárlólisták", "no-shopping-lists-found": "Nem találhatók bevásárlólisták",
"item-checked-off": "Checked off {item}" "item-checked-off": "{item} leellenőrzve"
}, },
"sidebar": { "sidebar": {
"all-recipes": "Minden recept", "all-recipes": "Minden recept",

View File

@@ -333,8 +333,8 @@
"any-household": "Öll heimili", "any-household": "Öll heimili",
"no-meal-plan-defined-yet": "Ekkert matarplan hefur verið skilgreint", "no-meal-plan-defined-yet": "Ekkert matarplan hefur verið skilgreint",
"no-meal-planned-for-today": "Ekkert matarplan skipulagt í dag", "no-meal-planned-for-today": "Ekkert matarplan skipulagt í dag",
"numberOfDaysPast-hint": "Number of days in the past on page load", "numberOfDaysPast-hint": "Fjöldi liðina daga við síðuhleðslu",
"numberOfDaysPast-label": "Default Days in the Past", "numberOfDaysPast-label": "Sjálfgefnir liðnir dagar",
"numberOfDays-hint": "Fjöldi daga við síðuhleðslu", "numberOfDays-hint": "Fjöldi daga við síðuhleðslu",
"numberOfDays-label": "Sjálfgefnir dagar", "numberOfDays-label": "Sjálfgefnir dagar",
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Aðeins uppskriftir í þessum flokkum verða notaðir í matarplan", "only-recipes-with-these-categories-will-be-used-in-meal-plans": "Aðeins uppskriftir í þessum flokkum verða notaðir í matarplan",
@@ -640,8 +640,8 @@
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Stofnaðu uppskrift með því að gefa henni nafn, allar uppskriftir þurfa að hafa einstakt nafn.", "create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Stofnaðu uppskrift með því að gefa henni nafn, allar uppskriftir þurfa að hafa einstakt nafn.",
"new-recipe-names-must-be-unique": "Nöfn uppskrifta þurfa að vera einstök", "new-recipe-names-must-be-unique": "Nöfn uppskrifta þurfa að vera einstök",
"scrape-recipe": "Vinna uppskrift", "scrape-recipe": "Vinna uppskrift",
"scrape-recipe-description": "Scrape a recipe by url. Provide the url for the site you want to scrape, and Mealie will attempt to scrape the recipe from that site and add it to your collection.", "scrape-recipe-description": "Sækja uppskrift af vefslóð. Settu inn vefslóð fyrir síðuna þar sem þú vilt sækja uppskrift og Mealie mun reyna að vinna uppskriftina þaðan og bæta henni við safnið þitt.",
"scrape-recipe-description-transcription": "You can also provide the url to a video and Mealie will attempt to transcribe it into a recipe.", "scrape-recipe-description-transcription": "Þú getur einnig sett inn slóð á video og Mealie mun reyna að umrita það yfir í uppskrift.",
"scrape-recipe-have-a-lot-of-recipes": "Ertu með margar uppskriftir sem þú villt setja inn í einu?", "scrape-recipe-have-a-lot-of-recipes": "Ertu með margar uppskriftir sem þú villt setja inn í einu?",
"scrape-recipe-suggest-bulk-importer": "Prófaðu að setja inn margar uppskriftir í einu", "scrape-recipe-suggest-bulk-importer": "Prófaðu að setja inn margar uppskriftir í einu",
"scrape-recipe-have-raw-html-or-json-data": "Ertu með hrá HTML eða JSON gögn?", "scrape-recipe-have-raw-html-or-json-data": "Ertu með hrá HTML eða JSON gögn?",
@@ -893,17 +893,17 @@
"server-side-base-url-error-text": "'BASE_URL' er enn sjálfgefið gildi á API netþjóns. Þetta getur valdið vandræðum með tilkynninga tengla sem netþjónninn býr til fyrir tölvupósta og annað.", "server-side-base-url-error-text": "'BASE_URL' er enn sjálfgefið gildi á API netþjóns. Þetta getur valdið vandræðum með tilkynninga tengla sem netþjónninn býr til fyrir tölvupósta og annað.",
"server-side-base-url-success-text": "Slóð netþjóns samsvarar ekki sjálfgefnu gildi", "server-side-base-url-success-text": "Slóð netþjóns samsvarar ekki sjálfgefnu gildi",
"ldap-ready": "LDAP klár", "ldap-ready": "LDAP klár",
"ldap-not-ready": "LDAP Not Ready", "ldap-not-ready": "LDAP er ekki tilbúið",
"ldap-ready-error-text": "Ekki öll LDAP-gildi eru stillt. Þetta má hunsa ef þú notar ekki LDAP-auðkenningu.", "ldap-ready-error-text": "Ekki öll LDAP-gildi eru stillt. Þetta má hunsa ef þú notar ekki LDAP-auðkenningu.",
"ldap-ready-success-text": "Öll nauðsynleg LDAP-gildi eru stillt.", "ldap-ready-success-text": "Öll nauðsynleg LDAP-gildi eru stillt.",
"build": "Build", "build": "Build",
"recipe-scraper-version": "Recipe Scraper útgáfa", "recipe-scraper-version": "Recipe Scraper útgáfa",
"oidc-ready": "OIDC klár", "oidc-ready": "OIDC klár",
"oidc-not-ready": "OIDC Not Ready", "oidc-not-ready": "OIDC er ekki tilbúið",
"oidc-ready-error-text": "Ekki öll OIDC gildi eru stillt. Þetta má hunsa ef þú notar ekki OIDC-auðkenningu.", "oidc-ready-error-text": "Ekki öll OIDC gildi eru stillt. Þetta má hunsa ef þú notar ekki OIDC-auðkenningu.",
"oidc-ready-success-text": "Öll nauðsynleg OIDC-gildi eru stillt.", "oidc-ready-success-text": "Öll nauðsynleg OIDC-gildi eru stillt.",
"openai-ready": "OpenAI klár", "openai-ready": "OpenAI klár",
"openai-not-ready": "OpenAI Not Ready", "openai-not-ready": "OpenAI er ekki tilbúið",
"openai-ready-error-text": "Ekki öll OpenAI gildi eru stillt. Þetta má hunsa ef þú notar ekki OpenAI.", "openai-ready-error-text": "Ekki öll OpenAI gildi eru stillt. Þetta má hunsa ef þú notar ekki OpenAI.",
"openai-ready-success-text": "Öll nauðsynleg OpenAI-gildi eru stillt." "openai-ready-success-text": "Öll nauðsynleg OpenAI-gildi eru stillt."
}, },
@@ -917,7 +917,7 @@
"quantity": "Fjöldi: {0}", "quantity": "Fjöldi: {0}",
"shopping-list": "Innkaupalisti", "shopping-list": "Innkaupalisti",
"shopping-lists": "Innkaupalistar", "shopping-lists": "Innkaupalistar",
"add-item": "Add item", "add-item": "Bæta við vöru",
"food": "Matvara", "food": "Matvara",
"note": "Minnispunktur", "note": "Minnispunktur",
"label": "Merkimiði", "label": "Merkimiði",

View File

@@ -51,7 +51,7 @@
"category": "Categorie" "category": "Categorie"
}, },
"events": { "events": {
"apprise-url": "Apprise URL", "apprise-url": "Kennisgevings-url",
"database": "Database", "database": "Database",
"delete-event": "Gebeurtenis verwijderen", "delete-event": "Gebeurtenis verwijderen",
"event-delete-confirmation": "Weet je zeker dat je deze gebeurtenis wilt verwijderen?", "event-delete-confirmation": "Weet je zeker dat je deze gebeurtenis wilt verwijderen?",
@@ -98,7 +98,7 @@
"dashboard": "Dashboard", "dashboard": "Dashboard",
"delete": "Verwijderen", "delete": "Verwijderen",
"disabled": "Uitgeschakeld", "disabled": "Uitgeschakeld",
"done": "Done", "done": "Gereed",
"download": "Downloaden", "download": "Downloaden",
"duplicate": "Dupliceren", "duplicate": "Dupliceren",
"edit": "Bewerken", "edit": "Bewerken",
@@ -169,7 +169,7 @@
"token": "Token", "token": "Token",
"tuesday": "dinsdag", "tuesday": "dinsdag",
"type": "Soort", "type": "Soort",
"undo": "Undo", "undo": "Ongedaan maken",
"update": "Bijwerken", "update": "Bijwerken",
"updated": "Bijgewerkt", "updated": "Bijgewerkt",
"upload": "Uploaden", "upload": "Uploaden",
@@ -333,8 +333,8 @@
"any-household": "Elk huishouden", "any-household": "Elk huishouden",
"no-meal-plan-defined-yet": "Nog geen maaltijdplan opgesteld", "no-meal-plan-defined-yet": "Nog geen maaltijdplan opgesteld",
"no-meal-planned-for-today": "Geen maaltijd gepland voor vandaag", "no-meal-planned-for-today": "Geen maaltijd gepland voor vandaag",
"numberOfDaysPast-hint": "Number of days in the past on page load", "numberOfDaysPast-hint": "Aantal dagen in het verleden bij laden pagina",
"numberOfDaysPast-label": "Default Days in the Past", "numberOfDaysPast-label": "Standaard dagen in het verleden",
"numberOfDays-hint": "Aantal dagen bij laden van de pagina", "numberOfDays-hint": "Aantal dagen bij laden van de pagina",
"numberOfDays-label": "Standaard aantal dagen", "numberOfDays-label": "Standaard aantal dagen",
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Alleen recepten met deze categorieën zullen worden gebruikt in maaltijdplannen", "only-recipes-with-these-categories-will-be-used-in-meal-plans": "Alleen recepten met deze categorieën zullen worden gebruikt in maaltijdplannen",
@@ -443,7 +443,7 @@
"error-details": "Alleen websites met ld+json of microdata kunnen worden geïmporteerd door Mealie. De meeste grote receptenwebsites ondersteunen deze gegevensstructuur. Als je site niet kan worden geïmporteerd, maar er zijn json-gegevens in de log, maak dan een github issue aan met de URL en gegevens.", "error-details": "Alleen websites met ld+json of microdata kunnen worden geïmporteerd door Mealie. De meeste grote receptenwebsites ondersteunen deze gegevensstructuur. Als je site niet kan worden geïmporteerd, maar er zijn json-gegevens in de log, maak dan een github issue aan met de URL en gegevens.",
"error-title": "Het lijkt erop dat we niets konden vinden", "error-title": "Het lijkt erop dat we niets konden vinden",
"from-url": "Recept importeren", "from-url": "Recept importeren",
"github-issues": "GitHub Issues", "github-issues": "GitHubproblemen",
"google-ld-json-info": "Google ld+json Info", "google-ld-json-info": "Google ld+json Info",
"must-be-a-valid-url": "Moet een geldige URL zijn", "must-be-a-valid-url": "Moet een geldige URL zijn",
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Plak je receptgegevens. Elke regel wordt behandeld als een item in een lijst", "paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Plak je receptgegevens. Elke regel wordt behandeld als een item in een lijst",
@@ -893,17 +893,17 @@
"server-side-base-url-error-text": "`BASE_URL` is nog steeds de standaard waarde op de API Server. Dit geeft problemen met notificatielinks in e-mails etc.", "server-side-base-url-error-text": "`BASE_URL` is nog steeds de standaard waarde op de API Server. Dit geeft problemen met notificatielinks in e-mails etc.",
"server-side-base-url-success-text": "Server-side URL komt niet overeen met de standaard", "server-side-base-url-success-text": "Server-side URL komt niet overeen met de standaard",
"ldap-ready": "LDAP klaar", "ldap-ready": "LDAP klaar",
"ldap-not-ready": "LDAP Not Ready", "ldap-not-ready": "LDAP niet gereed",
"ldap-ready-error-text": "Niet alle LDAP-waarden zijn geconfigureerd. Dit kan worden genegeerd als je geen LDAP-authenticatie gebruikt.", "ldap-ready-error-text": "Niet alle LDAP-waarden zijn geconfigureerd. Dit kan worden genegeerd als je geen LDAP-authenticatie gebruikt.",
"ldap-ready-success-text": "Vereiste LDAP variabelen zijn helemaal ingesteld.", "ldap-ready-success-text": "Vereiste LDAP variabelen zijn helemaal ingesteld.",
"build": "Build", "build": "Build",
"recipe-scraper-version": "Versie van de receptenscraper", "recipe-scraper-version": "Versie van de receptenscraper",
"oidc-ready": "OIDC klaar", "oidc-ready": "OIDC klaar",
"oidc-not-ready": "OIDC Not Ready", "oidc-not-ready": "OIDC niet gereed",
"oidc-ready-error-text": "Niet alle OIDC-waarden zijn geconfigureerd. Dit kan worden genegeerd als je geen OIDC-authenticatie gebruikt.", "oidc-ready-error-text": "Niet alle OIDC-waarden zijn geconfigureerd. Dit kan worden genegeerd als je geen OIDC-authenticatie gebruikt.",
"oidc-ready-success-text": "Vereiste OIDC-variabelen zijn allemaal ingesteld.", "oidc-ready-success-text": "Vereiste OIDC-variabelen zijn allemaal ingesteld.",
"openai-ready": "OpenAI staat klaar", "openai-ready": "OpenAI staat klaar",
"openai-not-ready": "OpenAI Not Ready", "openai-not-ready": "OpenAI niet gereed",
"openai-ready-error-text": "Niet alle tekstvakken voor OpenAI zijn ingevuld. Als je geen OpenAI gebruikt kun je dit leeg laten.", "openai-ready-error-text": "Niet alle tekstvakken voor OpenAI zijn ingevuld. Als je geen OpenAI gebruikt kun je dit leeg laten.",
"openai-ready-success-text": "Verplichte tekstvakken voor OpenAI zijn ingevuld." "openai-ready-success-text": "Verplichte tekstvakken voor OpenAI zijn ingevuld."
}, },
@@ -917,7 +917,7 @@
"quantity": "Hoeveelheid: {0}", "quantity": "Hoeveelheid: {0}",
"shopping-list": "Boodschappenlijst", "shopping-list": "Boodschappenlijst",
"shopping-lists": "Boodschappenlijsten", "shopping-lists": "Boodschappenlijsten",
"add-item": "Add item", "add-item": "Item toevoegen",
"food": "Levensmiddelen", "food": "Levensmiddelen",
"note": "Notitie", "note": "Notitie",
"label": "Label", "label": "Label",
@@ -943,7 +943,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Weet je zeker dat je alle items wilt deselecteren?", "are-you-sure-you-want-to-uncheck-all-items": "Weet je zeker dat je alle items wilt deselecteren?",
"are-you-sure-you-want-to-delete-checked-items": "Weet je zeker dat je de geselecteerde items wilt verwijderen?", "are-you-sure-you-want-to-delete-checked-items": "Weet je zeker dat je de geselecteerde items wilt verwijderen?",
"no-shopping-lists-found": "Geen boodschappenlijsten gevonden", "no-shopping-lists-found": "Geen boodschappenlijsten gevonden",
"item-checked-off": "Checked off {item}" "item-checked-off": "Uitgevinkt {item}"
}, },
"sidebar": { "sidebar": {
"all-recipes": "Alle Recepten", "all-recipes": "Alle Recepten",
@@ -1283,7 +1283,7 @@
"split-by-block": "Splits per tekstblok", "split-by-block": "Splits per tekstblok",
"flatten": "Plat maken ongeacht originele opmaak", "flatten": "Plat maken ongeacht originele opmaak",
"help": { "help": {
"help": "Help", "help": "Hulp",
"mouse-modes": "Muismodus", "mouse-modes": "Muismodus",
"selection-mode": "Selectiemodus (standaard)", "selection-mode": "Selectiemodus (standaard)",
"selection-mode-desc": "De selectiemodus is de hoofdmodus die gebruikt kan worden om gegevens in te voeren:", "selection-mode-desc": "De selectiemodus is de hoofdmodus die gebruikt kan worden om gegevens in te voeren:",
@@ -1478,10 +1478,10 @@
"max-length": "Moet maximaal {max} tekens bevatten|Moet maximaal {max} tekens bevatten" "max-length": "Moet maximaal {max} tekens bevatten|Moet maximaal {max} tekens bevatten"
}, },
"announcements": { "announcements": {
"announcements": "Announcements", "announcements": "Aankondigingen",
"all-announcements": "All announcements", "all-announcements": "Alle aankondigingen",
"mark-all-as-read": "Mark All as Read", "mark-all-as-read": "Alles markeren als gelezen",
"show-announcements-from-mealie": "Show announcements from Mealie", "show-announcements-from-mealie": "Aankondigingen van Mealie weergeven",
"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": "Of je gebruikers wel of niet meldingen van Mealie wilt laten zien. Wanneer ingeschakeld kunnen gebruikers nog steeds afzien van het bekijken van hen in hun gebruikersinstellingen"
} }
} }

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

@@ -169,7 +169,7 @@
"token": "Token", "token": "Token",
"tuesday": "Tisdag", "tuesday": "Tisdag",
"type": "Typ", "type": "Typ",
"undo": "Undo", "undo": "Ångra",
"update": "Uppdatera", "update": "Uppdatera",
"updated": "Uppdaterad", "updated": "Uppdaterad",
"upload": "Ladda upp", "upload": "Ladda upp",
@@ -333,8 +333,8 @@
"any-household": "Valfritt hushåll", "any-household": "Valfritt hushåll",
"no-meal-plan-defined-yet": "Ingen måltidsplan definierad ännu", "no-meal-plan-defined-yet": "Ingen måltidsplan definierad ännu",
"no-meal-planned-for-today": "Ingen måltidsplan för idag", "no-meal-planned-for-today": "Ingen måltidsplan för idag",
"numberOfDaysPast-hint": "Number of days in the past on page load", "numberOfDaysPast-hint": "Antal förflutna dagar vid sidhämtning",
"numberOfDaysPast-label": "Default Days in the Past", "numberOfDaysPast-label": "Förvalda förflutna dagar",
"numberOfDays-hint": "Antal dagar vid sidhämtning", "numberOfDays-hint": "Antal dagar vid sidhämtning",
"numberOfDays-label": "Förvalda dagar", "numberOfDays-label": "Förvalda dagar",
"only-recipes-with-these-categories-will-be-used-in-meal-plans": "Endast recept med dessa kategorier kommer att användas i måltidsplaner", "only-recipes-with-these-categories-will-be-used-in-meal-plans": "Endast recept med dessa kategorier kommer att användas i måltidsplaner",
@@ -812,7 +812,7 @@
"settings-updated": "Inställningar uppdaterade", "settings-updated": "Inställningar uppdaterade",
"site-settings": "Systeminställningar", "site-settings": "Systeminställningar",
"theme": { "theme": {
"accent": "Accent", "accent": "Accentfärg",
"dark": "Mörkt", "dark": "Mörkt",
"default-to-system": "Standard", "default-to-system": "Standard",
"error": "Fel", "error": "Fel",
@@ -893,17 +893,17 @@
"server-side-base-url-error-text": "`BASE_URL` är fortfarande standardvärdet på API-servern. Detta kommer att orsaka problem med meddelanden som genereras på servern för e-postmeddelanden, etc.", "server-side-base-url-error-text": "`BASE_URL` är fortfarande standardvärdet på API-servern. Detta kommer att orsaka problem med meddelanden som genereras på servern för e-postmeddelanden, etc.",
"server-side-base-url-success-text": "Serversidans URL matchar inte standard", "server-side-base-url-success-text": "Serversidans URL matchar inte standard",
"ldap-ready": "LDAP Redo", "ldap-ready": "LDAP Redo",
"ldap-not-ready": "LDAP Not Ready", "ldap-not-ready": "LDAP ej tillgängligt",
"ldap-ready-error-text": "Alla LDAP-värden är inte konfigurerade. Detta kan ignoreras om du inte använder LDAP-autentisering.", "ldap-ready-error-text": "Alla LDAP-värden är inte konfigurerade. Detta kan ignoreras om du inte använder LDAP-autentisering.",
"ldap-ready-success-text": "Alla obligatoriska LDAP-variabler är satta.", "ldap-ready-success-text": "Alla obligatoriska LDAP-variabler är satta.",
"build": "Bygge", "build": "Bygge",
"recipe-scraper-version": "Version av Recept-scraper", "recipe-scraper-version": "Version av Recept-scraper",
"oidc-ready": "OIDC Klar", "oidc-ready": "OIDC Klar",
"oidc-not-ready": "OIDC Not Ready", "oidc-not-ready": "OIDC ej tillgängligt",
"oidc-ready-error-text": "Alla OIDC-värden är inte konfigurerade. Detta kan ignoreras om du inte använder OIDC-autentisering.", "oidc-ready-error-text": "Alla OIDC-värden är inte konfigurerade. Detta kan ignoreras om du inte använder OIDC-autentisering.",
"oidc-ready-success-text": "Alla obligatoriska OIDC-variabler är satta.", "oidc-ready-success-text": "Alla obligatoriska OIDC-variabler är satta.",
"openai-ready": "OpenAI redo", "openai-ready": "OpenAI redo",
"openai-not-ready": "OpenAI Not Ready", "openai-not-ready": "OpenAI ej tillgängligt",
"openai-ready-error-text": "Alla OpenAI-värden är inte konfigurerade. Detta kan ignoreras om du inte använder OpenAI-funktioner.", "openai-ready-error-text": "Alla OpenAI-värden är inte konfigurerade. Detta kan ignoreras om du inte använder OpenAI-funktioner.",
"openai-ready-success-text": "Alla obligatoriska OpenAI-variabler är satta." "openai-ready-success-text": "Alla obligatoriska OpenAI-variabler är satta."
}, },
@@ -917,7 +917,7 @@
"quantity": "Antal {0}", "quantity": "Antal {0}",
"shopping-list": "Inköpslista", "shopping-list": "Inköpslista",
"shopping-lists": "Inköpslistor", "shopping-lists": "Inköpslistor",
"add-item": "Add item", "add-item": "Lägg till vara",
"food": "Mat", "food": "Mat",
"note": "Anteckning", "note": "Anteckning",
"label": "Etikett", "label": "Etikett",
@@ -943,7 +943,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Är du säker på att du vill avmarkera alla objekt?", "are-you-sure-you-want-to-uncheck-all-items": "Är du säker på att du vill avmarkera alla objekt?",
"are-you-sure-you-want-to-delete-checked-items": "Är du säker på att du vill ta bort alla markerade objekt?", "are-you-sure-you-want-to-delete-checked-items": "Är du säker på att du vill ta bort alla markerade objekt?",
"no-shopping-lists-found": "Inga inköpslistor hittades", "no-shopping-lists-found": "Inga inköpslistor hittades",
"item-checked-off": "Checked off {item}" "item-checked-off": "Kryssat av {item}"
}, },
"sidebar": { "sidebar": {
"all-recipes": "Recept", "all-recipes": "Recept",
@@ -1478,10 +1478,10 @@
"max-length": "Måste Vara Som Mest {max} Tecken|Måste Vara Som Mest {max} Tecken" "max-length": "Måste Vara Som Mest {max} Tecken|Måste Vara Som Mest {max} Tecken"
}, },
"announcements": { "announcements": {
"announcements": "Announcements", "announcements": "Meddelanden",
"all-announcements": "All announcements", "all-announcements": "Alla meddelanden",
"mark-all-as-read": "Mark All as Read", "mark-all-as-read": "Markera alla som lästa",
"show-announcements-from-mealie": "Show announcements from Mealie", "show-announcements-from-mealie": "Visa meddelanden från 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": "Om du vill tillåta användare att se meddelanden från Mealie eller inte. När funktionen är aktiverad kan användarna fortfarande välja att inte se dem i sina användarinställningar"
} }
} }

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

@@ -1,6 +1,6 @@
{ {
"name": "mealie", "name": "mealie",
"version": "3.17.0", "version": "3.18.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "nuxt dev", "dev": "nuxt dev",

View File

@@ -35,7 +35,7 @@ class OpenIDProvider(AuthProvider[UserInfo]):
self._logger.debug("[OIDC] %s: %s", key, value) self._logger.debug("[OIDC] %s: %s", key, value)
if not self.required_claims.issubset(claims.keys()): if not self.required_claims.issubset(claims.keys()):
self._logger.error( self._logger.debug(
"[OIDC] Required claims not present. Expected: %s Actual: %s", "[OIDC] Required claims not present. Expected: %s Actual: %s",
self.required_claims, self.required_claims,
claims.keys(), claims.keys(),
@@ -45,7 +45,7 @@ class OpenIDProvider(AuthProvider[UserInfo]):
# Check for empty required claims # Check for empty required claims
for claim in self.required_claims: for claim in self.required_claims:
if not claims.get(claim): if not claims.get(claim):
self._logger.error("[OIDC] Required claim '%s' is empty", claim) self._logger.debug("[OIDC] Required claim '%s' is empty", claim)
raise MissingClaimException() raise MissingClaimException()
repos = get_repositories(self.session, group_id=None, household_id=None) repos = get_repositories(self.session, group_id=None, household_id=None)

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

@@ -23,7 +23,7 @@
"create-progress": { "create-progress": {
"creating-recipe-with-ai": "Recept maken met AI...", "creating-recipe-with-ai": "Recept maken met AI...",
"creating-recipe-from-transcript-with-ai": "Maak recept van een transcript met AI...", "creating-recipe-from-transcript-with-ai": "Maak recept van een transcript met AI...",
"creating-recipe-from-webpage-data": "Creëren recept van webpagina-gegevens...", "creating-recipe-from-webpage-data": "Creëren recept van webpaginagegevens...",
"downloading-image": "Afbeeldingen downloaden...", "downloading-image": "Afbeeldingen downloaden...",
"downloading-video": "Video downloaden...", "downloading-video": "Video downloaden...",
"extracting-recipe-data": "Receptgegevens ophalen...", "extracting-recipe-data": "Receptgegevens ophalen...",

View File

@@ -340,7 +340,7 @@
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "acelga", "name": "acelga",
"plural_name": "chard" "plural_name": "acelga"
}, },
"pimiento": { "pimiento": {
"aliases": [], "aliases": [],
@@ -979,8 +979,8 @@
"chestnut purée": { "chestnut purée": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "chestnut purée", "name": "puré de castañas",
"plural_name": "chestnut purée" "plural_name": "puré de castañas"
}, },
"prickly pear": { "prickly pear": {
"aliases": [], "aliases": [],
@@ -1295,7 +1295,7 @@
"black fungu": { "black fungu": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "black fungus", "name": "hongos negros",
"plural_name": "hongos negros" "plural_name": "hongos negros"
}, },
"black truffle": { "black truffle": {
@@ -1361,7 +1361,7 @@
"white fungu": { "white fungu": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "white fungus", "name": "hongos blancos",
"plural_name": "hongos blancos" "plural_name": "hongos blancos"
}, },
"pioppini": { "pioppini": {
@@ -1373,7 +1373,7 @@
"snow fungu": { "snow fungu": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "snow fungus", "name": "hongos de nieve",
"plural_name": "hongos de nieve" "plural_name": "hongos de nieve"
}, },
"white beech mushroom": { "white beech mushroom": {

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

@@ -134,6 +134,7 @@ async def oauth_callback(request: Request, session: Session = Depends(generate_s
auth_provider = OpenIDProvider(session, userinfo, use_default_groups=True) auth_provider = OpenIDProvider(session, userinfo, use_default_groups=True)
auth = auth_provider.authenticate() auth = auth_provider.authenticate()
except MissingClaimException: except MissingClaimException:
logger.error("[OIDC] Required claims not present in ID token or userinfo endpoint")
auth = None auth = None
if not auth: if not auth:

View File

@@ -54,7 +54,11 @@ class MultiPurposeLabelsController(BaseCrudController):
@router.post("", response_model=MultiPurposeLabelOut) @router.post("", response_model=MultiPurposeLabelOut)
def create_one(self, data: MultiPurposeLabelCreate): def create_one(self, data: MultiPurposeLabelCreate):
new_label = self.service.create_one(data) try:
new_label = self.service.create_one(data)
except Exception as ex:
self.mixins.handle_exception(ex)
raise # handle_exception always raises; this satisfies static analysis
self.publish_event( self.publish_event(
event_type=EventTypes.label_created, event_type=EventTypes.label_created,
document_data=EventLabelData(operation=EventOperation.create, label_id=new_label.id), document_data=EventLabelData(operation=EventOperation.create, label_id=new_label.id),

View File

@@ -58,6 +58,6 @@ async def get_recipe_asset(recipe_id: UUID4, file_name: str):
raise HTTPException(status.HTTP_400_BAD_REQUEST) raise HTTPException(status.HTTP_400_BAD_REQUEST)
if file.exists(): if file.exists():
return FileResponse(file) return FileResponse(file, filename=file.name, content_disposition_type="attachment")
else: else:
raise HTTPException(status.HTTP_404_NOT_FOUND) raise HTTPException(status.HTTP_404_NOT_FOUND)

View File

@@ -52,7 +52,7 @@ class TagController(BaseCrudController):
def create_one(self, tag: TagIn): def create_one(self, tag: TagIn):
"""Creates a Tag in the database""" """Creates a Tag in the database"""
save_data = mapper.cast(tag, TagSave, group_id=self.group_id) save_data = mapper.cast(tag, TagSave, group_id=self.group_id)
new_tag = self.repo.create(save_data) new_tag = self.mixins.create_one(save_data)
if new_tag: if new_tag:
self.publish_event( self.publish_event(

View File

@@ -80,6 +80,8 @@ from mealie.services.scraper.scraper_strategies import (
from ._base import BaseRecipeController, JSONBytes from ._base import BaseRecipeController, JSONBytes
ASSET_ALLOWED_EXTENSIONS = {"pdf", "jpg", "jpeg", "png", "gif", "webp", "bmp", "avif", "txt", "md", "csv", "json"}
router = UserAPIRouter(prefix="/recipes", route_class=MealieCrudRoute) router = UserAPIRouter(prefix="/recipes", route_class=MealieCrudRoute)
@@ -660,6 +662,10 @@ class RecipeController(BaseRecipeController):
if "." in extension: if "." in extension:
extension = extension.split(".")[-1] extension = extension.split(".")[-1]
extension = extension.lower()
if extension not in ASSET_ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail="Unsupported file extension")
file_slug = slugify(name) file_slug = slugify(name)
if not extension or not file_slug: if not extension or not file_slug:
raise HTTPException(status_code=400, detail="Missing required fields") raise HTTPException(status_code=400, detail="Missing required fields")

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
@@ -45,21 +44,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(
@@ -68,7 +52,8 @@ class RecipeTimelineEventsController(BaseCrudController):
) )
for event in response.items: for event in response.items:
self._translate_event_subject(event) if event.event_type == TimelineEventType.system.value:
event.subject = self.t(event.subject)
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
@@ -104,7 +89,8 @@ 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) event = self.mixins.get_one(item_id)
self._translate_event_subject(event) if event.event_type == TimelineEventType.system.value:
event.subject = self.t(event.subject)
return event return event
@router.put("/{item_id}", response_model=RecipeTimelineEventOut) @router.put("/{item_id}", response_model=RecipeTimelineEventOut)

View File

@@ -38,6 +38,8 @@ from mealie.services.scraper import cleaner
from .template_service import TemplateService from .template_service import TemplateService
RECIPE_CREATED_EVENT_SUBJECT = "recipe.recipe-created"
class RecipeServiceBase(BaseService): class RecipeServiceBase(BaseService):
def __init__(self, repos: AllRepositories, user: PrivateUser, household: HouseholdInDB, translator: Translator): def __init__(self, repos: AllRepositories, user: PrivateUser, household: HouseholdInDB, translator: Translator):
@@ -69,8 +71,19 @@ class RecipeService(RecipeServiceBase):
def can_delete(self, recipe_slugs: list[str]) -> bool: def can_delete(self, recipe_slugs: list[str]) -> bool:
if self.user.admin: if self.user.admin:
return True return True
else:
return self.can_update(recipe_slugs) # Deletion requires ownership; collaborative editing rules (can_update) do not apply
model = self.group_recipes.model
owned_count = self.group_recipes.session.scalar(
sa.select(sa.func.count())
.select_from(model)
.where(
model.slug.in_(recipe_slugs),
model.group_id == self.user.group_id,
model.user_id == self.user.id,
)
)
return owned_count == len(recipe_slugs)
def can_update(self, recipe_slugs: list[str]) -> bool: def can_update(self, recipe_slugs: list[str]) -> bool:
sql = dedent( sql = dedent(
@@ -224,7 +237,7 @@ class RecipeService(RecipeServiceBase):
timeline_event_data = RecipeTimelineEventCreate( timeline_event_data = RecipeTimelineEventCreate(
user_id=new_recipe.user_id, user_id=new_recipe.user_id,
recipe_id=new_recipe.id, recipe_id=new_recipe.id,
subject=self.t("recipe.recipe-created"), subject=RECIPE_CREATED_EVENT_SUBJECT,
event_type=TimelineEventType.system, event_type=TimelineEventType.system,
timestamp=new_recipe.created_at or datetime.now(UTC), timestamp=new_recipe.created_at or datetime.now(UTC),
) )

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

@@ -1,6 +1,6 @@
[project] [project]
name = "mealie" name = "mealie"
version = "3.17.0" version = "3.18.0"
description = "A Recipe Manager" description = "A Recipe Manager"
authors = [{ name = "Hayden", email = "hay-kot@pm.me" }] authors = [{ name = "Hayden", email = "hay-kot@pm.me" }]
license = "AGPL-3.0-only" license = "AGPL-3.0-only"

View File

@@ -191,6 +191,24 @@ def test_organizer_association(
assert response.status_code == 200 assert response.status_code == 200
@pytest.mark.parametrize("route", organizer_routes, ids=test_ids)
def test_organizer_create_duplicate_name_returns_400(
api_client: TestClient,
unique_user: TestUser,
route: RoutesBase,
):
# Regression test for #7582: POSTing a duplicate name to organizer endpoints
# leaked the sqlalchemy IntegrityError as an HTTP 500. The expected behavior,
# matching other organizer endpoints (foods, units, tools), is HTTP 400.
data = {"name": random_string(10)}
response = api_client.post(route.base, json=data, headers=unique_user.token)
assert response.status_code == 201
response = api_client.post(route.base, json=data, headers=unique_user.token)
assert response.status_code == 400
@pytest.mark.parametrize("route, recipe_key", association_data, ids=test_ids) @pytest.mark.parametrize("route, recipe_key", association_data, ids=test_ids)
def test_organizer_get_by_slug( def test_organizer_get_by_slug(
api_client: TestClient, api_client: TestClient,

View File

@@ -20,6 +20,21 @@ def create_labels(api_client: TestClient, unique_user: TestUser, count: int = 10
return labels return labels
def test_label_create_duplicate_name_returns_400(api_client: TestClient, unique_user_fn_scoped: TestUser):
# Regression test for #7582: POSTing a duplicate label name leaked the
# sqlalchemy IntegrityError as an HTTP 500. The expected behavior, matching
# the other organizer endpoints (foods, units, tools, tags, categories),
# is HTTP 400. The function-scoped fixture avoids leaking the created label
# into the module-scoped `unique_user` group state used by sibling tests.
payload = {"name": random_string(), "color": "#ff0000"}
response = api_client.post(api_routes.groups_labels, json=payload, headers=unique_user_fn_scoped.token)
assert response.status_code == 200
response = api_client.post(api_routes.groups_labels, json=payload, headers=unique_user_fn_scoped.token)
assert response.status_code == 400
def test_new_list_creates_list_labels(api_client: TestClient, unique_user: TestUser): def test_new_list_creates_list_labels(api_client: TestClient, unique_user: TestUser):
labels = create_labels(api_client, unique_user) labels = create_labels(api_client, unique_user)
response = api_client.post( response = api_client.post(

View File

@@ -201,19 +201,12 @@ def test_delete_recipes_from_other_households(
assert recipe_json["id"] == h2_recipe_id assert recipe_json["id"] == h2_recipe_id
response = api_client.delete(api_routes.recipes_slug(recipe_json["slug"]), headers=unique_user.token) response = api_client.delete(api_routes.recipes_slug(recipe_json["slug"]), headers=unique_user.token)
if household_lock_recipe_edits: assert response.status_code == 403
assert response.status_code == 403
# confirm the recipe still exists # confirm the recipe still exists
response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token) response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token)
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["id"] == h2_recipe_id assert response.json()["id"] == h2_recipe_id
else:
assert response.status_code == 200
# confirm the recipe was deleted
response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token)
assert response.status_code == 404
@pytest.mark.parametrize("is_private_household", [True, False]) @pytest.mark.parametrize("is_private_household", [True, False])

View File

@@ -87,6 +87,23 @@ def test_recipe_asset_exploit(api_client: TestClient, unique_user: TestUser, rec
assert not (recipe.asset_dir / "test.txt").exists() assert not (recipe.asset_dir / "test.txt").exists()
def test_recipe_asset_dangerous_extension_blocked(
api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe
):
"""Ensure scriptable extensions are rejected to prevent stored XSS (GHSA-gfwc-pjx4-mg9p)."""
recipe = recipe_ingredient_only
for ext in ("html", "svg", "js", "htm", "xhtml"):
payload = {"name": random_string(10), "icon": "mdi-file", "extension": ext}
file_payload = {"file": b"<script>alert(1)</script>"}
response = api_client.post(
f"/api/recipes/{recipe.slug}/assets",
data=payload,
files=file_payload,
headers=unique_user.token,
)
assert response.status_code == 400, f"expected 400 for extension={ext}, got {response.status_code}"
def test_recipe_image_upload(api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe): def test_recipe_image_upload(api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe):
data_payload = {"extension": "jpg"} data_payload = {"extension": "jpg"}
file_payload = {"image": data.images_test_image_1.read_bytes()} file_payload = {"image": data.images_test_image_1.read_bytes()}

View File

@@ -160,6 +160,24 @@ def test_other_user_cant_delete_recipe(api_client: TestClient, user_tuple: list[
assert response.status_code == 403 assert response.status_code == 403
def test_other_user_cant_delete_unlocked_recipe(api_client: TestClient, user_tuple: list[TestUser]):
"""Non-owner must not delete an unlocked recipe — BOLA regression (GHSA-x5v9-9jvh-7c7q)."""
slug = random_string(10)
unique_user, other_user = user_tuple
unique_user.repos.recipes.create(
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=slug,
settings=RecipeSettings(locked=False),
)
)
response = api_client.delete(api_routes.recipes_slug(slug), headers=other_user.token)
assert response.status_code == 403
def test_other_user_bulk_delete(api_client: TestClient, user_tuple: list[TestUser]): def test_other_user_bulk_delete(api_client: TestClient, user_tuple: list[TestUser]):
slug_locked = random_string(10) slug_locked = random_string(10)
slug_unlocked = random_string(10) slug_unlocked = random_string(10)
@@ -190,6 +208,30 @@ def test_other_user_bulk_delete(api_client: TestClient, user_tuple: list[TestUse
assert response.status_code == 403 assert response.status_code == 403
def test_other_user_cant_bulk_delete_unlocked_recipes(api_client: TestClient, user_tuple: list[TestUser]):
"""Non-owner must not bulk-delete unlocked recipes — BOLA regression (GHSA-x5v9-9jvh-7c7q)."""
slug_1 = random_string(10)
slug_2 = random_string(10)
unique_user, other_user = user_tuple
for slug in (slug_1, slug_2):
unique_user.repos.recipes.create(
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=slug,
settings=RecipeSettings(locked=False),
)
)
response = api_client.post(
api_routes.recipes_bulk_actions_delete,
json={"recipes": [slug_1, slug_2]},
headers=other_user.token,
)
assert response.status_code == 403
def test_admin_can_delete_locked_recipe_owned_by_another_user( def test_admin_can_delete_locked_recipe_owned_by_another_user(
api_client: TestClient, unfiltered_database: AllRepositories, unique_user: TestUser, admin_user: TestUser api_client: TestClient, unfiltered_database: AllRepositories, unique_user: TestUser, admin_user: TestUser
): ):

View File

@@ -3,18 +3,24 @@ from uuid import uuid4
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from mealie.lang.providers import get_all_translations
from mealie.schema.recipe.recipe import Recipe from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe_timeline_events import ( from mealie.schema.recipe.recipe_timeline_events import (
RecipeTimelineEventOut, RecipeTimelineEventOut,
RecipeTimelineEventPagination, RecipeTimelineEventPagination,
TimelineEventImage, TimelineEventImage,
TimelineEventType,
) )
from mealie.schema.recipe.request_helpers import UpdateImageResponse from mealie.schema.recipe.request_helpers import UpdateImageResponse
from mealie.services.recipe.recipe_service import RECIPE_CREATED_EVENT_SUBJECT
from tests.utils import api_routes from tests.utils import api_routes
from tests.utils.factories import random_string from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser from tests.utils.fixture_schemas import TestUser
PERSISTED_TRANSLATION_KEYS = [RECIPE_CREATED_EVENT_SUBJECT]
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def recipes(api_client: TestClient, unique_user: TestUser): def recipes(api_client: TestClient, unique_user: TestUser):
recipes = [] recipes = []
@@ -341,6 +347,50 @@ def test_create_recipe_with_timeline_event(
assert events_pagination.items assert events_pagination.items
@pytest.mark.parametrize("translation_key", PERSISTED_TRANSLATION_KEYS)
def test_persisted_translation_keys_have_translations(translation_key: str):
translations = get_all_translations(translation_key)
missing_translations = [locale for locale, translation in translations.items() if translation == translation_key]
assert missing_translations == []
def test_recipe_created_system_event_is_translated(
api_client: TestClient,
unique_user: TestUser,
recipes: list[Recipe],
):
recipe = recipes[0]
params = {"queryFilter": f"recipe_id={recipe.id}"}
# fetch events in French — the system "recipe created" event should be translated
fr_headers = {**unique_user.token, "Accept-Language": "fr-FR"}
events_response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=fr_headers)
assert events_response.status_code == 200
events_pagination = RecipeTimelineEventPagination.model_validate(events_response.json())
system_events = [e for e in events_pagination.items if e.event_type == TimelineEventType.system.value]
assert system_events, "expected at least one system event for a newly created recipe"
for event in system_events:
assert event.subject == "Recette créée", f"expected French translation, got: {event.subject!r}"
# also verify the individual GET endpoint translates correctly
single_response = api_client.get(api_routes.recipes_timeline_events_item_id(event.id), headers=fr_headers)
assert single_response.status_code == 200
single_event = RecipeTimelineEventOut.model_validate(single_response.json())
assert single_event.subject == "Recette créée"
# fetch the same events in English — subject should be the English string
en_headers = {**unique_user.token, "Accept-Language": "en-US"}
events_response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=en_headers)
events_pagination = RecipeTimelineEventPagination.model_validate(events_response.json())
system_events = [e for e in events_pagination.items if e.event_type == TimelineEventType.system.value]
for event in system_events:
assert event.subject == "Recipe Created", f"expected English string, got: {event.subject!r}"
@pytest.mark.parametrize("use_other_household_user", [True, False]) @pytest.mark.parametrize("use_other_household_user", [True, False])
def test_invalid_recipe_id( def test_invalid_recipe_id(
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, use_other_household_user: bool api_client: TestClient, unique_user: TestUser, h2_user: TestUser, use_other_household_user: bool

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}"

2
uv.lock generated
View File

@@ -898,7 +898,7 @@ wheels = [
[[package]] [[package]]
name = "mealie" name = "mealie"
version = "3.17.0" version = "3.18.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },