Compare commits

..

4 Commits

Author SHA1 Message Date
renovate[bot]
7ab9d9b2ba fix(deps): update dependency pillow-heif to v1.3.0 2026-02-27 14:50:45 +00:00
renovate[bot]
3ee64c930c chore(deps): update dependency ruff to v0.15.4 (#7152)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-26 22:46:53 +00:00
Hayden
189e98fb1f chore(l10n): New Crowdin updates (#7153) 2026-02-26 21:19:36 +00:00
renovate[bot]
7da01f7873 chore(deps): update dependency ruff to v0.15.3 (#7151)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-26 17:08:10 +00:00
75 changed files with 369 additions and 1322 deletions

View File

@@ -88,25 +88,6 @@
validate-on="input"
/>
<!-- Number Input -->
<v-number-input
v-else-if="inputField.type === fieldTypes.NUMBER"
v-model="model[inputField.varName]"
variant="underlined"
:control-variant="inputField.numberInputConfig?.controlVariant"
density="comfortable"
:label="inputField.label"
:name="inputField.varName"
:min="inputField.numberInputConfig?.min"
:max="inputField.numberInputConfig?.max"
:precision="inputField.numberInputConfig?.precision"
:hint="inputField.hint"
:hide-details="!inputField.hint"
:persistent-hint="!!inputField.hint"
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
validate-on="input"
/>
<!-- Option Select -->
<v-select
v-else-if="inputField.type === fieldTypes.SELECT"

View File

@@ -1,7 +1,6 @@
export const fieldTypes = {
TEXT: "text",
TEXT_AREA: "textarea",
NUMBER: "number",
SELECT: "select",
BOOLEAN: "boolean",
PASSWORD: "password",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Recipe Actions Data",
"new-recipe-action": "New Recipe Action",
"edit-recipe-action": "Edit Recipe Action",
"action-type": "Action Type"
"action-type": "Action Type",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Create Alias",
"manage-aliases": "Manage Aliases",
@@ -1447,4 +1451,4 @@
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Recipe Actions Data",
"new-recipe-action": "New Recipe Action",
"edit-recipe-action": "Edit Recipe Action",
"action-type": "Action Type"
"action-type": "Action Type",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Create Alias",
"manage-aliases": "إدارة الأسماء المستعارة",
@@ -1447,4 +1451,4 @@
"min-length": "يجب أن يكون على الأقل {min} أحرف",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Данни за действия с рецепти",
"new-recipe-action": "Ново действие с рецепта",
"edit-recipe-action": "Редактиране на действието с рецепта",
"action-type": "Вид действие"
"action-type": "Вид действие",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Създаване на псевдоним",
"manage-aliases": "Управление на псевдоними",
@@ -1447,4 +1451,4 @@
"min-length": "Трябва да съдържа поне {min} знака",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Dades d'accions de receptes",
"new-recipe-action": "Nova acció de receptes",
"edit-recipe-action": "Edita acció de receptes",
"action-type": "Tipus d'acció"
"action-type": "Tipus d'acció",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Crea un àlies",
"manage-aliases": "Gestiona els àlies",
@@ -1447,4 +1451,4 @@
"min-length": "Ha de tenir almenys {min} caràcters",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Data akcí receptu",
"new-recipe-action": "Nová akce receptu",
"edit-recipe-action": "Upravit akci receptu",
"action-type": "Typ akce"
"action-type": "Typ akce",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Vytvořit alias",
"manage-aliases": "Spravovat aliasy",
@@ -1447,4 +1451,4 @@
"min-length": "Musí být alespoň {min} znaků",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Opskriftshandlinger",
"new-recipe-action": "Ny opskriftshandling",
"edit-recipe-action": "Rediger opskriftshandling",
"action-type": "Handlingstype"
"action-type": "Handlingstype",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Opret alias",
"manage-aliases": "Administrer aliaser",
@@ -1447,4 +1451,4 @@
"min-length": "Der skal mindst være {min} tegn",
"max-length": "Må højst være {max} tegn|Må højst være {max} tegn"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Rezept-Aktionen Daten",
"new-recipe-action": "Neue Rezept-Aktion",
"edit-recipe-action": "Rezept-Aktion bearbeiten",
"action-type": "Aktionstyp"
"action-type": "Aktionstyp",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Alias erstellen",
"manage-aliases": "Aliasse verwalten",
@@ -1447,4 +1451,4 @@
"min-length": "Muss mindestens {min} Zeichen haben",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Δεδομένα Ενεργειών Συνταγής",
"new-recipe-action": "Νέα Ενέργεια Συνταγής",
"edit-recipe-action": "Επεξεργασία Ενέργειας Συνταγής",
"action-type": "Τύπος Ενέργειας"
"action-type": "Τύπος Ενέργειας",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Δημιουργία ψευδώνυμου",
"manage-aliases": "Διαχείριση ψευδωνύμων",
@@ -1447,4 +1451,4 @@
"min-length": "Πρέπει να αποτελείται από τουλάχιστον {min} χαρακτήρες",
"max-length": "Πρέπει να αποτελείται το πολύ από {max} χαρακτήρα|Πρέπει να αποτελείται το πολύ από {max} χαρακτήρες"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Recipe Actions Data",
"new-recipe-action": "New Recipe Action",
"edit-recipe-action": "Edit Recipe Action",
"action-type": "Action Type"
"action-type": "Action Type",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Create Alias",
"manage-aliases": "Manage Aliases",
@@ -1447,4 +1451,4 @@
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1134,22 +1134,7 @@
"example-unit-singular": "ex: Tablespoon",
"example-unit-plural": "ex: Tablespoons",
"example-unit-abbreviation-singular": "ex: Tbsp",
"example-unit-abbreviation-plural": "ex: Tbsps",
"standardization": "Standardization",
"standardization-description": "How this unit can be represented as a standard unit. This enables unit conversion features such as merging compatible units in shopping lists.",
"standard-unit": "Standard Unit",
"standard-quantity": "Standard Quantity",
"unit-conversion": "Unit Conversion",
"standard-unit-labels": {
"fluid-ounce": "fluid ounce",
"cup": "cup",
"ounce": "ounce",
"pound": "pound",
"milliliter": "milliliter",
"liter": "liter",
"gram": "gram",
"kilogram": "kilogram"
}
"example-unit-abbreviation-plural": "ex: Tbsps"
},
"labels": {
"seed-dialog-text": "Seed the database with common labels based on your local language.",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Datos de Acciones de Receta",
"new-recipe-action": "Nueva Acción de Receta",
"edit-recipe-action": "Editar Acción de Receta",
"action-type": "Tipo de Acción"
"action-type": "Tipo de Acción",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Crear un Alias",
"manage-aliases": "Administrar Alias",
@@ -1447,4 +1451,4 @@
"min-length": "Debe ser como mínimo {min} caracteres",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Retsepti kasutusteave",
"new-recipe-action": "Uus retsepti tegevus",
"edit-recipe-action": "Muuda retsepti tegevust",
"action-type": "Tegevuse tüüp"
"action-type": "Tegevuse tüüp",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Loo alias",
"manage-aliases": "Halda aliaseid",
@@ -1447,4 +1451,4 @@
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Reseptin toimintojen käyttötiedot",
"new-recipe-action": "Uusi reseptin toiminto",
"edit-recipe-action": "Muuta reseptin toimintoa",
"action-type": "Toiminnon tyyppi"
"action-type": "Toiminnon tyyppi",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Luo alias",
"manage-aliases": "Hallitse aliaksia",
@@ -1447,4 +1451,4 @@
"min-length": "Vähimmäispituus on {min} merkkiä",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Données des actions de recette",
"new-recipe-action": "Nouvelle action de recette",
"edit-recipe-action": "Modifier l'action de recette",
"action-type": "Type d'action"
"action-type": "Type d'action",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Créer un alias",
"manage-aliases": "Gérer les alias",
@@ -1447,4 +1451,4 @@
"min-length": "Doit contenir au moins {min} caractères",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Données des actions de recette",
"new-recipe-action": "Nouvelle action de recette",
"edit-recipe-action": "Modifier l'action de recette",
"action-type": "Type d'action"
"action-type": "Type d'action",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Créer un alias",
"manage-aliases": "Gérer les alias",
@@ -1447,4 +1451,4 @@
"min-length": "",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Données des actions de recette",
"new-recipe-action": "Nouvelle action de recette",
"edit-recipe-action": "Modifier l'action de recette",
"action-type": "Type d'action"
"action-type": "Type d'action",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Créer un alias",
"manage-aliases": "Gérer les alias",
@@ -1447,4 +1451,4 @@
"min-length": "Doit contenir au moins {min} caractères",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Datos das Acións da Receita",
"new-recipe-action": "Nova Ación da Receita",
"edit-recipe-action": "Editar Ación da Receita",
"action-type": "Tipo de Ación"
"action-type": "Tipo de Ación",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Crear Pseudónimo",
"manage-aliases": "Xestionar Pseudónimos",
@@ -1447,4 +1451,4 @@
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "מידע על הפעולות במתכון",
"new-recipe-action": "פעולת-מתכון חדשה",
"edit-recipe-action": "עריכת פעולת-מתכון",
"action-type": "סוג פעולה"
"action-type": "סוג פעולה",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "יצירת שם נרדף",
"manage-aliases": "נהל שמות נרדפים",
@@ -1447,4 +1451,4 @@
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Recipe Actions Data",
"new-recipe-action": "New Recipe Action",
"edit-recipe-action": "Edit Recipe Action",
"action-type": "Action Type"
"action-type": "Action Type",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Create Alias",
"manage-aliases": "Manage Aliases",
@@ -1447,4 +1451,4 @@
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Receptekkel kapcsolatos tevékenységek adatai",
"new-recipe-action": "Új recept tevékenység",
"edit-recipe-action": "Recept tevékenység szerkesztése",
"action-type": "Művelet típusa"
"action-type": "Művelet típusa",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Alias készítése",
"manage-aliases": "Alias kezelése",
@@ -1447,4 +1451,4 @@
"min-length": "Legalább {min} karakter legyen",
"max-length": "Legfeljebb {max} karakter lehet|Legfeljebb {max} karakter lehet"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Gögn fyrir uppskriftaaðgerðir",
"new-recipe-action": "Nú uppskriftaaðgerð",
"edit-recipe-action": "Breyta uppskriftaaðgerð",
"action-type": "Aðgerð"
"action-type": "Aðgerð",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Stofna samheiti",
"manage-aliases": "Vinna með samheiti",
@@ -1447,4 +1451,4 @@
"min-length": "Verður að vera að lágmarki {min} stafir",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Dati Azioni Ricetta",
"new-recipe-action": "Nuova Azione Ricetta",
"edit-recipe-action": "Modifica Azione Ricetta",
"action-type": "Tipo Di Azione"
"action-type": "Tipo Di Azione",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Crea Alias",
"manage-aliases": "Gestisci Alias",
@@ -1447,4 +1451,4 @@
"min-length": "Deve Essere Almeno {min} Caratteri",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "レシピ操作データ",
"new-recipe-action": "新しいレシピ操作",
"edit-recipe-action": "レシピ操作の編集",
"action-type": "操作タイプ"
"action-type": "操作タイプ",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "エイリアスを作成",
"manage-aliases": "エイリアスの管理",
@@ -1447,4 +1451,4 @@
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "레시피 액션 데이터",
"new-recipe-action": "새 레시피 액션",
"edit-recipe-action": "레시피 액션 편집",
"action-type": "액션 종류"
"action-type": "액션 종류",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "별칭 생성",
"manage-aliases": "별칭 관리",
@@ -1447,4 +1451,4 @@
"min-length": "최소 {min}자 이상이어야 합니다",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Recipe Actions Data",
"new-recipe-action": "New Recipe Action",
"edit-recipe-action": "Edit Recipe Action",
"action-type": "Action Type"
"action-type": "Action Type",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Create Alias",
"manage-aliases": "Manage Aliases",
@@ -1447,4 +1451,4 @@
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Recepšu darbību dati",
"new-recipe-action": "Jaunas receptes darbība",
"edit-recipe-action": "Rediģēt receptes darbību",
"action-type": "Darbības veids"
"action-type": "Darbības veids",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Izveidot aizstājvārdu",
"manage-aliases": "Pārvaldīt aizstājvārdus",
@@ -1447,4 +1451,4 @@
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Gegevens voor acties met recepten",
"new-recipe-action": "Nieuwe actie met recept",
"edit-recipe-action": "Pas actie met recept aan",
"action-type": "Soort actie"
"action-type": "Soort actie",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Maak alias",
"manage-aliases": "Beheer aliassen ",
@@ -1447,4 +1451,4 @@
"min-length": "Moet minimaal {min} tekens bevatten",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Opppgavehandlings-data",
"new-recipe-action": "Ny oppskriftshandling",
"edit-recipe-action": "Rediger oppskriftshandling",
"action-type": "Handlingstype"
"action-type": "Handlingstype",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Opprett alias",
"manage-aliases": "Administrer aliaser",
@@ -1447,4 +1451,4 @@
"min-length": "Må minst ha {min} tegn",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Dane akcji przepisu",
"new-recipe-action": "",
"edit-recipe-action": "Edycja akcji przepisu",
"action-type": "Typ akcji"
"action-type": "Typ akcji",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Utwórz alias",
"manage-aliases": "Zarządzaj aliasami",
@@ -1447,4 +1451,4 @@
"min-length": "Musi zawierać co najmniej {min} znaków",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Dados de Ações de Receita",
"new-recipe-action": "Nova Ação de Receita",
"edit-recipe-action": "Alterar Ação de Receita",
"action-type": "Tipo de Ação"
"action-type": "Tipo de Ação",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Criar Apelido",
"manage-aliases": "Gerenciar apelidos",
@@ -1447,4 +1451,4 @@
"min-length": "Precisa ter pelo menos {min} caracteres",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Dados das Ações da Receita",
"new-recipe-action": "Nova Ação da Receita",
"edit-recipe-action": "Editar Ação da Receita",
"action-type": "Tipo de Ação"
"action-type": "Tipo de Ação",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Criar Pseudónimo",
"manage-aliases": "Gerir Pseudónimos",
@@ -1447,4 +1451,4 @@
"min-length": "Deve ter pelo menos {min} caracteres",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Date acțiuni rețetă",
"new-recipe-action": "Acțiune rețetă nouă",
"edit-recipe-action": "Editează Acțiune Rețetă",
"action-type": "Tip de acțiune"
"action-type": "Tip de acțiune",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Creează Alias",
"manage-aliases": "Gestionare Aliasuri",
@@ -1447,4 +1451,4 @@
"min-length": "Trebuie Să Aibă Cel Puțin {min} Caractere",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Данные действий рецепта",
"new-recipe-action": "Новое действие с рецептом",
"edit-recipe-action": "Редактировать действие рецепта",
"action-type": "Тип Действия"
"action-type": "Тип Действия",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Создать псевдоним",
"manage-aliases": "Управление псевдонимами",
@@ -1447,4 +1451,4 @@
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Dáta akcií receptu",
"new-recipe-action": "Nová akcia receptu",
"edit-recipe-action": "Upraviť akciu receptu",
"action-type": "Typ akcie"
"action-type": "Typ akcie",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Vytvoriť alias",
"manage-aliases": "Spravovať aliasy",
@@ -1447,4 +1451,4 @@
"min-length": "Musí mať aspoň {min} znakov",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Podatki o opravilu na receptu",
"new-recipe-action": "Novo opravilo na receptu",
"edit-recipe-action": "Urejaj opravilo na receptu",
"action-type": "Tip opravila"
"action-type": "Tip opravila",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Ustvari alias",
"manage-aliases": "Upravljanje z aliasi",
@@ -1447,4 +1451,4 @@
"min-length": "Mora vsebovati vsaj {min} znakov",
"max-length": "Največ {max} znakov|Vsebuje lahko največ {max} znakov"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Recipe Actions Data",
"new-recipe-action": "New Recipe Action",
"edit-recipe-action": "Edit Recipe Action",
"action-type": "Action Type"
"action-type": "Action Type",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Create Alias",
"manage-aliases": "Manage Aliases",
@@ -1447,4 +1451,4 @@
"min-length": "Мора бити најмање {min} карактера",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Data för receptåtgärder",
"new-recipe-action": "Ny receptåtgärd",
"edit-recipe-action": "Redigera receptåtgärd",
"action-type": "Åtgärdstyp"
"action-type": "Åtgärdstyp",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Skapa alias",
"manage-aliases": "Hantera alias",
@@ -1447,4 +1451,4 @@
"min-length": "Måste vara minst {min} tecken",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Tarif İşlemleri Verisi",
"new-recipe-action": "Yeni Tarif İşlemi",
"edit-recipe-action": "Tarif İşlemini Düzenle",
"action-type": "İşlem Türü"
"action-type": "İşlem Türü",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Takma Ad Oluştur",
"manage-aliases": "Takma Adları Yönet",
@@ -1447,4 +1451,4 @@
"min-length": "En Az {min} Karakter Olmalıdır",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Дані дій рецепта",
"new-recipe-action": "Нова дія рецепту",
"edit-recipe-action": "Редагувати дії рецепта",
"action-type": "Тип Дії"
"action-type": "Тип Дії",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Створити псевдонім",
"manage-aliases": "Керувати псевдонімами",
@@ -1447,4 +1451,4 @@
"min-length": "Повинно бути не менше ніж {min} символів ",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Recipe Actions Data",
"new-recipe-action": "New Recipe Action",
"edit-recipe-action": "Edit Recipe Action",
"action-type": "Action Type"
"action-type": "Action Type",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Create Alias",
"manage-aliases": "Manage Aliases",
@@ -1447,4 +1451,4 @@
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "食谱行为数据",
"new-recipe-action": "新建食谱行为",
"edit-recipe-action": "编辑食谱行为",
"action-type": "行为种类"
"action-type": "行为种类",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "创建别名",
"manage-aliases": "管理别名",
@@ -1447,4 +1451,4 @@
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Recipe Actions Data",
"new-recipe-action": "New Recipe Action",
"edit-recipe-action": "Edit Recipe Action",
"action-type": "Action Type"
"action-type": "Action Type",
"action-types": {
"link": "Link",
"post": "Post"
}
},
"create-alias": "Create Alias",
"manage-aliases": "Manage Aliases",
@@ -1447,4 +1451,4 @@
"min-length": "Must Be At Least {min} Characters",
"max-length": "Must Be At Most {max} Character|Must Be At Most {max} Characters"
}
}
}

View File

@@ -329,8 +329,6 @@ export interface IngredientUnit {
pluralAbbreviation?: string | null;
useAbbreviation?: boolean;
aliases?: IngredientUnitAlias[];
standardQuantity?: number | null;
standardUnit?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
@@ -350,8 +348,6 @@ export interface CreateIngredientUnit {
pluralAbbreviation?: string | null;
useAbbreviation?: boolean;
aliases?: CreateIngredientUnitAlias[];
standardQuantity?: number | null;
standardUnit?: string | null;
}
export interface CreateIngredientUnitAlias {
name: string;

View File

@@ -58,13 +58,3 @@ export interface QueryFilterJSONPart {
relationalOperator?: RelationalKeyword | RelationalOperator | null;
value?: string | string[] | null;
}
export type StandardizedUnitType
= | "fluid_ounce"
| "cup"
| "ounce"
| "pound"
| "milliliter"
| "liter"
| "gram"
| "kilogram";

View File

@@ -85,8 +85,6 @@ export interface CreateIngredientUnit {
pluralAbbreviation?: string | null;
useAbbreviation?: boolean;
aliases?: CreateIngredientUnitAlias[];
standardQuantity?: number | null;
standardUnit?: string | null;
}
export interface CreateIngredientUnitAlias {
name: string;
@@ -176,8 +174,6 @@ export interface IngredientUnit {
pluralAbbreviation?: string | null;
useAbbreviation?: boolean;
aliases?: IngredientUnitAlias[];
standardQuantity?: number | null;
standardUnit?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
@@ -502,8 +498,6 @@ export interface SaveIngredientUnit {
pluralAbbreviation?: string | null;
useAbbreviation?: boolean;
aliases?: CreateIngredientUnitAlias[];
standardQuantity?: number | null;
standardUnit?: string | null;
groupId: string;
}
export interface ScrapeRecipe {

View File

@@ -158,7 +158,6 @@ import RecipeDataAliasManagerDialog from "~/components/Domain/Recipe/RecipeDataA
import { validators } from "~/composables/use-validators";
import { useUserApi } from "~/composables/api";
import type { CreateIngredientUnit, IngredientUnit, IngredientUnitAlias } from "~/lib/api/types/recipe";
import type { StandardizedUnitType } from "~/lib/api/types/non-generated";
import { useLocales } from "~/composables/use-locales";
import { normalizeFilter } from "~/composables/use-utils";
import { useUnitStore } from "~/composables/store";
@@ -220,16 +219,6 @@ const tableHeaders: TableHeaders[] = [
show: true,
sortable: true,
},
{
text: i18n.t("data-pages.units.standard-quantity"),
value: "standardQuantity",
show: false,
},
{
text: i18n.t("data-pages.units.standard-unit"),
value: "standardUnit",
show: false,
},
{
text: i18n.t("general.date-added"),
value: "createdAt",
@@ -242,12 +231,7 @@ const { store: unitStore, actions: unitActions } = useUnitStore();
// ============================================================
// Form items (shared)
type StandardizedUnitTypeOption = {
text: string;
value: StandardizedUnitType;
};
const formItems = computed<AutoFormItems>(() => [
const formItems: AutoFormItems = [
{
cols: 8,
label: i18n.t("general.name"),
@@ -278,59 +262,6 @@ const formItems = computed<AutoFormItems>(() => [
varName: "description",
type: fieldTypes.TEXT,
},
{
section: i18n.t("data-pages.units.standardization"),
sectionDetails: i18n.t("data-pages.units.standardization-description"),
cols: 2,
varName: "standardQuantity",
type: fieldTypes.NUMBER,
numberInputConfig: {
min: 0,
max: undefined,
precision: undefined,
controlVariant: "hidden",
},
},
{
cols: 10,
varName: "standardUnit",
type: fieldTypes.SELECT,
selectReturnValue: "value",
options: [
{
text: i18n.t("data-pages.units.standard-unit-labels.fluid-ounce"),
value: "fluid_ounce",
},
{
text: i18n.t("data-pages.units.standard-unit-labels.cup"),
value: "cup",
},
{
text: i18n.t("data-pages.units.standard-unit-labels.ounce"),
value: "ounce",
},
{
text: i18n.t("data-pages.units.standard-unit-labels.pound"),
value: "pound",
},
{
text: i18n.t("data-pages.units.standard-unit-labels.milliliter"),
value: "milliliter",
},
{
text: i18n.t("data-pages.units.standard-unit-labels.liter"),
value: "liter",
},
{
text: i18n.t("data-pages.units.standard-unit-labels.gram"),
value: "gram",
},
{
text: i18n.t("data-pages.units.standard-unit-labels.kilogram"),
value: "kilogram",
},
] as StandardizedUnitTypeOption[],
},
{
section: i18n.t("general.settings"),
cols: 4,
@@ -344,7 +275,7 @@ const formItems = computed<AutoFormItems>(() => [
varName: "fraction",
type: fieldTypes.BOOLEAN,
},
]);
];
// ============================================================
// Create

View File

@@ -1,15 +1,6 @@
import type { VForm as VuetifyForm } from "vuetify/components/VForm";
type FormFieldType
= | "text"
| "textarea"
| "number"
| "list"
| "select"
| "object"
| "boolean"
| "color"
| "password";
type FormFieldType = "text" | "textarea" | "list" | "select" | "object" | "boolean" | "color" | "password";
export type FormValidationRule = (value: any) => boolean | string;
@@ -18,13 +9,6 @@ export interface FormSelectOption {
value?: string;
}
export interface FormFieldNumberInputConfig {
min?: number;
max?: number;
precision?: number;
controlVariant?: "split" | "default" | "hidden" | "stacked";
}
export interface FormField {
section?: string;
sectionDetails?: string;
@@ -36,7 +20,6 @@ export interface FormField {
rules?: FormValidationRule[];
disableUpdate?: boolean;
disableCreate?: boolean;
numberInputConfig?: FormFieldNumberInputConfig;
options?: FormSelectOption[];
selectReturnValue?: "text" | "value";
}

View File

@@ -1,106 +0,0 @@
"""add unit standardization fields
Revision ID: a39c7f1826e3
Revises: 1d9a002d7234
Create Date: 2026-02-21 17:59:01.161812
"""
import sqlalchemy as sa
from sqlalchemy import orm
from alembic import op
from mealie.repos.repository_units import RepositoryUnit
from mealie.core.root_logger import get_logger
from mealie.db.models._model_utils.guid import GUID
from mealie.repos.seed.seeders import IngredientUnitsSeeder
from mealie.lang.locale_config import LOCALE_CONFIG
# revision identifiers, used by Alembic.
revision = "a39c7f1826e3"
down_revision: str | None = "1d9a002d7234"
branch_labels: str | tuple[str, ...] | None = None
depends_on: str | tuple[str, ...] | None = None
logger = get_logger()
class SqlAlchemyBase(orm.DeclarativeBase): ...
class IngredientUnitModel(SqlAlchemyBase):
__tablename__ = "ingredient_units"
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
name: orm.Mapped[str | None] = orm.mapped_column(sa.String)
plural_name: orm.Mapped[str | None] = orm.mapped_column(sa.String)
abbreviation: orm.Mapped[str | None] = orm.mapped_column(sa.String)
plural_abbreviation: orm.Mapped[str | None] = orm.mapped_column(sa.String)
standard_quantity: orm.Mapped[float | None] = orm.mapped_column(sa.Float)
standard_unit: orm.Mapped[str | None] = orm.mapped_column(sa.String)
def populate_standards() -> None:
bind = op.get_bind()
session = orm.Session(bind)
# We aren't using most of the functionality of this class, so we pass dummy args
repo = RepositoryUnit(None, None, None, None, group_id=None) # type: ignore
stmt = sa.select(IngredientUnitModel)
units = session.execute(stmt).scalars().all()
if not units:
return
# Manually build repo._standardized_unit_map with all locales
repo._standardized_unit_map = {}
for locale in LOCALE_CONFIG:
locale_file = IngredientUnitsSeeder.get_file(locale)
for unit_key, unit in IngredientUnitsSeeder.load_file(locale_file).items():
for prop in ["name", "plural_name", "abbreviation"]:
val = unit.get(prop)
if val and isinstance(val, str):
repo._standardized_unit_map[val.strip().lower()] = unit_key
for unit in units:
unit_data = {
"name": unit.name,
"plural_name": unit.plural_name,
"abbreviation": unit.abbreviation,
"plural_abbreviation": unit.plural_abbreviation,
}
standardized_data = repo._add_standardized_unit(unit_data)
std_q = standardized_data.get("standard_quantity")
std_u = standardized_data.get("standard_unit")
if std_q and std_u:
logger.info(f"Found unit '{unit.name}', which is standardized as '{std_q} * {std_u}'")
unit.standard_quantity = std_q
unit.standard_unit = std_u
session.commit()
session.close()
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("ingredient_units", schema=None) as batch_op:
batch_op.add_column(sa.Column("standard_quantity", sa.Float(), nullable=True))
batch_op.add_column(sa.Column("standard_unit", sa.String(), nullable=True))
# ### end Alembic commands ###
# Populate standardized units for existing records
try:
populate_standards()
except Exception:
logger.exception("Failed to populate unit standards, skipping...")
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("ingredient_units", schema=None) as batch_op:
batch_op.drop_column("standard_unit")
batch_op.drop_column("standard_quantity")
# ### end Alembic commands ###

View File

@@ -52,10 +52,6 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
cascade="all, delete, delete-orphan",
)
# Standardization
standard_quantity: Mapped[float | None] = mapped_column(Float)
standard_unit: Mapped[str | None] = mapped_column(String)
# Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
plural_name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)

View File

@@ -15,63 +15,52 @@ class LocalePluralFoodHandling(StrEnum):
@dataclass
class LocaleConfig:
key: str
name: str
dir: LocaleTextDirection = LocaleTextDirection.LTR
plural_food_handling: LocalePluralFoodHandling = LocalePluralFoodHandling.ALWAYS
LOCALE_CONFIG: dict[str, LocaleConfig] = {
"af-ZA": LocaleConfig(key="af-ZA", name="Afrikaans (Afrikaans)"),
"ar-SA": LocaleConfig(key="ar-SA", name="العربية (Arabic)", dir=LocaleTextDirection.RTL),
"bg-BG": LocaleConfig(key="bg-BG", name="Български (Bulgarian)"),
"ca-ES": LocaleConfig(key="ca-ES", name="Català (Catalan)"),
"cs-CZ": LocaleConfig(key="cs-CZ", name="Čeština (Czech)"),
"da-DK": LocaleConfig(key="da-DK", name="Dansk (Danish)"),
"de-DE": LocaleConfig(key="de-DE", name="Deutsch (German)"),
"el-GR": LocaleConfig(key="el-GR", name="Ελληνικά (Greek)"),
"en-GB": LocaleConfig(
key="en-GB", name="British English", plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT
),
"en-US": LocaleConfig(
key="en-US", name="American English", plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT
),
"es-ES": LocaleConfig(key="es-ES", name="Español (Spanish)"),
"et-EE": LocaleConfig(key="et-EE", name="Eesti (Estonian)"),
"fi-FI": LocaleConfig(key="fi-FI", name="Suomi (Finnish)"),
"fr-BE": LocaleConfig(key="fr-BE", name="Belge (Belgian)"),
"fr-CA": LocaleConfig(key="fr-CA", name="Français canadien (Canadian French)"),
"fr-FR": LocaleConfig(key="fr-FR", name="Français (French)"),
"gl-ES": LocaleConfig(key="gl-ES", name="Galego (Galician)"),
"he-IL": LocaleConfig(key="he-IL", name="עברית (Hebrew)", dir=LocaleTextDirection.RTL),
"hr-HR": LocaleConfig(key="hr-HR", name="Hrvatski (Croatian)"),
"hu-HU": LocaleConfig(key="hu-HU", name="Magyar (Hungarian)"),
"is-IS": LocaleConfig(key="is-IS", name="Íslenska (Icelandic)"),
"it-IT": LocaleConfig(key="it-IT", name="Italiano (Italian)"),
"ja-JP": LocaleConfig(key="ja-JP", name="日本語 (Japanese)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"ko-KR": LocaleConfig(key="ko-KR", name="한국어 (Korean)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"lt-LT": LocaleConfig(key="lt-LT", name="Lietuvių (Lithuanian)"),
"lv-LV": LocaleConfig(key="lv-LV", name="Latviešu (Latvian)"),
"nl-NL": LocaleConfig(key="nl-NL", name="Nederlands (Dutch)"),
"no-NO": LocaleConfig(key="no-NO", name="Norsk (Norwegian)"),
"pl-PL": LocaleConfig(key="pl-PL", name="Polski (Polish)"),
"pt-BR": LocaleConfig(key="pt-BR", name="Português do Brasil (Brazilian Portuguese)"),
"pt-PT": LocaleConfig(key="pt-PT", name="Português (Portuguese)"),
"ro-RO": LocaleConfig(key="ro-RO", name="Română (Romanian)"),
"ru-RU": LocaleConfig(key="ru-RU", name="Pусский (Russian)"),
"sk-SK": LocaleConfig(key="sk-SK", name="Slovenčina (Slovak)"),
"sl-SI": LocaleConfig(key="sl-SI", name="Slovenščina (Slovenian)"),
"sr-SP": LocaleConfig(key="sr-SP", name="српски (Serbian)"),
"sv-SE": LocaleConfig(key="sv-SE", name="Svenska (Swedish)"),
"tr-TR": LocaleConfig(key="tr-TR", name="Türkçe (Turkish)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"uk-UA": LocaleConfig(key="uk-UA", name="Українська (Ukrainian)"),
"vi-VN": LocaleConfig(
key="vi-VN", name="Tiếng Việt (Vietnamese)", plural_food_handling=LocalePluralFoodHandling.NEVER
),
"zh-CN": LocaleConfig(
key="zh-CN", name="简体中文 (Chinese simplified)", plural_food_handling=LocalePluralFoodHandling.NEVER
),
"zh-TW": LocaleConfig(
key="zh-TW", name="繁體中文 (Chinese traditional)", plural_food_handling=LocalePluralFoodHandling.NEVER
),
"af-ZA": LocaleConfig(name="Afrikaans (Afrikaans)"),
"ar-SA": LocaleConfig(name="العربية (Arabic)", dir=LocaleTextDirection.RTL),
"bg-BG": LocaleConfig(name="Български (Bulgarian)"),
"ca-ES": LocaleConfig(name="Català (Catalan)"),
"cs-CZ": LocaleConfig(name="Čeština (Czech)"),
"da-DK": LocaleConfig(name="Dansk (Danish)"),
"de-DE": LocaleConfig(name="Deutsch (German)"),
"el-GR": LocaleConfig(name="Ελληνικά (Greek)"),
"en-GB": LocaleConfig(name="British English", plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT),
"en-US": LocaleConfig(name="American English", plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT),
"es-ES": LocaleConfig(name="Español (Spanish)"),
"et-EE": LocaleConfig(name="Eesti (Estonian)"),
"fi-FI": LocaleConfig(name="Suomi (Finnish)"),
"fr-BE": LocaleConfig(name="Belge (Belgian)"),
"fr-CA": LocaleConfig(name="Français canadien (Canadian French)"),
"fr-FR": LocaleConfig(name="Français (French)"),
"gl-ES": LocaleConfig(name="Galego (Galician)"),
"he-IL": LocaleConfig(name="עברית (Hebrew)", dir=LocaleTextDirection.RTL),
"hr-HR": LocaleConfig(name="Hrvatski (Croatian)"),
"hu-HU": LocaleConfig(name="Magyar (Hungarian)"),
"is-IS": LocaleConfig(name="Íslenska (Icelandic)"),
"it-IT": LocaleConfig(name="Italiano (Italian)"),
"ja-JP": LocaleConfig(name="日本語 (Japanese)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"ko-KR": LocaleConfig(name="한국어 (Korean)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"lt-LT": LocaleConfig(name="Lietuvių (Lithuanian)"),
"lv-LV": LocaleConfig(name="Latviešu (Latvian)"),
"nl-NL": LocaleConfig(name="Nederlands (Dutch)"),
"no-NO": LocaleConfig(name="Norsk (Norwegian)"),
"pl-PL": LocaleConfig(name="Polski (Polish)"),
"pt-BR": LocaleConfig(name="Português do Brasil (Brazilian Portuguese)"),
"pt-PT": LocaleConfig(name="Português (Portuguese)"),
"ro-RO": LocaleConfig(name="Română (Romanian)"),
"ru-RU": LocaleConfig(name="Pусский (Russian)"),
"sk-SK": LocaleConfig(name="Slovenčina (Slovak)"),
"sl-SI": LocaleConfig(name="Slovenščina (Slovenian)"),
"sr-SP": LocaleConfig(name="српски (Serbian)"),
"sv-SE": LocaleConfig(name="Svenska (Swedish)"),
"tr-TR": LocaleConfig(name="Türkçe (Turkish)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"uk-UA": LocaleConfig(name="Українська (Ukrainian)"),
"vi-VN": LocaleConfig(name="Tiếng Việt (Vietnamese)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"zh-CN": LocaleConfig(name="简体中文 (Chinese simplified)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"zh-TW": LocaleConfig(name="繁體中文 (Chinese traditional)", plural_food_handling=LocalePluralFoodHandling.NEVER),
}

View File

@@ -1,119 +1,17 @@
from collections.abc import Iterable
from pydantic import UUID4, BaseModel
from pydantic import UUID4
from sqlalchemy import select
from mealie.db.models.recipe.ingredient import IngredientUnitModel
from mealie.lang.providers import get_locale_context
from mealie.schema.recipe.recipe_ingredient import IngredientUnit, StandardizedUnitType
from mealie.schema.recipe.recipe_ingredient import IngredientUnit
from .repository_generic import GroupRepositoryGeneric
class RepositoryUnit(GroupRepositoryGeneric[IngredientUnit, IngredientUnitModel]):
_standardized_unit_map: dict[str, str] | None = None
@property
def standardized_unit_map(self) -> dict[str, str]:
"""A map of potential known units to its standardized name in our seed data"""
if self._standardized_unit_map is None:
from .seed.seeders import IngredientUnitsSeeder
ctx = get_locale_context()
if ctx:
locale = ctx[1].key
else:
locale = None
self._standardized_unit_map = {}
locale_file = IngredientUnitsSeeder.get_file(locale=locale)
for unit_key, unit in IngredientUnitsSeeder.load_file(locale_file).items():
for prop in ["name", "plural_name", "abbreviation"]:
val = unit.get(prop)
if val and isinstance(val, str):
self._standardized_unit_map[val.strip().lower()] = unit_key
return self._standardized_unit_map
def _get_unit(self, id: UUID4) -> IngredientUnitModel:
stmt = select(self.model).filter_by(**self._filter_builder(**{"id": id}))
return self.session.execute(stmt).scalars().one()
def _add_standardized_unit(self, data: BaseModel | dict) -> dict:
if not isinstance(data, dict):
data = data.model_dump()
# Don't overwrite user data if it exists
if data.get("standard_quantity") is not None or data.get("standard_unit") is not None:
return data
# Compare name attrs to translation files and see if there's a match to a known standard unit
for prop in ["name", "plural_name", "abbreviation", "plural_abbreviation"]:
val = data.get(prop)
if not (val and isinstance(val, str)):
continue
standardized_unit_key = self.standardized_unit_map.get(val.strip().lower())
if not standardized_unit_key:
continue
match standardized_unit_key:
case "teaspoon":
data["standard_quantity"] = 1 / 6
data["standard_unit"] = StandardizedUnitType.FLUID_OUNCE
case "tablespoon":
data["standard_quantity"] = 1 / 2
data["standard_unit"] = StandardizedUnitType.FLUID_OUNCE
case "cup":
data["standard_quantity"] = 1
data["standard_unit"] = StandardizedUnitType.CUP
case "fluid-ounce":
data["standard_quantity"] = 1
data["standard_unit"] = StandardizedUnitType.FLUID_OUNCE
case "pint":
data["standard_quantity"] = 2
data["standard_unit"] = StandardizedUnitType.CUP
case "quart":
data["standard_quantity"] = 4
data["standard_unit"] = StandardizedUnitType.CUP
case "gallon":
data["standard_quantity"] = 16
data["standard_unit"] = StandardizedUnitType.CUP
case "milliliter":
data["standard_quantity"] = 1
data["standard_unit"] = StandardizedUnitType.MILLILITER
case "liter":
data["standard_quantity"] = 1
data["standard_unit"] = StandardizedUnitType.LITER
case "pound":
data["standard_quantity"] = 1
data["standard_unit"] = StandardizedUnitType.POUND
case "ounce":
data["standard_quantity"] = 1
data["standard_unit"] = StandardizedUnitType.OUNCE
case "gram":
data["standard_quantity"] = 1
data["standard_unit"] = StandardizedUnitType.GRAM
case "kilogram":
data["standard_quantity"] = 1
data["standard_unit"] = StandardizedUnitType.KILOGRAM
case "milligram":
data["standard_quantity"] = 1 / 1000
data["standard_unit"] = StandardizedUnitType.GRAM
case _:
continue
return data
def create(self, data: IngredientUnit | dict) -> IngredientUnit:
data = self._add_standardized_unit(data)
return super().create(data)
def create_many(self, data: Iterable[IngredientUnit | dict]) -> list[IngredientUnit]:
data = [self._add_standardized_unit(i) for i in data]
return super().create_many(data)
def merge(self, from_unit: UUID4, to_unit: UUID4) -> IngredientUnit | None:
from_model = self._get_unit(from_unit)
to_model = self._get_unit(to_unit)

View File

@@ -1,4 +1,3 @@
import json
from abc import ABC, abstractmethod
from logging import Logger
from pathlib import Path
@@ -12,8 +11,6 @@ class AbstractSeeder(ABC):
Abstract class for seeding data.
"""
resources = Path(__file__).parent / "resources"
def __init__(self, db: AllRepositories, logger: Logger | None = None):
"""
Initialize the abstract seeder.
@@ -22,14 +19,7 @@ class AbstractSeeder(ABC):
"""
self.repos = db
self.logger = logger or get_logger("Data Seeder")
@classmethod
@abstractmethod
def get_file(self, locale: str | None = None) -> Path: ...
@classmethod
def load_file(self, file: Path) -> dict[str, dict]:
return json.loads(file.read_text(encoding="utf-8"))
self.resources = Path(__file__).parent / "resources"
@abstractmethod
def seed(self, locale: str | None = None) -> None: ...

View File

@@ -5377,8 +5377,8 @@
"shark meat": {
"aliases": [],
"description": "",
"name": "shark meat",
"plural_name": "shark meats"
"name": "haaienvlees",
"plural_name": "haaienvlees"
},
"garoupa": {
"aliases": [],
@@ -5687,7 +5687,7 @@
"salted shrimp": {
"aliases": [],
"description": "",
"name": "salted shrimp",
"name": "gezouten garnalen",
"plural_name": "gezouten garnalen"
},
"yaki-nori": {

View File

@@ -1,6 +1,6 @@
[
{
"name": "Landbrugsprodukt"
"name": "Landbrugsprodukter"
},
{
"name": "Korn"

View File

@@ -1,3 +1,4 @@
import json
import pathlib
from collections.abc import Generator
from functools import cached_property
@@ -20,10 +21,9 @@ class MultiPurposeLabelSeeder(AbstractSeeder):
def service(self):
return MultiPurposeLabelService(self.repos)
@classmethod
def get_file(cls, locale: str | None = None) -> pathlib.Path:
def get_file(self, locale: str | None = None) -> pathlib.Path:
# Get the labels from the foods seed file now
locale_path = cls.resources / "foods" / "locales" / f"{locale}.json"
locale_path = self.resources / "foods" / "locales" / f"{locale}.json"
return locale_path if locale_path.exists() else foods.en_US
def get_all_labels(self) -> list[MultiPurposeLabelOut]:
@@ -34,7 +34,7 @@ class MultiPurposeLabelSeeder(AbstractSeeder):
current_label_names = {label.name for label in self.get_all_labels()}
# load from the foods locale file and remove any empty strings
seed_label_names = set(filter(None, self.load_file(file).keys())) # type: set[str]
seed_label_names = set(filter(None, json.loads(file.read_text(encoding="utf-8")).keys())) # type: set[str]
# only seed new labels
to_seed_labels = seed_label_names - current_label_names
for label in to_seed_labels:
@@ -53,9 +53,8 @@ class MultiPurposeLabelSeeder(AbstractSeeder):
class IngredientUnitsSeeder(AbstractSeeder):
@classmethod
def get_file(cls, locale: str | None = None) -> pathlib.Path:
locale_path = cls.resources / "units" / "locales" / f"{locale}.json"
def get_file(self, locale: str | None = None) -> pathlib.Path:
locale_path = self.resources / "units" / "locales" / f"{locale}.json"
return locale_path if locale_path.exists() else units.en_US
def get_all_units(self) -> list[IngredientUnit]:
@@ -65,7 +64,7 @@ class IngredientUnitsSeeder(AbstractSeeder):
file = self.get_file(locale)
seen_unit_names = {unit.name for unit in self.get_all_units()}
for unit in self.load_file(file).values():
for unit in json.loads(file.read_text(encoding="utf-8")).values():
if unit["name"] in seen_unit_names:
continue
@@ -89,9 +88,8 @@ class IngredientUnitsSeeder(AbstractSeeder):
class IngredientFoodsSeeder(AbstractSeeder):
@classmethod
def get_file(cls, locale: str | None = None) -> pathlib.Path:
locale_path = cls.resources / "foods" / "locales" / f"{locale}.json"
def get_file(self, locale: str | None = None) -> pathlib.Path:
locale_path = self.resources / "foods" / "locales" / f"{locale}.json"
return locale_path if locale_path.exists() else foods.en_US
def get_label(self, value: str) -> MultiPurposeLabelOut | None:
@@ -105,7 +103,7 @@ class IngredientFoodsSeeder(AbstractSeeder):
# get all current unique foods
seen_foods_names = {food.name for food in self.get_all_foods()}
for label, values in self.load_file(file).items():
for label, values in json.loads(file.read_text(encoding="utf-8")).items():
label_out = self.get_label(label)
for food_name, attributes in values["foods"].items():

View File

@@ -67,7 +67,6 @@ from .recipe_ingredient import (
RegisteredParser,
SaveIngredientFood,
SaveIngredientUnit,
StandardizedUnitType,
UnitFoodBase,
)
from .recipe_notes import RecipeNote
@@ -160,7 +159,6 @@ __all__ = [
"RegisteredParser",
"SaveIngredientFood",
"SaveIngredientUnit",
"StandardizedUnitType",
"UnitFoodBase",
"RecipeSuggestionQuery",
"RecipeSuggestionResponse",

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
import datetime
import enum
from enum import StrEnum
from fractions import Fraction
from typing import ClassVar
from uuid import UUID, uuid4
@@ -35,28 +34,6 @@ def display_fraction(fraction: Fraction):
)
class StandardizedUnitType(StrEnum):
"""
An arbitrary list of standardized units supported by unit conversions.
The backend doesn't really care what standardized unit you use, as long as it's recognized,
but defining them here keeps it consistant with the frontend.
"""
# Imperial
FLUID_OUNCE = "fluid_ounce"
CUP = "cup"
OUNCE = "ounce"
POUND = "pound"
# Metric
MILLILITER = "milliliter"
LITER = "liter"
GRAM = "gram"
KILOGRAM = "kilogram"
class UnitFoodBase(MealieModel):
id: UUID4 | None = None
name: str
@@ -132,6 +109,9 @@ class IngredientFood(CreateIngredientFood):
except AttributeError:
return v
def is_on_hand(self, household_slug: str) -> bool:
return household_slug in self.households_with_tool
class IngredientFoodPagination(PaginationBase):
items: list[IngredientFood]
@@ -150,21 +130,7 @@ class CreateIngredientUnit(UnitFoodBase):
abbreviation: str = ""
plural_abbreviation: str | None = ""
use_abbreviation: bool = False
aliases: list[CreateIngredientUnitAlias] = []
standard_quantity: float | None = None
standard_unit: str | None = None
@model_validator(mode="after")
def validate_standardization_fields(self):
# If one is set, the other must be set.
# If quantity is <= 0, it's considered not set.
if not self.standard_unit:
self.standard_quantity = self.standard_unit = None
elif not ((self.standard_quantity or 0) > 0):
self.standard_quantity = self.standard_unit = None
return self
class SaveIngredientUnit(CreateIngredientUnit):

View File

@@ -32,6 +32,9 @@ class RecipeToolOut(RecipeToolCreate):
except AttributeError:
return v
def is_on_hand(self, household_slug: str) -> bool:
return household_slug in self.households_with_tool
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return [

View File

@@ -28,7 +28,6 @@ from mealie.schema.recipe.recipe_ingredient import (
)
from mealie.schema.response.pagination import OrderDirection, PaginationQuery
from mealie.services.parser_services._base import DataMatcher
from mealie.services.parser_services.parser_utils import UnitConverter, merge_quantity_and_unit
class ShoppingListService:
@@ -42,7 +41,8 @@ class ShoppingListService:
self.list_refs = repos.group_shopping_list_recipe_refs
self.data_matcher = DataMatcher(self.repos, food_fuzzy_match_threshold=self.DEFAULT_FOOD_FUZZY_MATCH_THRESHOLD)
def can_merge(self, item1: ShoppingListItemBase, item2: ShoppingListItemBase) -> bool:
@staticmethod
def can_merge(item1: ShoppingListItemBase, item2: ShoppingListItemBase) -> bool:
"""Check to see if this item can be merged with another item"""
if any(
@@ -50,28 +50,16 @@ class ShoppingListService:
item1.checked,
item2.checked,
item1.food_id != item2.food_id,
item1.unit_id != item2.unit_id,
]
):
return False
# check if units match or if they're compatable
if item1.unit_id != item2.unit_id:
item1_unit = item1.unit or self.data_matcher.units_by_id.get(item1.unit_id)
item2_unit = item2.unit or self.data_matcher.units_by_id.get(item2.unit_id)
if not (item1_unit and item1_unit.standard_unit):
return False
if not (item2_unit and item2_unit.standard_unit):
return False
uc = UnitConverter()
if not uc.can_convert(item1_unit.standard_unit, item2_unit.standard_unit):
return False
# if foods match, we can merge, otherwise compare the notes
return bool(item1.food_id) or item1.note == item2.note
@staticmethod
def merge_items(
self,
from_item: ShoppingListItemCreate | ShoppingListItemUpdateBulk,
to_item: ShoppingListItemCreate | ShoppingListItemUpdateBulk | ShoppingListItemOut,
) -> ShoppingListItemUpdate:
@@ -81,20 +69,7 @@ class ShoppingListService:
Attributes of the `to_item` take priority over the `from_item`, except extras with overlapping keys
"""
to_item_unit = to_item.unit or self.data_matcher.units_by_id.get(to_item.unit_id)
from_item_unit = from_item.unit or self.data_matcher.units_by_id.get(from_item.unit_id)
if to_item_unit and to_item_unit.standard_unit and from_item_unit and from_item_unit.standard_unit:
merged_qty, merged_unit = merge_quantity_and_unit(
from_item.quantity or 0, from_item_unit, to_item.quantity or 0, to_item_unit
)
to_item.quantity = merged_qty
to_item.unit_id = merged_unit.id
to_item.unit = merged_unit
else:
# No conversion needed, just sum the quantities
to_item.quantity += from_item.quantity
to_item.quantity += from_item.quantity
if to_item.note != from_item.note:
to_item.note = " | ".join([note for note in [to_item.note, from_item.note] if note])

View File

@@ -28,38 +28,18 @@ class DataMatcher:
self._food_fuzzy_match_threshold = food_fuzzy_match_threshold
self._unit_fuzzy_match_threshold = unit_fuzzy_match_threshold
self._foods_by_id: dict[UUID4, IngredientFood] | None = None
self._units_by_id: dict[UUID4, IngredientUnit] | None = None
self._foods_by_alias: dict[str, IngredientFood] | None = None
self._units_by_alias: dict[str, IngredientUnit] | None = None
@property
def foods_by_id(self) -> dict[UUID4, IngredientFood]:
if self._foods_by_id is None:
def foods_by_alias(self) -> dict[str, IngredientFood]:
if self._foods_by_alias is None:
foods_repo = self.repos.ingredient_foods
query = PaginationQuery(page=1, per_page=-1)
all_foods = foods_repo.page_all(query).items
self._foods_by_id = {food.id: food for food in all_foods}
return self._foods_by_id
@property
def units_by_id(self) -> dict[UUID4, IngredientUnit]:
if self._units_by_id is None:
units_repo = self.repos.ingredient_units
query = PaginationQuery(page=1, per_page=-1)
all_units = units_repo.page_all(query).items
self._units_by_id = {unit.id: unit for unit in all_units}
return self._units_by_id
@property
def foods_by_alias(self) -> dict[str, IngredientFood]:
if self._foods_by_alias is None:
foods_by_alias: dict[str, IngredientFood] = {}
for food in self.foods_by_id.values():
for food in all_foods:
if food.name:
foods_by_alias[IngredientFoodModel.normalize(food.name)] = food
if food.plural_name:
@@ -76,8 +56,12 @@ class DataMatcher:
@property
def units_by_alias(self) -> dict[str, IngredientUnit]:
if self._units_by_alias is None:
units_repo = self.repos.ingredient_units
query = PaginationQuery(page=1, per_page=-1)
all_units = units_repo.page_all(query).items
units_by_alias: dict[str, IngredientUnit] = {}
for unit in self.units_by_id.values():
for unit in all_units:
if unit.name:
units_by_alias[IngredientUnitModel.normalize(unit.name)] = unit
if unit.plural_name:

View File

@@ -1,2 +1 @@
from .string_utils import *
from .unit_utils import *

View File

@@ -1,146 +0,0 @@
from typing import TYPE_CHECKING, Literal, overload
from pint import Quantity, Unit, UnitRegistry
if TYPE_CHECKING:
from mealie.schema.recipe.recipe_ingredient import CreateIngredientUnit
class UnitNotFound(Exception):
"""Raised when trying to access a unit not found in the unit registry."""
def __init__(self, message: str = "Unit not found in unit registry"):
self.message = message
super().__init__(self.message)
def __str__(self):
return f"{self.message}"
class UnitConverter:
def __init__(self):
self.ureg = UnitRegistry()
def _resolve_ounce(self, unit_1: Unit, unit_2: Unit) -> tuple[Unit, Unit]:
"""
Often times "ounce" is used in place of "fluid ounce" in recipes.
When trying to convert/combine ounces with a volume, we can assume it should have been a fluid ounce.
This function will convert ounces to fluid ounces if the other unit is a volume.
"""
OUNCE = self.ureg("ounce")
FL_OUNCE = self.ureg("fluid_ounce")
VOLUME = "[length] ** 3"
if unit_1 == OUNCE and unit_2.dimensionality == VOLUME:
return FL_OUNCE, unit_2
if unit_2 == OUNCE and unit_1.dimensionality == VOLUME:
return unit_1, FL_OUNCE
return unit_1, unit_2
@overload
def parse(self, unit: str | Unit, strict: Literal[False] = False) -> str | Unit: ...
@overload
def parse(self, unit: str | Unit, strict: Literal[True]) -> Unit: ...
def parse(self, unit: str | Unit, strict: bool = False) -> str | Unit:
"""
Parse a string unit into a pint.Unit.
If strict is False (default), returns a pint.Unit if it exists, otherwise returns the original string.
If strict is True, raises UnitNotFound instead of returning a string.
If the input is already a parsed pint.Unit, returns it as-is.
"""
if isinstance(unit, Unit):
return unit
try:
return self.ureg(unit).units
except Exception as e:
if strict:
raise UnitNotFound(f"Unit '{unit}' not found in unit registry") from e
return unit
def can_convert(self, unit: str | Unit, to_unit: str | Unit) -> bool:
"""Whether or not a given unit can be converted into another unit."""
unit = self.parse(unit)
to_unit = self.parse(to_unit)
if not (isinstance(unit, Unit) and isinstance(to_unit, Unit)):
return False
unit, to_unit = self._resolve_ounce(unit, to_unit)
return unit.is_compatible_with(to_unit)
def convert(self, quantity: float, unit: str | Unit, to_unit: str | Unit) -> tuple[float, Unit]:
"""
Convert a quantity and a unit into another unit.
Returns tuple[quantity, unit]
"""
unit = self.parse(unit, strict=True)
to_unit = self.parse(to_unit, strict=True)
unit, to_unit = self._resolve_ounce(unit, to_unit)
qty = quantity * unit
converted = qty.to(to_unit)
return float(converted.magnitude), converted.units
def merge(self, quantity_1: float, unit_1: str | Unit, quantity_2: float, unit_2: str | Unit) -> tuple[float, Unit]:
"""Merge two quantities together"""
unit_1 = self.parse(unit_1, strict=True)
unit_2 = self.parse(unit_2, strict=True)
unit_1, unit_2 = self._resolve_ounce(unit_1, unit_2)
q1 = quantity_1 * unit_1
q2 = quantity_2 * unit_2
out: Quantity = q1 + q2
return float(out.magnitude), out.units
def merge_quantity_and_unit[T: CreateIngredientUnit](
qty_1: float, unit_1: T, qty_2: float, unit_2: T
) -> tuple[float, T]:
"""
Merge a quantity and unit.
Returns tuple[quantity, unit]
"""
if not (unit_1.standard_quantity and unit_1.standard_unit and unit_2.standard_quantity and unit_2.standard_unit):
raise ValueError("Both units must contain standardized unit data")
PINT_UNIT_1_TXT = "_mealie_unit_1"
PINT_UNIT_2_TXT = "_mealie_unit_2"
uc = UnitConverter()
# pre-process units to account for ounce -> fluid_ounce conversion
unit_1_standard = uc.parse(unit_1.standard_unit, strict=True)
unit_2_standard = uc.parse(unit_2.standard_unit, strict=True)
unit_1_standard, unit_2_standard = uc._resolve_ounce(unit_1_standard, unit_2_standard)
# create custon unit definition so pint can handle them natively
uc.ureg.define(f"{PINT_UNIT_1_TXT} = {unit_1.standard_quantity} * {unit_1_standard}")
uc.ureg.define(f"{PINT_UNIT_2_TXT} = {unit_2.standard_quantity} * {unit_2_standard}")
pint_unit_1 = uc.parse(PINT_UNIT_1_TXT)
pint_unit_2 = uc.parse(PINT_UNIT_2_TXT)
merged_q, merged_u = uc.merge(qty_1, pint_unit_1, qty_2, pint_unit_2)
# Convert to the bigger unit if quantity >= 1, else the smaller unit
merged_q, merged_u = uc.convert(merged_q, merged_u, max(pint_unit_1, pint_unit_2))
if abs(merged_q) < 1:
merged_q, merged_u = uc.convert(merged_q, merged_u, min(pint_unit_1, pint_unit_2))
if str(merged_u) == PINT_UNIT_1_TXT:
return merged_q, unit_1
else:
return merged_q, unit_2

View File

@@ -40,13 +40,12 @@ dependencies = [
"html2text==2025.4.15",
"paho-mqtt==1.6.1",
"pydantic-settings==2.13.1",
"pillow-heif==1.2.1",
"pillow-heif==1.3.0",
"pyjwt==2.11.0",
"openai==2.24.0",
"typing-extensions==4.15.0",
"itsdangerous==2.2.0",
"ingredient-parser-nlp==2.5.0",
"pint>=0.25",
]
[project.scripts]
@@ -71,7 +70,7 @@ dev = [
"pytest==9.0.2",
"pytest-asyncio==1.3.0",
"rich==14.3.3",
"ruff==0.15.2",
"ruff==0.15.4",
"types-PyYAML==6.0.12.20250915",
"types-python-dateutil==2.9.0.20260124",
"types-python-slugify==8.0.2.20240310",

View File

@@ -4,9 +4,6 @@ CWD = Path(__file__).parent
locale_dir = CWD / "locale"
backup_version_1d9a002d7234_1 = CWD / "backups/backup-version-1d9a002d7234-1.zip"
"""1d9a002d7234: add referenced_recipe to ingredients"""
backup_version_44e8d670719d_1 = CWD / "backups/backup-version-44e8d670719d-1.zip"
"""44e8d670719d: add extras to shopping lists, list items, and ingredient foods"""

View File

@@ -15,12 +15,14 @@ def test_seed_foods(api_client: TestClient, unique_user: TestUser):
CREATED_FOODS = 2687
database = unique_user.repos
# Check that the foods was created
foods = database.ingredient_foods.page_all(PaginationQuery(page=1, per_page=-1)).items
assert len(foods) == 0
resp = api_client.post(api_routes.groups_seeders_foods, json={"locale": "en-US"}, headers=unique_user.token)
assert resp.status_code == 200
# Check that the foods was created
foods = database.ingredient_foods.page_all(PaginationQuery(page=1, per_page=-1)).items
assert len(foods) == CREATED_FOODS
@@ -29,37 +31,29 @@ def test_seed_units(api_client: TestClient, unique_user: TestUser):
CREATED_UNITS = 24
database = unique_user.repos
# Check that the foods was created
units = database.ingredient_units.page_all(PaginationQuery(page=1, per_page=-1)).items
assert len(units) == 0
resp = api_client.post(api_routes.groups_seeders_units, json={"locale": "en-US"}, headers=unique_user.token)
assert resp.status_code == 200
# Check that the foods was created
units = database.ingredient_units.page_all(PaginationQuery(page=1, per_page=-1)).items
assert len(units) == CREATED_UNITS
# Check that the "pint" unit was created and includes standardized data
pint_found = False
for unit in units:
if unit.name != "pint":
continue
pint_found = True
assert unit.standard_quantity == 2
assert unit.standard_unit == "cup"
assert pint_found
def test_seed_labels(api_client: TestClient, unique_user: TestUser):
CREATED_LABELS = 32
database = unique_user.repos
# Check that the foods was created
labels = database.group_multi_purpose_labels.page_all(PaginationQuery(page=1, per_page=-1)).items
assert len(labels) == 0
resp = api_client.post(api_routes.groups_seeders_labels, json={"locale": "en-US"}, headers=unique_user.token)
assert resp.status_code == 200
# Check that the foods was created
labels = database.group_multi_purpose_labels.page_all(PaginationQuery(page=1, per_page=-1)).items
assert len(labels) == CREATED_LABELS

View File

@@ -7,7 +7,7 @@ from fastapi.testclient import TestClient
from pydantic import UUID4
from mealie.schema.household.group_shopping_list import ShoppingListItemOut, ShoppingListOut
from mealie.schema.recipe.recipe_ingredient import IngredientUnit, SaveIngredientFood
from mealie.schema.recipe.recipe_ingredient import SaveIngredientFood
from tests import utils
from tests.utils import api_routes
from tests.utils.factories import random_int, random_string
@@ -641,96 +641,6 @@ def test_shopping_list_items_with_zero_quantity(
assert len(as_json["listItems"]) == len(normal_items + zero_qty_items) - 1
def test_shopping_list_merge_standard_unit(
api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut
):
unit_1_cup_data = {"name": random_string(), "standardQuantity": 1, "standardUnit": "cup"}
unit_2_cup_data = {"name": random_string(), "standardQuantity": 2, "standardUnit": "cup"}
unit_1_out = api_client.post(api_routes.units, json=unit_1_cup_data, headers=unique_user.token)
unit_2_out = api_client.post(api_routes.units, json=unit_2_cup_data, headers=unique_user.token)
unit_1 = IngredientUnit.model_validate(unit_1_out.json())
unit_2 = IngredientUnit.model_validate(unit_2_out.json())
list_item_1_data = create_item(shopping_list.id, unit_id=str(unit_1.id), note="mealie-food")
list_item_2_data = create_item(shopping_list.id, unit_id=str(unit_2.id), note="mealie-food")
response = api_client.post(
api_routes.households_shopping_items_create_bulk,
json=[list_item_1_data, list_item_2_data],
headers=unique_user.token,
)
as_json = utils.assert_deserialize(response, 201)
assert len(as_json["createdItems"]) == 1
item_out = as_json["createdItems"][0]
# should use larger "2 cup" unit (a la "pint")
assert item_out["unitId"] == str(unit_2.id)
# calculate quantity by summing base "cup" amount and dividing by 2 (a la pints)
assert item_out["quantity"] == (list_item_1_data["quantity"] + (list_item_2_data["quantity"] * 2)) / 2
def test_shopping_list_merge_standard_unit_different_foods(
api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut
):
unit_1_cup_data = {"name": random_string(), "standardQuantity": 1, "standardUnit": "cup"}
unit_2_cup_data = {"name": random_string(), "standardQuantity": 2, "standardUnit": "cup"}
unit_1_out = api_client.post(api_routes.units, json=unit_1_cup_data, headers=unique_user.token)
unit_2_out = api_client.post(api_routes.units, json=unit_2_cup_data, headers=unique_user.token)
unit_1 = IngredientUnit.model_validate(unit_1_out.json())
unit_2 = IngredientUnit.model_validate(unit_2_out.json())
list_item_1_data = create_item(shopping_list.id, unit_id=str(unit_1.id), note="mealie-food-1")
list_item_2_data = create_item(shopping_list.id, unit_id=str(unit_2.id), note="mealie-food-2")
response = api_client.post(
api_routes.households_shopping_items_create_bulk,
json=[list_item_1_data, list_item_2_data],
headers=unique_user.token,
)
as_json = utils.assert_deserialize(response, 201)
assert len(as_json["createdItems"]) == 2
for in_data, out_data in zip(
[list_item_1_data, list_item_2_data], [as_json["createdItems"][0], as_json["createdItems"][1]], strict=True
):
assert in_data["quantity"] == out_data["quantity"]
assert out_data["unit"]
assert in_data["unit_id"] == out_data["unit"]["id"]
assert in_data["note"] == out_data["note"]
def test_shopping_list_merge_standard_unit_incompatible_units(
api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut
):
unit_1_data = {"name": random_string(), "standardQuantity": 1, "standardUnit": "cup"}
unit_2_data = {"name": random_string(), "standardQuantity": 2, "standardUnit": "gram"}
unit_1_out = api_client.post(api_routes.units, json=unit_1_data, headers=unique_user.token)
unit_2_out = api_client.post(api_routes.units, json=unit_2_data, headers=unique_user.token)
unit_1 = IngredientUnit.model_validate(unit_1_out.json())
unit_2 = IngredientUnit.model_validate(unit_2_out.json())
list_item_1_data = create_item(shopping_list.id, unit_id=str(unit_1.id), note="mealie-food")
list_item_2_data = create_item(shopping_list.id, unit_id=str(unit_2.id), note="mealie-food")
response = api_client.post(
api_routes.households_shopping_items_create_bulk,
json=[list_item_1_data, list_item_2_data],
headers=unique_user.token,
)
as_json = utils.assert_deserialize(response, 201)
assert len(as_json["createdItems"]) == 2
for in_data, out_data in zip(
[list_item_1_data, list_item_2_data], [as_json["createdItems"][0], as_json["createdItems"][1]], strict=True
):
assert in_data["quantity"] == out_data["quantity"]
assert out_data["unit"]
assert in_data["unit_id"] == out_data["unit"]["id"]
assert in_data["note"] == out_data["note"]
def test_shopping_list_item_extras(
api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut
) -> None:

View File

@@ -2,7 +2,7 @@ import pytest
from fastapi.testclient import TestClient
from mealie.schema.recipe.recipe_ingredient import RegisteredParser
from tests.unit_tests.ingredient_parser.test_ingredient_parser import TestIngredient
from tests.unit_tests.test_ingredient_parser import TestIngredient
from tests.utils import api_routes
from tests.utils.fixture_schemas import TestUser

View File

@@ -1,309 +0,0 @@
import pint
import pytest
from mealie.schema.recipe.recipe_ingredient import CreateIngredientUnit
from mealie.services.parser_services.parser_utils import UnitConverter, UnitNotFound, merge_quantity_and_unit
from tests.utils import random_string
def test_uc_parse_string():
uc = UnitConverter()
parsed = uc.parse("cup")
assert isinstance(parsed, pint.Unit)
assert (str(parsed)) == "cup"
def test_uc_parse_unit():
uc = UnitConverter()
parsed = uc.parse(uc.parse("cup"))
assert isinstance(parsed, pint.Unit)
assert (str(parsed)) == "cup"
def test_uc_parse_invalid():
uc = UnitConverter()
input_str = random_string()
parsed = uc.parse(input_str)
assert not isinstance(parsed, pint.Unit)
assert parsed == input_str
def test_uc_parse_invalid_strict():
uc = UnitConverter()
input_str = random_string()
with pytest.raises(UnitNotFound):
uc.parse(input_str, strict=True)
@pytest.mark.parametrize("pre_parse_1", [True, False])
@pytest.mark.parametrize("pre_parse_2", [True, False])
def test_can_convert(pre_parse_1: bool, pre_parse_2: bool):
unit_1 = "cup"
unit_2 = "pint"
uc = UnitConverter()
if pre_parse_1:
unit_1 = uc.parse(unit_1)
if pre_parse_2:
unit_2 = uc.parse(unit_2)
assert uc.can_convert(unit_1, unit_2)
@pytest.mark.parametrize("pre_parse_1", [True, False])
@pytest.mark.parametrize("pre_parse_2", [True, False])
def test_cannot_convert(pre_parse_1: bool, pre_parse_2: bool):
unit_1 = "cup"
unit_2 = "pound"
uc = UnitConverter()
if pre_parse_1:
unit_1 = uc.parse(unit_1)
if pre_parse_2:
unit_2 = uc.parse(unit_2)
assert not uc.can_convert(unit_1, unit_2)
def test_cannot_convert_invalid_unit():
uc = UnitConverter()
assert not uc.can_convert("cup", random_string())
assert not uc.can_convert(random_string(), "cup")
def test_can_convert_same_unit():
uc = UnitConverter()
assert uc.can_convert("cup", "cup")
def test_can_convert_volume_ounce():
uc = UnitConverter()
assert uc.can_convert("ounce", "cup")
assert uc.can_convert("cup", "ounce")
def test_convert_simple():
uc = UnitConverter()
quantity, unit = uc.convert(1, "cup", "pint")
assert isinstance(unit, pint.Unit)
assert str(unit) == "pint"
assert quantity == 1 / 2
@pytest.mark.parametrize("pre_parse_1", [True, False])
@pytest.mark.parametrize("pre_parse_2", [True, False])
def test_convert_pre_parsed(pre_parse_1: bool, pre_parse_2: bool):
unit_1 = "cup"
unit_2 = "pint"
uc = UnitConverter()
if pre_parse_1:
unit_1 = uc.parse(unit_1)
if pre_parse_2:
unit_2 = uc.parse(unit_2)
quantity, unit = uc.convert(1, unit_1, unit_2)
assert isinstance(unit, pint.Unit)
assert str(unit) == "pint"
assert quantity == 1 / 2
def test_convert_weight():
uc = UnitConverter()
quantity, unit = uc.convert(16, "ounce", "pound")
assert isinstance(unit, pint.Unit)
assert str(unit) == "pound"
assert quantity == 1
def test_convert_zero_quantity():
uc = UnitConverter()
quantity, unit = uc.convert(0, "cup", "pint")
assert isinstance(unit, pint.Unit)
assert quantity == 0
def test_convert_invalid_unit():
uc = UnitConverter()
with pytest.raises(UnitNotFound):
uc.convert(1, "pound", random_string())
def test_convert_incompatible_units():
uc = UnitConverter()
with pytest.raises(pint.errors.DimensionalityError):
uc.convert(1, "pound", "cup")
def test_convert_volume_ounce():
uc = UnitConverter()
quantity, unit = uc.convert(8, "ounce", "cup")
assert isinstance(unit, pint.Unit)
assert str(unit) == "cup"
assert quantity == 1
def test_merge_same_unit():
uc = UnitConverter()
quantity, unit = uc.merge(1, "cup", 2, "cup")
assert isinstance(unit, pint.Unit)
assert str(unit) == "cup"
assert quantity == 3
@pytest.mark.parametrize("pre_parse_1", [True, False])
@pytest.mark.parametrize("pre_parse_2", [True, False])
def test_merge_compatible_units(pre_parse_1: bool, pre_parse_2: bool):
unit_1 = "cup"
unit_2 = "pint"
uc = UnitConverter()
if pre_parse_1:
unit_1 = uc.parse(unit_1)
if pre_parse_2:
unit_2 = uc.parse(unit_2)
quantity, unit = uc.merge(1, unit_1, 1, unit_2)
assert isinstance(unit, pint.Unit)
# 1 cup + 1 pint = 1 cup + 2 cups = 3 cups
assert quantity == 3
def test_merge_weight_units():
uc = UnitConverter()
quantity, unit = uc.merge(8, "ounce", 8, "ounce")
assert isinstance(unit, pint.Unit)
assert str(unit) == "ounce"
assert quantity == 16
def test_merge_different_weight_units():
uc = UnitConverter()
quantity, unit = uc.merge(1, "pound", 8, "ounce")
assert isinstance(unit, pint.Unit)
# 1 pound + 8 ounces = 16 ounces + 8 ounces = 24 ounces
assert str(unit) == "pound"
assert quantity == 1.5
def test_merge_zero_quantities():
uc = UnitConverter()
quantity, unit = uc.merge(0, "cup", 1, "cup")
assert isinstance(unit, pint.Unit)
assert str(unit) == "cup"
assert quantity == 1
def test_merge_invalid_unit():
uc = UnitConverter()
with pytest.raises(UnitNotFound):
uc.merge(1, "pound", 1, random_string())
def test_merge_incompatible_units():
uc = UnitConverter()
with pytest.raises(pint.errors.DimensionalityError):
uc.merge(1, "pound", 1, "cup")
def test_merge_negative_quantity():
uc = UnitConverter()
quantity, unit = uc.merge(-1, "cup", 2, "cup")
assert isinstance(unit, pint.Unit)
assert str(unit) == "cup"
assert quantity == 1
def test_merge_volume_ounce():
uc = UnitConverter()
quantity, unit = uc.merge(4, "ounce", 1, "cup")
assert isinstance(unit, pint.Unit)
assert str(unit) == "fluid_ounce" # converted automatically from ounce
assert quantity == 12
def test_merge_quantity_and_unit_simple():
unit_1 = CreateIngredientUnit(name="mealie_cup", standard_quantity=1, standard_unit="cup")
unit_2 = CreateIngredientUnit(name="mealie_cup", standard_quantity=1, standard_unit="cup")
quantity, unit = merge_quantity_and_unit(1, unit_1, 2, unit_2)
assert quantity == 3
assert unit.name == "mealie_cup"
def test_merge_quantity_and_unit_invalid():
unit_1 = CreateIngredientUnit(name="mealie_cup", standard_quantity=1, standard_unit="cup")
unit_2 = CreateIngredientUnit(name="mealie_random", standard_quantity=1, standard_unit=random_string())
with pytest.raises(UnitNotFound):
merge_quantity_and_unit(1, unit_1, 1, unit_2)
def test_merge_quantity_and_unit_compatible():
unit_1 = CreateIngredientUnit(name="mealie_pint", standard_quantity=1, standard_unit="pint")
unit_2 = CreateIngredientUnit(name="mealie_cup", standard_quantity=1, standard_unit="cup")
quantity, unit = merge_quantity_and_unit(1, unit_1, 1, unit_2)
# 1 pint + 1 cup = 2 pints + 1 cup = 3 cups, converted to pint = 1.5 pint
assert quantity == 1.5
assert unit.name == "mealie_pint"
def test_merge_quantity_and_unit_selects_larger_unit():
unit_1 = CreateIngredientUnit(name="mealie_pint", standard_quantity=1, standard_unit="pint")
unit_2 = CreateIngredientUnit(name="mealie_cup", standard_quantity=1, standard_unit="cup")
quantity, unit = merge_quantity_and_unit(2, unit_1, 4, unit_2)
# 2 pint + 4 cup = 4 cups + 4 cups = 8 cups, should be returned as pint (larger unit)
assert quantity == 4
assert unit.name == "mealie_pint"
def test_merge_quantity_and_unit_selects_smaller_unit():
unit_1 = CreateIngredientUnit(name="mealie_pint", standard_quantity=1, standard_unit="pint")
unit_2 = CreateIngredientUnit(name="mealie_cup", standard_quantity=1, standard_unit="cup")
quantity, unit = merge_quantity_and_unit(0.125, unit_1, 0.5, unit_2)
# 0.125 pint + 0.5 cup = 0.25 cup + 0.5 cup = 0.75 cup, should be returned as cup (smaller for < 1)
assert quantity == 0.75
assert unit.name == "mealie_cup"
def test_merge_quantity_and_unit_missing_standard_data():
unit_1 = CreateIngredientUnit(name="mealie_cup", standard_quantity=1, standard_unit="cup")
unit_2 = CreateIngredientUnit(name="mealie_cup_no_std", standard_quantity=None, standard_unit=None)
with pytest.raises(ValueError):
merge_quantity_and_unit(1, unit_1, 1, unit_2)
def test_merge_quantity_and_unit_volume_ounce():
unit_1 = CreateIngredientUnit(name="mealie_oz", standard_quantity=1, standard_unit="ounce")
unit_2 = CreateIngredientUnit(name="mealie_cup", standard_quantity=1, standard_unit="cup")
quantity, unit = merge_quantity_and_unit(8, unit_1, 1, unit_2)
assert quantity == 2
assert unit.name == "mealie_cup"

View File

@@ -1,26 +1,11 @@
from uuid import UUID
import pytest
from sqlalchemy.orm import Session
from mealie.repos.all_repositories import AllRepositories, get_repositories
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient, SaveIngredientUnit
from mealie.schema.user.user import GroupBase
from tests.utils.factories import random_int, random_string
from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser
@pytest.fixture()
def unique_local_group_id(unfiltered_database: AllRepositories) -> str:
return str(unfiltered_database.groups.create(GroupBase(name=random_string())).id)
@pytest.fixture()
def unique_db(session: Session, unique_local_group_id: str) -> AllRepositories:
return get_repositories(session, group_id=unique_local_group_id)
def test_unit_merger(unique_user: TestUser):
database = unique_user.repos
recipe: Recipe | None = None
@@ -66,79 +51,3 @@ def test_unit_merger(unique_user: TestUser):
for ingredient in recipe.recipe_ingredient:
assert ingredient.unit.id == unit_1.id # type: ignore
@pytest.mark.parametrize("standard_field", ["name", "plural_name", "abbreviation", "plural_abbreviation"])
@pytest.mark.parametrize("use_bulk", [True, False])
def test_auto_inject_standardization(unique_db: AllRepositories, standard_field: str, use_bulk: bool):
unit_in = SaveIngredientUnit(name=random_string(), group_id=unique_db.group_id).model_dump()
unit_in[standard_field] = "gallon"
if use_bulk:
out_many = unique_db.ingredient_units.create_many([unit_in])
assert len(out_many) == 1
unit_out = out_many[0]
else:
unit_out = unique_db.ingredient_units.create(unit_in)
assert unit_out.standard_unit == "cup"
assert unit_out.standard_quantity == 16
def test_dont_auto_inject_random(unique_db: AllRepositories):
unit_in = SaveIngredientUnit(name=random_string(), group_id=unique_db.group_id)
unit_out = unique_db.ingredient_units.create(unit_in)
assert unit_out.standard_quantity is None
assert unit_out.standard_unit is None
def test_auto_inject_other_language(unique_db: AllRepositories):
# Inject custom unit map
GALLON = random_string()
unique_db.ingredient_units._standardized_unit_map = {GALLON: "gallon"}
# Create unit with translated value
unit_in = SaveIngredientUnit(name=GALLON, group_id=unique_db.group_id)
unit_out = unique_db.ingredient_units.create(unit_in)
assert unit_out.standard_unit == "cup"
assert unit_out.standard_quantity == 16
@pytest.mark.parametrize("name", ["custom-mealie-unit", "gallon"])
def test_user_standardization(unique_db: AllRepositories, name: str):
unit_in = SaveIngredientUnit(
name=name,
group_id=unique_db.group_id,
standard_quantity=random_int(1, 10),
standard_unit=random_string(),
)
unit_out = unique_db.ingredient_units.create(unit_in)
assert unit_out.standard_quantity == unit_in.standard_quantity
assert unit_out.standard_unit == unit_in.standard_unit
def test_ignore_incomplete_standardization(unique_db: AllRepositories):
unit_in = SaveIngredientUnit(
name=random_string(),
group_id=unique_db.group_id,
standard_quantity=random_int(1, 10),
standard_unit=None,
)
unit_out = unique_db.ingredient_units.create(unit_in)
assert unit_out.standard_quantity is None
assert unit_out.standard_unit is None
unit_in = SaveIngredientUnit(
name=random_string(),
group_id=unique_db.group_id,
standard_quantity=None,
standard_unit=random_string(),
)
unit_out = unique_db.ingredient_units.create(unit_in)
assert unit_out.standard_quantity is None
assert unit_out.standard_unit is None

View File

@@ -217,22 +217,6 @@ def _b9e516e2d3b3_add_household_to_recipe_last_made_household_to_foods_and_tools
assert not tool.households_with_tool
def _a39c7f1826e3_add_unit_standardization_fields(session: Session):
groups = session.query(Group).all()
for group in groups:
# test_data.backup_version_1d9a002d7234_1 has a non-anonymized "pint" unit
# and has not yet run the standardization migration.
pint_units = (
session.query(IngredientUnitModel)
.filter(IngredientUnitModel.group_id == group.id, IngredientUnitModel.name == "pint")
.all()
)
for unit in pint_units:
assert unit.standard_quantity == 2
assert unit.standard_unit == "cup"
def test_database_restore_data():
"""
This tests real user backups to make sure the data is restored correctly. The data has been anonymized, but
@@ -243,7 +227,6 @@ def test_database_restore_data():
"""
backup_paths = [
test_data.backup_version_1d9a002d7234_1,
test_data.backup_version_44e8d670719d_1,
test_data.backup_version_44e8d670719d_2,
test_data.backup_version_44e8d670719d_3,
@@ -262,7 +245,6 @@ def test_database_restore_data():
_d7c6efd2de42_migrate_favorites_and_ratings_to_user_ratings,
_86054b40fd06_added_query_filter_string_to_cookbook_and_mealplan,
_b9e516e2d3b3_add_household_to_recipe_last_made_household_to_foods_and_tools,
_a39c7f1826e3_add_unit_standardization_fields,
]
settings = get_app_settings()

62
uv.lock generated
View File

@@ -850,7 +850,6 @@ dependencies = [
{ name = "paho-mqtt" },
{ name = "pillow" },
{ name = "pillow-heif" },
{ name = "pint" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyhumps" },
@@ -923,8 +922,7 @@ requires-dist = [
{ name = "orjson", specifier = "==3.11.7" },
{ name = "paho-mqtt", specifier = "==1.6.1" },
{ name = "pillow", specifier = "==12.1.1" },
{ name = "pillow-heif", specifier = "==1.2.1" },
{ name = "pint", specifier = ">=0.25" },
{ name = "pillow-heif", specifier = "==1.3.0" },
{ name = "psycopg2-binary", marker = "extra == 'pgsql'", specifier = "==2.9.11" },
{ name = "pydantic", specifier = "==2.12.5" },
{ name = "pydantic-settings", specifier = "==2.13.1" },
@@ -960,7 +958,7 @@ dev = [
{ name = "pytest", specifier = "==9.0.2" },
{ name = "pytest-asyncio", specifier = "==1.3.0" },
{ name = "rich", specifier = "==14.3.3" },
{ name = "ruff", specifier = "==0.15.2" },
{ name = "ruff", specifier = "==0.15.4" },
{ name = "types-python-dateutil", specifier = "==2.9.0.20260124" },
{ name = "types-python-slugify", specifier = "==8.0.2.20240310" },
{ name = "types-pyyaml", specifier = "==6.0.12.20250915" },
@@ -1239,20 +1237,20 @@ wheels = [
[[package]]
name = "pillow-heif"
version = "1.2.1"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pillow" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f4/68bd0465dc0494c22e23334dde0a9c52dec5afe98cf5a40abb47f75e1b08/pillow_heif-1.2.1.tar.gz", hash = "sha256:29be44d636269e2d779b4aec629bc056ec7260b734a16b4d3bb284c49c200274", size = 17128668, upload-time = "2026-02-18T11:20:48.643Z" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/58/2df4fc42840633e01c97b75965cb1bc6e14425973b92382391650e97e4b7/pillow_heif-1.3.0.tar.gz", hash = "sha256:af8d2bda85e395677d5bb50d7bda3b5655c946cc95b913b5e7222fabacbb467f", size = 17133211, upload-time = "2026-02-27T12:21:36.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/4a/8d674f384d1d9d2d84193dcb7f6b41eeb240ed127f45f07114770205c8a1/pillow_heif-1.2.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c5a3c8fec8cf63f6d9170f092a210e76d584beef5a5b0f5e8fbfa171eb27520a", size = 4666941, upload-time = "2026-02-18T11:20:03.957Z" },
{ url = "https://files.pythonhosted.org/packages/f9/4b/f08a3c535bb116ed74b7851eb5cc0ae105338f4d5e921d7547137322e68e/pillow_heif-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:add430cf7f5340eaa70c2e57af59655515fd415b2b93dde0baec87be48debd0f", size = 3392297, upload-time = "2026-02-18T11:20:06.167Z" },
{ url = "https://files.pythonhosted.org/packages/92/eb/39f38534894c1e12cd4526683b7aec5a1d403cd8f76efc4efce762826658/pillow_heif-1.2.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9a6daa0f88fe5fa76b72c848615836368d0577a108059e3070615c1e50551dc", size = 5843458, upload-time = "2026-02-18T11:20:07.737Z" },
{ url = "https://files.pythonhosted.org/packages/f9/07/431be5fc0d34d67dedc918a1a0f9f9aa7f3973557698fb7d35b5d136c45c/pillow_heif-1.2.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35a355df6024f09b0e46b56bb5805c275a8ca7dc67e1da2be245aee3a70c82ec", size = 5577826, upload-time = "2026-02-18T11:20:09.407Z" },
{ url = "https://files.pythonhosted.org/packages/e5/1b/ecdedae3225b75e870980bc6f505af2387e38f9ca85a110e59b4328e7263/pillow_heif-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:33d84eb1c40d9c63d2ea869e6290f5b59ebf4421ed16090796be60b8e3b2a061", size = 6885202, upload-time = "2026-02-18T11:20:11.421Z" },
{ url = "https://files.pythonhosted.org/packages/b6/27/a9a2ca1aa874166064ced4c8fbb03f106ef71b9eda0cc45688c3e12376d4/pillow_heif-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2522a54df26f996993189326208513a6c8458ac89de51644a89b19fcda712539", size = 6510193, upload-time = "2026-02-18T11:20:13.435Z" },
{ url = "https://files.pythonhosted.org/packages/41/63/7d1f5358b6a4c1214e5ef25e2166992381e6a30c0da933ab56ce2c278ea9/pillow_heif-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:0c965277fde806c7c628b16f9a45f4a7b10c32c390ce7d70c0572499a5d8426f", size = 5483265, upload-time = "2026-02-18T11:20:15.741Z" },
{ url = "https://files.pythonhosted.org/packages/67/f7/e0b13500470421536fdfe01acfc4c56daccd3d23655605aa04cfb30cc58c/pillow_heif-1.3.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:079abbcaeb42ef0849a33f35c1a96ccd431feb56b242a0d4f8435a1c8ca02c7d", size = 4667382, upload-time = "2026-02-27T12:20:45.149Z" },
{ url = "https://files.pythonhosted.org/packages/a6/fb/beb62f26231e7c76d235e993d18b4ddb3d2d427e93539b8e6eb8dc188fa7/pillow_heif-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76c33f80ec111492642b98309db98516a7fba9677dcda9ec5fe9111b7e38d720", size = 3392733, upload-time = "2026-02-27T12:20:46.606Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/3d86e237b3a20c909f62a50e8cb3492ed6206675136d1ebddb168920261b/pillow_heif-1.3.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33b838d06e2fd730f806af5a76bfc4cd3de9d146d88d37572e40f7a4c4ff8221", size = 5844247, upload-time = "2026-02-27T12:20:49.669Z" },
{ url = "https://files.pythonhosted.org/packages/58/2a/826faf3df8c9ef9a19dc96bdfff34cf76f8b025540d5f931903d2c64f25c/pillow_heif-1.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f92b387af891cf5d98f52e79eeaf51ee7955a54fe2deeec12bfb7519e41464b5", size = 5578692, upload-time = "2026-02-27T12:20:51.219Z" },
{ url = "https://files.pythonhosted.org/packages/f1/4e/59f74fede18e3e06f98a448488df2fa4e5de1c159419d47ca345a51a0da0/pillow_heif-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f9f73246836f93f99343cbc3052b61d212d27e59ddf40262d494a1e3e54af31a", size = 6885928, upload-time = "2026-02-27T12:20:52.934Z" },
{ url = "https://files.pythonhosted.org/packages/0a/e4/bce83d2f4d703f418d252b509a6c9d6de52dc7c4eedcf6a286f871dea824/pillow_heif-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:84c3816742c2e49176e651895e73c555b9c3b0f3561d60230242f3be0c9d272b", size = 6511151, upload-time = "2026-02-27T12:20:54.236Z" },
{ url = "https://files.pythonhosted.org/packages/41/c2/87d433a9681c79e0926d8a113ea153d592ec49d1d7aa7278ee798bc490f2/pillow_heif-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:5f0db0bf49162fb1d73d13340a9576b3a2805bde026a9a40038bcc1a0878d710", size = 5483578, upload-time = "2026-02-27T12:20:55.715Z" },
]
[[package]]
@@ -1742,27 +1740,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.2"
version = "0.15.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" }
sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" },
{ url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" },
{ url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" },
{ url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" },
{ url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" },
{ url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" },
{ url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" },
{ url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" },
{ url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" },
{ url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" },
{ url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" },
{ url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" },
{ url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" },
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
{ url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" },
{ url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" },
{ url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" },
{ url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" },
{ url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" },
{ url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" },
{ url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" },
{ url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" },
{ url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" },
{ url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" },
{ url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" },
{ url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" },
]
[[package]]