Compare commits

..

1 Commits

Author SHA1 Message Date
Hayden
8ff7f27fea fix: translate mealplan timeline event subjects by locale
Mealplan "made this" timeline events were always stored as hardcoded
English strings. This stores them instead as i18n key references in the
format `recipe.<key>|<user-name>`, and translates them at serve time
in the timeline events GET endpoints.

- Add backend i18n keys for all seven entry-type variants
  (breakfast, lunch, dinner, snack, drink, dessert, side).
- Store structured subjects in create_timeline_events.py — deduplication
  logic is unaffected since the stored string is still deterministic.
- Translate info-type events at serve time in timeline_events.py, with
  a fallback to en-US when a locale has not yet been translated.
- Old events with plain English subject strings are displayed as-is
  (backward-compatible).

Follows up on #7623 which applied the same pattern to the system-type
"Recipe Created" event.
2026-05-14 09:12:58 -05:00
37 changed files with 233 additions and 306 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.18.0` 2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.17.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.18.0 # (3) image: ghcr.io/mealie-recipes/mealie:v3.17.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.18.0 # (3) image: ghcr.io/mealie-recipes/mealie:v3.17.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: 75, progress: 74,
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: 60, progress: 59,
dir: "ltr", dir: "ltr",
pluralFoodHandling: "always", pluralFoodHandling: "always",
}, },
{ {
name: "Nederlands (Dutch)", name: "Nederlands (Dutch)",
value: "nl-NL", value: "nl-NL",
progress: 98, progress: 97,
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": "Valmis", "done": "Done",
"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": "Peru", "undo": "Undo",
"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": "Menneisyydestä ladattujen päivien määrä", "numberOfDaysPast-hint": "Number of days in the past on page load",
"numberOfDaysPast-label": "Oletusarvo menneiden päivien lataukselle", "numberOfDaysPast-label": "Default Days in the Past",
"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-keittokirja" "title": "Nextcloud Cookbook"
}, },
"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": "Lisää \"{text}\" kohteen {item} aliakseksi", "add-text-as-alias-for-item": "Add \"{text}\" as alias for {item}",
"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 ei valmis", "ldap-not-ready": "LDAP Not Ready",
"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 ei ole valmis", "oidc-not-ready": "OIDC Not Ready",
"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 ei ole valmis", "openai-not-ready": "OpenAI Not Ready",
"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": "Lisää kohde", "add-item": "Add item",
"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": "Jäsentäjä", "parser": "Parser",
"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": "Jäsentäjä", "parser": "Parser",
"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": "Näytä Mealien ilmoitukset", "show-announcements-from-mealie": "Show announcements from 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

@@ -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": "{item} leellenőrzve" "item-checked-off": "Checked off {item}"
}, },
"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": "Fjöldi liðina daga við síðuhleðslu", "numberOfDaysPast-hint": "Number of days in the past on page load",
"numberOfDaysPast-label": "Sjálfgefnir liðnir dagar", "numberOfDaysPast-label": "Default Days in the Past",
"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": "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": "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-transcription": "Þú getur einnig sett inn slóð á video og Mealie mun reyna að umrita það yfir í uppskrift.", "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-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 er ekki tilbúið", "ldap-not-ready": "LDAP Not Ready",
"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 er ekki tilbúið", "oidc-not-ready": "OIDC Not Ready",
"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 er ekki tilbúið", "openai-not-ready": "OpenAI Not Ready",
"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": "Bæta við vöru", "add-item": "Add item",
"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": "Kennisgevings-url", "apprise-url": "Apprise 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": "Gereed", "done": "Done",
"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": "Ongedaan maken", "undo": "Undo",
"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": "Aantal dagen in het verleden bij laden pagina", "numberOfDaysPast-hint": "Number of days in the past on page load",
"numberOfDaysPast-label": "Standaard dagen in het verleden", "numberOfDaysPast-label": "Default Days in the Past",
"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": "GitHubproblemen", "github-issues": "GitHub Issues",
"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 niet gereed", "ldap-not-ready": "LDAP Not Ready",
"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 niet gereed", "oidc-not-ready": "OIDC Not Ready",
"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 niet gereed", "openai-not-ready": "OpenAI Not Ready",
"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": "Item toevoegen", "add-item": "Add item",
"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": "Uitgevinkt {item}" "item-checked-off": "Checked off {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": "Hulp", "help": "Help",
"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": "Aankondigingen", "announcements": "Announcements",
"all-announcements": "Alle aankondigingen", "all-announcements": "All announcements",
"mark-all-as-read": "Alles markeren als gelezen", "mark-all-as-read": "Mark All as Read",
"show-announcements-from-mealie": "Aankondigingen van Mealie weergeven", "show-announcements-from-mealie": "Show announcements from Mealie",
"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" "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

@@ -98,7 +98,7 @@
"dashboard": "Kontrollpanel", "dashboard": "Kontrollpanel",
"delete": "Slett", "delete": "Slett",
"disabled": "Deaktivert", "disabled": "Deaktivert",
"done": "Ferdig", "done": "Done",
"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": "Angre", "undo": "Undo",
"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": "Standard antall dager tilbake", "numberOfDaysPast-label": "Default Days in the Past",
"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 kokebok" "title": "Nextcloud Cookbook"
}, },
"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": "Legg til produkt", "add-item": "Add item",
"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": "Avkrysset av {item}" "item-checked-off": "Checked off {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": "Kunngjøringer", "announcements": "Announcements",
"all-announcements": "Alle kunngjøringer", "all-announcements": "All announcements",
"mark-all-as-read": "Marker alle som lest", "mark-all-as-read": "Mark All as Read",
"show-announcements-from-mealie": "Vis kunngjøringer fra Mealie", "show-announcements-from-mealie": "Show announcements from 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": "URL Apprise", "apprise-url": "Apprise URL",
"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": "Gotowe", "done": "Done",
"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": "Cofnij", "undo": "Undo",
"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": "Dodaj element", "add-item": "Add item",
"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": "Zaznaczono {item}" "item-checked-off": "Checked off {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": "Ogłoszenia", "announcements": "Announcements",
"all-announcements": "Wszystkie ogłoszenia", "all-announcements": "All announcements",
"mark-all-as-read": "Oznacz wszystkie jako przeczytane", "mark-all-as-read": "Mark All as Read",
"show-announcements-from-mealie": "Pokazuj ogłoszenia z Mealie", "show-announcements-from-mealie": "Show announcements from Mealie",
"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" "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": "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": "Odkljukano {item}" "item-checked-off": "Checked off {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": "Ångra", "undo": "Undo",
"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": "Antal förflutna dagar vid sidhämtning", "numberOfDaysPast-hint": "Number of days in the past on page load",
"numberOfDaysPast-label": "Förvalda förflutna dagar", "numberOfDaysPast-label": "Default Days in the Past",
"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": "Accentfärg", "accent": "Accent",
"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 ej tillgängligt", "ldap-not-ready": "LDAP Not Ready",
"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 ej tillgängligt", "oidc-not-ready": "OIDC Not Ready",
"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 ej tillgängligt", "openai-not-ready": "OpenAI Not Ready",
"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": "Lägg till vara", "add-item": "Add item",
"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": "Kryssat av {item}" "item-checked-off": "Checked off {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": "Meddelanden", "announcements": "Announcements",
"all-announcements": "Alla meddelanden", "all-announcements": "All announcements",
"mark-all-as-read": "Markera alla som lästa", "mark-all-as-read": "Mark All as Read",
"show-announcements-from-mealie": "Visa meddelanden från Mealie", "show-announcements-from-mealie": "Show announcements from Mealie",
"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" "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

@@ -911,7 +911,7 @@
"all-lists": "Всі списки", "all-lists": "Всі списки",
"create-shopping-list": "Сторити список покупок", "create-shopping-list": "Сторити список покупок",
"from-recipe": "З рецепту", "from-recipe": "З рецепту",
"ingredient-of-recipe": "Інгредієнт з {recipe}", "ingredient-of-recipe": "Ingredient of {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.18.0", "version": "3.17.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.debug( self._logger.error(
"[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.debug("[OIDC] Required claim '%s' is empty", claim) self._logger.error("[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,6 +5,13 @@
"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 webpaginagegevens...", "creating-recipe-from-webpage-data": "Creëren recept van webpagina-gegevens...",
"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": "acelga" "plural_name": "chard"
}, },
"pimiento": { "pimiento": {
"aliases": [], "aliases": [],
@@ -979,8 +979,8 @@
"chestnut purée": { "chestnut purée": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "puré de castañas", "name": "chestnut purée",
"plural_name": "puré de castañas" "plural_name": "chestnut purée"
}, },
"prickly pear": { "prickly pear": {
"aliases": [], "aliases": [],
@@ -1295,7 +1295,7 @@
"black fungu": { "black fungu": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "hongos negros", "name": "black fungus",
"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": "hongos blancos", "name": "white fungus",
"plural_name": "hongos blancos" "plural_name": "hongos blancos"
}, },
"pioppini": { "pioppini": {
@@ -1373,7 +1373,7 @@
"snow fungu": { "snow fungu": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "hongos de nieve", "name": "snow fungus",
"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": "delikat squash", "name": "delicata squash",
"plural_name": "delikate squasher" "plural_name": "delicata squashes"
}, },
"Frisée": { "Frisée": {
"aliases": [ "aliases": [
@@ -980,7 +980,7 @@
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "kastanjepuré", "name": "kastanjepuré",
"plural_name": "kastanjepuré" "plural_name": "chestnut purée"
}, },
"prickly pear": { "prickly pear": {
"aliases": [], "aliases": [],
@@ -1045,7 +1045,7 @@
"sweet lime": { "sweet lime": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "søt lime", "name": "sweet 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": "melonfrø", "name": "melon seed",
"plural_name": "melonfrø" "plural_name": "melon seeds"
}, },
"lotus seed": { "lotus seed": {
"aliases": [], "aliases": [],
@@ -2003,48 +2003,48 @@
"parmesan cheese": { "parmesan cheese": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "parmesanost", "name": "parmesan cheese",
"plural_name": "parmesanost" "plural_name": "parmesan cheese"
}, },
"cheddar cheese": { "cheddar cheese": {
"aliases": [ "aliases": [
"cheddarost" "cheddar cheese"
], ],
"description": "", "description": "",
"name": "cheddarost", "name": "cheddar cheese",
"plural_name": "cheddarost" "plural_name": "cheddar cheese"
}, },
"cream cheese": { "cream cheese": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "kremost", "name": "cream cheese",
"plural_name": "kremost" "plural_name": "cream cheese"
}, },
"sharp cheddar cheese": { "sharp cheddar cheese": {
"aliases": [ "aliases": [
"skarp cheddarost" "sharp cheddar"
], ],
"description": "", "description": "",
"name": "skarp cheddarost", "name": "sharp cheddar cheese",
"plural_name": "skarp cheddarost" "plural_name": "sharp cheddar cheese"
}, },
"cheese": { "cheese": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "ost", "name": "cheese",
"plural_name": "ost" "plural_name": "cheese"
}, },
"mozzarella cheese": { "mozzarella cheese": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "mozzarellaost", "name": "mozzarella cheese",
"plural_name": "mozzarellaost" "plural_name": "mozzarella cheese"
}, },
"feta cheese": { "feta cheese": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "fetaost", "name": "feta cheese",
"plural_name": "fetaost" "plural_name": "feta cheese"
}, },
"ricotta cheese": { "ricotta cheese": {
"aliases": [], "aliases": [],
@@ -2073,14 +2073,14 @@
"goat cheese": { "goat cheese": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "geitost", "name": "goat cheese",
"plural_name": "geitost" "plural_name": "goat cheese"
}, },
"fresh mozzarella cheese": { "fresh mozzarella cheese": {
"aliases": [], "aliases": [],
"description": "", "description": "",
"name": "fersk mozzarellaost", "name": "fresh mozzarella cheese",
"plural_name": "fersk mozzarellaost" "plural_name": "fresh mozzarella cheese"
}, },
"swis cheese": { "swis cheese": {
"aliases": [], "aliases": [],

View File

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

View File

@@ -134,7 +134,6 @@ 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,11 +54,7 @@ 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):
try: new_label = self.service.create_one(data)
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, filename=file.name, content_disposition_type="attachment") return FileResponse(file)
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.mixins.create_one(save_data) new_tag = self.repo.create(save_data)
if new_tag: if new_tag:
self.publish_event( self.publish_event(

View File

@@ -80,8 +80,6 @@ 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)
@@ -662,10 +660,6 @@ 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,6 +4,7 @@ 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
@@ -44,6 +45,21 @@ 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(
@@ -52,8 +68,7 @@ class RecipeTimelineEventsController(BaseCrudController):
) )
for event in response.items: for event in response.items:
if event.event_type == TimelineEventType.system.value: self._translate_event_subject(event)
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
@@ -89,8 +104,7 @@ class RecipeTimelineEventsController(BaseCrudController):
@router.get("/{item_id}", response_model=RecipeTimelineEventOut) @router.get("/{item_id}", response_model=RecipeTimelineEventOut)
def get_one(self, item_id: UUID4): def get_one(self, item_id: UUID4):
event = self.mixins.get_one(item_id) event = self.mixins.get_one(item_id)
if event.event_type == TimelineEventType.system.value: self._translate_event_subject(event)
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,8 +38,6 @@ 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):
@@ -71,19 +69,8 @@ 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:
# Deletion requires ownership; collaborative editing rules (can_update) do not apply return self.can_update(recipe_slugs)
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(
@@ -237,7 +224,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=RECIPE_CREATED_EVENT_SUBJECT, subject=self.t("recipe.recipe-created"),
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,12 +43,10 @@ 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"{user.full_name} made this as a side" event_subject = f"recipe.made-this-as-side|{user.full_name}"
else: else:
event_subject = f"{user.full_name} made this for {mealplan.entry_type.value}" event_subject = f"recipe.made-this-for-{mealplan.entry_type.value}|{user.full_name}"
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.18.0" version = "3.17.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,24 +191,6 @@ 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,21 +20,6 @@ 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,12 +201,19 @@ 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)
assert response.status_code == 403 if household_lock_recipe_edits:
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,23 +87,6 @@ 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,24 +160,6 @@ 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)
@@ -208,30 +190,6 @@ 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,24 +3,18 @@ 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 = []
@@ -347,50 +341,6 @@ 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,6 +13,49 @@ 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()
@@ -251,3 +294,27 @@ 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.18.0" version = "3.17.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },