Compare commits

..

2 Commits

Author SHA1 Message Date
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 358 additions and 1311 deletions

View File

@@ -88,25 +88,6 @@
validate-on="input" 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 --> <!-- Option Select -->
<v-select <v-select
v-else-if="inputField.type === fieldTypes.SELECT" v-else-if="inputField.type === fieldTypes.SELECT"

View File

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

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Recipe Actions Data", "recipe-actions-data": "Recipe Actions Data",
"new-recipe-action": "New Recipe Action", "new-recipe-action": "New Recipe Action",
"edit-recipe-action": "Edit 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", "create-alias": "Create Alias",
"manage-aliases": "Manage Aliases", "manage-aliases": "Manage Aliases",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Recipe Actions Data", "recipe-actions-data": "Recipe Actions Data",
"new-recipe-action": "New Recipe Action", "new-recipe-action": "New Recipe Action",
"edit-recipe-action": "Edit 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", "create-alias": "Create Alias",
"manage-aliases": "إدارة الأسماء المستعارة", "manage-aliases": "إدارة الأسماء المستعارة",

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-types": {
"link": "Link",
"post": "Post"
}
}, },
"create-alias": "Създаване на псевдоним", "create-alias": "Създаване на псевдоним",
"manage-aliases": "Управление на псевдоними", "manage-aliases": "Управление на псевдоними",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Dades d'accions de receptes", "recipe-actions-data": "Dades d'accions de receptes",
"new-recipe-action": "Nova acció de receptes", "new-recipe-action": "Nova acció de receptes",
"edit-recipe-action": "Edita 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", "create-alias": "Crea un àlies",
"manage-aliases": "Gestiona els àlies", "manage-aliases": "Gestiona els àlies",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Data akcí receptu", "recipe-actions-data": "Data akcí receptu",
"new-recipe-action": "Nová akce receptu", "new-recipe-action": "Nová akce receptu",
"edit-recipe-action": "Upravit akci 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", "create-alias": "Vytvořit alias",
"manage-aliases": "Spravovat aliasy", "manage-aliases": "Spravovat aliasy",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Opskriftshandlinger", "recipe-actions-data": "Opskriftshandlinger",
"new-recipe-action": "Ny opskriftshandling", "new-recipe-action": "Ny opskriftshandling",
"edit-recipe-action": "Rediger opskriftshandling", "edit-recipe-action": "Rediger opskriftshandling",
"action-type": "Handlingstype" "action-type": "Handlingstype",
"action-types": {
"link": "Link",
"post": "Post"
}
}, },
"create-alias": "Opret alias", "create-alias": "Opret alias",
"manage-aliases": "Administrer aliaser", "manage-aliases": "Administrer aliaser",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Rezept-Aktionen Daten", "recipe-actions-data": "Rezept-Aktionen Daten",
"new-recipe-action": "Neue Rezept-Aktion", "new-recipe-action": "Neue Rezept-Aktion",
"edit-recipe-action": "Rezept-Aktion bearbeiten", "edit-recipe-action": "Rezept-Aktion bearbeiten",
"action-type": "Aktionstyp" "action-type": "Aktionstyp",
"action-types": {
"link": "Link",
"post": "Post"
}
}, },
"create-alias": "Alias erstellen", "create-alias": "Alias erstellen",
"manage-aliases": "Aliasse verwalten", "manage-aliases": "Aliasse verwalten",

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-types": {
"link": "Link",
"post": "Post"
}
}, },
"create-alias": "Δημιουργία ψευδώνυμου", "create-alias": "Δημιουργία ψευδώνυμου",
"manage-aliases": "Διαχείριση ψευδωνύμων", "manage-aliases": "Διαχείριση ψευδωνύμων",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Recipe Actions Data", "recipe-actions-data": "Recipe Actions Data",
"new-recipe-action": "New Recipe Action", "new-recipe-action": "New Recipe Action",
"edit-recipe-action": "Edit 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", "create-alias": "Create Alias",
"manage-aliases": "Manage Aliases", "manage-aliases": "Manage Aliases",

View File

@@ -1134,22 +1134,7 @@
"example-unit-singular": "ex: Tablespoon", "example-unit-singular": "ex: Tablespoon",
"example-unit-plural": "ex: Tablespoons", "example-unit-plural": "ex: Tablespoons",
"example-unit-abbreviation-singular": "ex: Tbsp", "example-unit-abbreviation-singular": "ex: Tbsp",
"example-unit-abbreviation-plural": "ex: Tbsps", "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"
}
}, },
"labels": { "labels": {
"seed-dialog-text": "Seed the database with common labels based on your local language.", "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", "recipe-actions-data": "Datos de Acciones de Receta",
"new-recipe-action": "Nueva Acción de Receta", "new-recipe-action": "Nueva Acción de Receta",
"edit-recipe-action": "Editar 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", "create-alias": "Crear un Alias",
"manage-aliases": "Administrar Alias", "manage-aliases": "Administrar Alias",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Retsepti kasutusteave", "recipe-actions-data": "Retsepti kasutusteave",
"new-recipe-action": "Uus retsepti tegevus", "new-recipe-action": "Uus retsepti tegevus",
"edit-recipe-action": "Muuda retsepti tegevust", "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", "create-alias": "Loo alias",
"manage-aliases": "Halda aliaseid", "manage-aliases": "Halda aliaseid",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Reseptin toimintojen käyttötiedot", "recipe-actions-data": "Reseptin toimintojen käyttötiedot",
"new-recipe-action": "Uusi reseptin toiminto", "new-recipe-action": "Uusi reseptin toiminto",
"edit-recipe-action": "Muuta reseptin toimintoa", "edit-recipe-action": "Muuta reseptin toimintoa",
"action-type": "Toiminnon tyyppi" "action-type": "Toiminnon tyyppi",
"action-types": {
"link": "Link",
"post": "Post"
}
}, },
"create-alias": "Luo alias", "create-alias": "Luo alias",
"manage-aliases": "Hallitse aliaksia", "manage-aliases": "Hallitse aliaksia",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Données des actions de recette", "recipe-actions-data": "Données des actions de recette",
"new-recipe-action": "Nouvelle action de recette", "new-recipe-action": "Nouvelle action de recette",
"edit-recipe-action": "Modifier l'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", "create-alias": "Créer un alias",
"manage-aliases": "Gérer les alias", "manage-aliases": "Gérer les alias",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Données des actions de recette", "recipe-actions-data": "Données des actions de recette",
"new-recipe-action": "Nouvelle action de recette", "new-recipe-action": "Nouvelle action de recette",
"edit-recipe-action": "Modifier l'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", "create-alias": "Créer un alias",
"manage-aliases": "Gérer les alias", "manage-aliases": "Gérer les alias",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Données des actions de recette", "recipe-actions-data": "Données des actions de recette",
"new-recipe-action": "Nouvelle action de recette", "new-recipe-action": "Nouvelle action de recette",
"edit-recipe-action": "Modifier l'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", "create-alias": "Créer un alias",
"manage-aliases": "Gérer les alias", "manage-aliases": "Gérer les alias",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Datos das Acións da Receita", "recipe-actions-data": "Datos das Acións da Receita",
"new-recipe-action": "Nova Ación da Receita", "new-recipe-action": "Nova Ación da Receita",
"edit-recipe-action": "Editar 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", "create-alias": "Crear Pseudónimo",
"manage-aliases": "Xestionar Pseudónimos", "manage-aliases": "Xestionar Pseudónimos",

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-types": {
"link": "Link",
"post": "Post"
}
}, },
"create-alias": "יצירת שם נרדף", "create-alias": "יצירת שם נרדף",
"manage-aliases": "נהל שמות נרדפים", "manage-aliases": "נהל שמות נרדפים",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Recipe Actions Data", "recipe-actions-data": "Recipe Actions Data",
"new-recipe-action": "New Recipe Action", "new-recipe-action": "New Recipe Action",
"edit-recipe-action": "Edit 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", "create-alias": "Create Alias",
"manage-aliases": "Manage Aliases", "manage-aliases": "Manage Aliases",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Receptekkel kapcsolatos tevékenységek adatai", "recipe-actions-data": "Receptekkel kapcsolatos tevékenységek adatai",
"new-recipe-action": "Új recept tevékenység", "new-recipe-action": "Új recept tevékenység",
"edit-recipe-action": "Recept tevékenység szerkesztése", "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", "create-alias": "Alias készítése",
"manage-aliases": "Alias kezelése", "manage-aliases": "Alias kezelése",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Gögn fyrir uppskriftaaðgerðir", "recipe-actions-data": "Gögn fyrir uppskriftaaðgerðir",
"new-recipe-action": "Nú uppskriftaaðgerð", "new-recipe-action": "Nú uppskriftaaðgerð",
"edit-recipe-action": "Breyta 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", "create-alias": "Stofna samheiti",
"manage-aliases": "Vinna með samheiti", "manage-aliases": "Vinna með samheiti",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Dati Azioni Ricetta", "recipe-actions-data": "Dati Azioni Ricetta",
"new-recipe-action": "Nuova Azione Ricetta", "new-recipe-action": "Nuova Azione Ricetta",
"edit-recipe-action": "Modifica 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", "create-alias": "Crea Alias",
"manage-aliases": "Gestisci Alias", "manage-aliases": "Gestisci Alias",

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-types": {
"link": "Link",
"post": "Post"
}
}, },
"create-alias": "エイリアスを作成", "create-alias": "エイリアスを作成",
"manage-aliases": "エイリアスの管理", "manage-aliases": "エイリアスの管理",

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-types": {
"link": "Link",
"post": "Post"
}
}, },
"create-alias": "별칭 생성", "create-alias": "별칭 생성",
"manage-aliases": "별칭 관리", "manage-aliases": "별칭 관리",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Recipe Actions Data", "recipe-actions-data": "Recipe Actions Data",
"new-recipe-action": "New Recipe Action", "new-recipe-action": "New Recipe Action",
"edit-recipe-action": "Edit 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", "create-alias": "Create Alias",
"manage-aliases": "Manage Aliases", "manage-aliases": "Manage Aliases",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Recepšu darbību dati", "recipe-actions-data": "Recepšu darbību dati",
"new-recipe-action": "Jaunas receptes darbība", "new-recipe-action": "Jaunas receptes darbība",
"edit-recipe-action": "Rediģēt receptes darbību", "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", "create-alias": "Izveidot aizstājvārdu",
"manage-aliases": "Pārvaldīt aizstājvārdus", "manage-aliases": "Pārvaldīt aizstājvārdus",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Gegevens voor acties met recepten", "recipe-actions-data": "Gegevens voor acties met recepten",
"new-recipe-action": "Nieuwe actie met recept", "new-recipe-action": "Nieuwe actie met recept",
"edit-recipe-action": "Pas actie met recept aan", "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", "create-alias": "Maak alias",
"manage-aliases": "Beheer aliassen ", "manage-aliases": "Beheer aliassen ",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Opppgavehandlings-data", "recipe-actions-data": "Opppgavehandlings-data",
"new-recipe-action": "Ny oppskriftshandling", "new-recipe-action": "Ny oppskriftshandling",
"edit-recipe-action": "Rediger oppskriftshandling", "edit-recipe-action": "Rediger oppskriftshandling",
"action-type": "Handlingstype" "action-type": "Handlingstype",
"action-types": {
"link": "Link",
"post": "Post"
}
}, },
"create-alias": "Opprett alias", "create-alias": "Opprett alias",
"manage-aliases": "Administrer aliaser", "manage-aliases": "Administrer aliaser",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Dane akcji przepisu", "recipe-actions-data": "Dane akcji przepisu",
"new-recipe-action": "", "new-recipe-action": "",
"edit-recipe-action": "Edycja akcji przepisu", "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", "create-alias": "Utwórz alias",
"manage-aliases": "Zarządzaj aliasami", "manage-aliases": "Zarządzaj aliasami",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Dados de Ações de Receita", "recipe-actions-data": "Dados de Ações de Receita",
"new-recipe-action": "Nova Ação de Receita", "new-recipe-action": "Nova Ação de Receita",
"edit-recipe-action": "Alterar 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", "create-alias": "Criar Apelido",
"manage-aliases": "Gerenciar apelidos", "manage-aliases": "Gerenciar apelidos",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Dados das Ações da Receita", "recipe-actions-data": "Dados das Ações da Receita",
"new-recipe-action": "Nova Ação da Receita", "new-recipe-action": "Nova Ação da Receita",
"edit-recipe-action": "Editar 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", "create-alias": "Criar Pseudónimo",
"manage-aliases": "Gerir Pseudónimos", "manage-aliases": "Gerir Pseudónimos",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Date acțiuni rețetă", "recipe-actions-data": "Date acțiuni rețetă",
"new-recipe-action": "Acțiune rețetă nouă", "new-recipe-action": "Acțiune rețetă nouă",
"edit-recipe-action": "Editează Acțiune Rețetă", "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", "create-alias": "Creează Alias",
"manage-aliases": "Gestionare Aliasuri", "manage-aliases": "Gestionare Aliasuri",

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-types": {
"link": "Link",
"post": "Post"
}
}, },
"create-alias": "Создать псевдоним", "create-alias": "Создать псевдоним",
"manage-aliases": "Управление псевдонимами", "manage-aliases": "Управление псевдонимами",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Dáta akcií receptu", "recipe-actions-data": "Dáta akcií receptu",
"new-recipe-action": "Nová akcia receptu", "new-recipe-action": "Nová akcia receptu",
"edit-recipe-action": "Upraviť akciu 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", "create-alias": "Vytvoriť alias",
"manage-aliases": "Spravovať aliasy", "manage-aliases": "Spravovať aliasy",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Podatki o opravilu na receptu", "recipe-actions-data": "Podatki o opravilu na receptu",
"new-recipe-action": "Novo opravilo na receptu", "new-recipe-action": "Novo opravilo na receptu",
"edit-recipe-action": "Urejaj 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", "create-alias": "Ustvari alias",
"manage-aliases": "Upravljanje z aliasi", "manage-aliases": "Upravljanje z aliasi",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Recipe Actions Data", "recipe-actions-data": "Recipe Actions Data",
"new-recipe-action": "New Recipe Action", "new-recipe-action": "New Recipe Action",
"edit-recipe-action": "Edit 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", "create-alias": "Create Alias",
"manage-aliases": "Manage Aliases", "manage-aliases": "Manage Aliases",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Data för receptåtgärder", "recipe-actions-data": "Data för receptåtgärder",
"new-recipe-action": "Ny receptåtgärd", "new-recipe-action": "Ny receptåtgärd",
"edit-recipe-action": "Redigera 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", "create-alias": "Skapa alias",
"manage-aliases": "Hantera alias", "manage-aliases": "Hantera alias",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Tarif İşlemleri Verisi", "recipe-actions-data": "Tarif İşlemleri Verisi",
"new-recipe-action": "Yeni Tarif İşlemi", "new-recipe-action": "Yeni Tarif İşlemi",
"edit-recipe-action": "Tarif İşlemini Düzenle", "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", "create-alias": "Takma Ad Oluştur",
"manage-aliases": "Takma Adları Yönet", "manage-aliases": "Takma Adları Yönet",

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-types": {
"link": "Link",
"post": "Post"
}
}, },
"create-alias": "Створити псевдонім", "create-alias": "Створити псевдонім",
"manage-aliases": "Керувати псевдонімами", "manage-aliases": "Керувати псевдонімами",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Recipe Actions Data", "recipe-actions-data": "Recipe Actions Data",
"new-recipe-action": "New Recipe Action", "new-recipe-action": "New Recipe Action",
"edit-recipe-action": "Edit 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", "create-alias": "Create Alias",
"manage-aliases": "Manage Aliases", "manage-aliases": "Manage Aliases",

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-types": {
"link": "Link",
"post": "Post"
}
}, },
"create-alias": "创建别名", "create-alias": "创建别名",
"manage-aliases": "管理别名", "manage-aliases": "管理别名",

View File

@@ -1168,7 +1168,11 @@
"recipe-actions-data": "Recipe Actions Data", "recipe-actions-data": "Recipe Actions Data",
"new-recipe-action": "New Recipe Action", "new-recipe-action": "New Recipe Action",
"edit-recipe-action": "Edit 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", "create-alias": "Create Alias",
"manage-aliases": "Manage Aliases", "manage-aliases": "Manage Aliases",

View File

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

View File

@@ -58,13 +58,3 @@ export interface QueryFilterJSONPart {
relationalOperator?: RelationalKeyword | RelationalOperator | null; relationalOperator?: RelationalKeyword | RelationalOperator | null;
value?: string | string[] | 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; pluralAbbreviation?: string | null;
useAbbreviation?: boolean; useAbbreviation?: boolean;
aliases?: CreateIngredientUnitAlias[]; aliases?: CreateIngredientUnitAlias[];
standardQuantity?: number | null;
standardUnit?: string | null;
} }
export interface CreateIngredientUnitAlias { export interface CreateIngredientUnitAlias {
name: string; name: string;
@@ -176,8 +174,6 @@ export interface IngredientUnit {
pluralAbbreviation?: string | null; pluralAbbreviation?: string | null;
useAbbreviation?: boolean; useAbbreviation?: boolean;
aliases?: IngredientUnitAlias[]; aliases?: IngredientUnitAlias[];
standardQuantity?: number | null;
standardUnit?: string | null;
createdAt?: string | null; createdAt?: string | null;
updatedAt?: string | null; updatedAt?: string | null;
} }
@@ -502,8 +498,6 @@ export interface SaveIngredientUnit {
pluralAbbreviation?: string | null; pluralAbbreviation?: string | null;
useAbbreviation?: boolean; useAbbreviation?: boolean;
aliases?: CreateIngredientUnitAlias[]; aliases?: CreateIngredientUnitAlias[];
standardQuantity?: number | null;
standardUnit?: string | null;
groupId: string; groupId: string;
} }
export interface ScrapeRecipe { export interface ScrapeRecipe {

View File

@@ -158,7 +158,6 @@ import RecipeDataAliasManagerDialog from "~/components/Domain/Recipe/RecipeDataA
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import type { CreateIngredientUnit, IngredientUnit, IngredientUnitAlias } from "~/lib/api/types/recipe"; 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 { useLocales } from "~/composables/use-locales";
import { normalizeFilter } from "~/composables/use-utils"; import { normalizeFilter } from "~/composables/use-utils";
import { useUnitStore } from "~/composables/store"; import { useUnitStore } from "~/composables/store";
@@ -220,16 +219,6 @@ const tableHeaders: TableHeaders[] = [
show: true, show: true,
sortable: 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"), text: i18n.t("general.date-added"),
value: "createdAt", value: "createdAt",
@@ -242,12 +231,7 @@ const { store: unitStore, actions: unitActions } = useUnitStore();
// ============================================================ // ============================================================
// Form items (shared) // Form items (shared)
type StandardizedUnitTypeOption = { const formItems: AutoFormItems = [
text: string;
value: StandardizedUnitType;
};
const formItems = computed<AutoFormItems>(() => [
{ {
cols: 8, cols: 8,
label: i18n.t("general.name"), label: i18n.t("general.name"),
@@ -278,59 +262,6 @@ const formItems = computed<AutoFormItems>(() => [
varName: "description", varName: "description",
type: fieldTypes.TEXT, 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"), section: i18n.t("general.settings"),
cols: 4, cols: 4,
@@ -344,7 +275,7 @@ const formItems = computed<AutoFormItems>(() => [
varName: "fraction", varName: "fraction",
type: fieldTypes.BOOLEAN, type: fieldTypes.BOOLEAN,
}, },
]); ];
// ============================================================ // ============================================================
// Create // Create

View File

@@ -1,15 +1,6 @@
import type { VForm as VuetifyForm } from "vuetify/components/VForm"; import type { VForm as VuetifyForm } from "vuetify/components/VForm";
type FormFieldType type FormFieldType = "text" | "textarea" | "list" | "select" | "object" | "boolean" | "color" | "password";
= | "text"
| "textarea"
| "number"
| "list"
| "select"
| "object"
| "boolean"
| "color"
| "password";
export type FormValidationRule = (value: any) => boolean | string; export type FormValidationRule = (value: any) => boolean | string;
@@ -18,13 +9,6 @@ export interface FormSelectOption {
value?: string; value?: string;
} }
export interface FormFieldNumberInputConfig {
min?: number;
max?: number;
precision?: number;
controlVariant?: "split" | "default" | "hidden" | "stacked";
}
export interface FormField { export interface FormField {
section?: string; section?: string;
sectionDetails?: string; sectionDetails?: string;
@@ -36,7 +20,6 @@ export interface FormField {
rules?: FormValidationRule[]; rules?: FormValidationRule[];
disableUpdate?: boolean; disableUpdate?: boolean;
disableCreate?: boolean; disableCreate?: boolean;
numberInputConfig?: FormFieldNumberInputConfig;
options?: FormSelectOption[]; options?: FormSelectOption[];
selectReturnValue?: "text" | "value"; 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", 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 # Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
plural_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 @dataclass
class LocaleConfig: class LocaleConfig:
key: str
name: str name: str
dir: LocaleTextDirection = LocaleTextDirection.LTR dir: LocaleTextDirection = LocaleTextDirection.LTR
plural_food_handling: LocalePluralFoodHandling = LocalePluralFoodHandling.ALWAYS plural_food_handling: LocalePluralFoodHandling = LocalePluralFoodHandling.ALWAYS
LOCALE_CONFIG: dict[str, LocaleConfig] = { LOCALE_CONFIG: dict[str, LocaleConfig] = {
"af-ZA": LocaleConfig(key="af-ZA", name="Afrikaans (Afrikaans)"), "af-ZA": LocaleConfig(name="Afrikaans (Afrikaans)"),
"ar-SA": LocaleConfig(key="ar-SA", name="العربية (Arabic)", dir=LocaleTextDirection.RTL), "ar-SA": LocaleConfig(name="العربية (Arabic)", dir=LocaleTextDirection.RTL),
"bg-BG": LocaleConfig(key="bg-BG", name="Български (Bulgarian)"), "bg-BG": LocaleConfig(name="Български (Bulgarian)"),
"ca-ES": LocaleConfig(key="ca-ES", name="Català (Catalan)"), "ca-ES": LocaleConfig(name="Català (Catalan)"),
"cs-CZ": LocaleConfig(key="cs-CZ", name="Čeština (Czech)"), "cs-CZ": LocaleConfig(name="Čeština (Czech)"),
"da-DK": LocaleConfig(key="da-DK", name="Dansk (Danish)"), "da-DK": LocaleConfig(name="Dansk (Danish)"),
"de-DE": LocaleConfig(key="de-DE", name="Deutsch (German)"), "de-DE": LocaleConfig(name="Deutsch (German)"),
"el-GR": LocaleConfig(key="el-GR", name="Ελληνικά (Greek)"), "el-GR": LocaleConfig(name="Ελληνικά (Greek)"),
"en-GB": LocaleConfig( "en-GB": LocaleConfig(name="British English", plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT),
key="en-GB", 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)"),
"en-US": LocaleConfig( "et-EE": LocaleConfig(name="Eesti (Estonian)"),
key="en-US", name="American English", plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT "fi-FI": LocaleConfig(name="Suomi (Finnish)"),
), "fr-BE": LocaleConfig(name="Belge (Belgian)"),
"es-ES": LocaleConfig(key="es-ES", name="Español (Spanish)"), "fr-CA": LocaleConfig(name="Français canadien (Canadian French)"),
"et-EE": LocaleConfig(key="et-EE", name="Eesti (Estonian)"), "fr-FR": LocaleConfig(name="Français (French)"),
"fi-FI": LocaleConfig(key="fi-FI", name="Suomi (Finnish)"), "gl-ES": LocaleConfig(name="Galego (Galician)"),
"fr-BE": LocaleConfig(key="fr-BE", name="Belge (Belgian)"), "he-IL": LocaleConfig(name="עברית (Hebrew)", dir=LocaleTextDirection.RTL),
"fr-CA": LocaleConfig(key="fr-CA", name="Français canadien (Canadian French)"), "hr-HR": LocaleConfig(name="Hrvatski (Croatian)"),
"fr-FR": LocaleConfig(key="fr-FR", name="Français (French)"), "hu-HU": LocaleConfig(name="Magyar (Hungarian)"),
"gl-ES": LocaleConfig(key="gl-ES", name="Galego (Galician)"), "is-IS": LocaleConfig(name="Íslenska (Icelandic)"),
"he-IL": LocaleConfig(key="he-IL", name="עברית (Hebrew)", dir=LocaleTextDirection.RTL), "it-IT": LocaleConfig(name="Italiano (Italian)"),
"hr-HR": LocaleConfig(key="hr-HR", name="Hrvatski (Croatian)"), "ja-JP": LocaleConfig(name="日本語 (Japanese)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"hu-HU": LocaleConfig(key="hu-HU", name="Magyar (Hungarian)"), "ko-KR": LocaleConfig(name="한국어 (Korean)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"is-IS": LocaleConfig(key="is-IS", name="Íslenska (Icelandic)"), "lt-LT": LocaleConfig(name="Lietuvių (Lithuanian)"),
"it-IT": LocaleConfig(key="it-IT", name="Italiano (Italian)"), "lv-LV": LocaleConfig(name="Latviešu (Latvian)"),
"ja-JP": LocaleConfig(key="ja-JP", name="日本語 (Japanese)", plural_food_handling=LocalePluralFoodHandling.NEVER), "nl-NL": LocaleConfig(name="Nederlands (Dutch)"),
"ko-KR": LocaleConfig(key="ko-KR", name="한국어 (Korean)", plural_food_handling=LocalePluralFoodHandling.NEVER), "no-NO": LocaleConfig(name="Norsk (Norwegian)"),
"lt-LT": LocaleConfig(key="lt-LT", name="Lietuvių (Lithuanian)"), "pl-PL": LocaleConfig(name="Polski (Polish)"),
"lv-LV": LocaleConfig(key="lv-LV", name="Latviešu (Latvian)"), "pt-BR": LocaleConfig(name="Português do Brasil (Brazilian Portuguese)"),
"nl-NL": LocaleConfig(key="nl-NL", name="Nederlands (Dutch)"), "pt-PT": LocaleConfig(name="Português (Portuguese)"),
"no-NO": LocaleConfig(key="no-NO", name="Norsk (Norwegian)"), "ro-RO": LocaleConfig(name="Română (Romanian)"),
"pl-PL": LocaleConfig(key="pl-PL", name="Polski (Polish)"), "ru-RU": LocaleConfig(name="Pусский (Russian)"),
"pt-BR": LocaleConfig(key="pt-BR", name="Português do Brasil (Brazilian Portuguese)"), "sk-SK": LocaleConfig(name="Slovenčina (Slovak)"),
"pt-PT": LocaleConfig(key="pt-PT", name="Português (Portuguese)"), "sl-SI": LocaleConfig(name="Slovenščina (Slovenian)"),
"ro-RO": LocaleConfig(key="ro-RO", name="Română (Romanian)"), "sr-SP": LocaleConfig(name="српски (Serbian)"),
"ru-RU": LocaleConfig(key="ru-RU", name="Pусский (Russian)"), "sv-SE": LocaleConfig(name="Svenska (Swedish)"),
"sk-SK": LocaleConfig(key="sk-SK", name="Slovenčina (Slovak)"), "tr-TR": LocaleConfig(name="Türkçe (Turkish)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"sl-SI": LocaleConfig(key="sl-SI", name="Slovenščina (Slovenian)"), "uk-UA": LocaleConfig(name="Українська (Ukrainian)"),
"sr-SP": LocaleConfig(key="sr-SP", name="српски (Serbian)"), "vi-VN": LocaleConfig(name="Tiếng Việt (Vietnamese)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"sv-SE": LocaleConfig(key="sv-SE", name="Svenska (Swedish)"), "zh-CN": LocaleConfig(name="简体中文 (Chinese simplified)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"tr-TR": LocaleConfig(key="tr-TR", name="Türkçe (Turkish)", plural_food_handling=LocalePluralFoodHandling.NEVER), "zh-TW": LocaleConfig(name="繁體中文 (Chinese traditional)", 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
),
} }

View File

@@ -1,119 +1,17 @@
from collections.abc import Iterable from pydantic import UUID4
from pydantic import UUID4, BaseModel
from sqlalchemy import select from sqlalchemy import select
from mealie.db.models.recipe.ingredient import IngredientUnitModel from mealie.db.models.recipe.ingredient import IngredientUnitModel
from mealie.lang.providers import get_locale_context from mealie.schema.recipe.recipe_ingredient import IngredientUnit
from mealie.schema.recipe.recipe_ingredient import IngredientUnit, StandardizedUnitType
from .repository_generic import GroupRepositoryGeneric from .repository_generic import GroupRepositoryGeneric
class RepositoryUnit(GroupRepositoryGeneric[IngredientUnit, IngredientUnitModel]): 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: def _get_unit(self, id: UUID4) -> IngredientUnitModel:
stmt = select(self.model).filter_by(**self._filter_builder(**{"id": id})) stmt = select(self.model).filter_by(**self._filter_builder(**{"id": id}))
return self.session.execute(stmt).scalars().one() 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: def merge(self, from_unit: UUID4, to_unit: UUID4) -> IngredientUnit | None:
from_model = self._get_unit(from_unit) from_model = self._get_unit(from_unit)
to_model = self._get_unit(to_unit) to_model = self._get_unit(to_unit)

View File

@@ -1,4 +1,3 @@
import json
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from logging import Logger from logging import Logger
from pathlib import Path from pathlib import Path
@@ -12,8 +11,6 @@ class AbstractSeeder(ABC):
Abstract class for seeding data. Abstract class for seeding data.
""" """
resources = Path(__file__).parent / "resources"
def __init__(self, db: AllRepositories, logger: Logger | None = None): def __init__(self, db: AllRepositories, logger: Logger | None = None):
""" """
Initialize the abstract seeder. Initialize the abstract seeder.
@@ -22,14 +19,7 @@ class AbstractSeeder(ABC):
""" """
self.repos = db self.repos = db
self.logger = logger or get_logger("Data Seeder") self.logger = logger or get_logger("Data Seeder")
self.resources = Path(__file__).parent / "resources"
@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"))
@abstractmethod @abstractmethod
def seed(self, locale: str | None = None) -> None: ... def seed(self, locale: str | None = None) -> None: ...

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
import datetime import datetime
import enum import enum
from enum import StrEnum
from fractions import Fraction from fractions import Fraction
from typing import ClassVar from typing import ClassVar
from uuid import UUID, uuid4 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): class UnitFoodBase(MealieModel):
id: UUID4 | None = None id: UUID4 | None = None
name: str name: str
@@ -132,6 +109,9 @@ class IngredientFood(CreateIngredientFood):
except AttributeError: except AttributeError:
return v return v
def is_on_hand(self, household_slug: str) -> bool:
return household_slug in self.households_with_tool
class IngredientFoodPagination(PaginationBase): class IngredientFoodPagination(PaginationBase):
items: list[IngredientFood] items: list[IngredientFood]
@@ -150,21 +130,7 @@ class CreateIngredientUnit(UnitFoodBase):
abbreviation: str = "" abbreviation: str = ""
plural_abbreviation: str | None = "" plural_abbreviation: str | None = ""
use_abbreviation: bool = False use_abbreviation: bool = False
aliases: list[CreateIngredientUnitAlias] = [] 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): class SaveIngredientUnit(CreateIngredientUnit):

View File

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

View File

@@ -28,7 +28,6 @@ from mealie.schema.recipe.recipe_ingredient import (
) )
from mealie.schema.response.pagination import OrderDirection, PaginationQuery from mealie.schema.response.pagination import OrderDirection, PaginationQuery
from mealie.services.parser_services._base import DataMatcher from mealie.services.parser_services._base import DataMatcher
from mealie.services.parser_services.parser_utils import UnitConverter, merge_quantity_and_unit
class ShoppingListService: class ShoppingListService:
@@ -42,7 +41,8 @@ class ShoppingListService:
self.list_refs = repos.group_shopping_list_recipe_refs 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) 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""" """Check to see if this item can be merged with another item"""
if any( if any(
@@ -50,28 +50,16 @@ class ShoppingListService:
item1.checked, item1.checked,
item2.checked, item2.checked,
item1.food_id != item2.food_id, item1.food_id != item2.food_id,
item1.unit_id != item2.unit_id,
] ]
): ):
return False 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 # if foods match, we can merge, otherwise compare the notes
return bool(item1.food_id) or item1.note == item2.note return bool(item1.food_id) or item1.note == item2.note
@staticmethod
def merge_items( def merge_items(
self,
from_item: ShoppingListItemCreate | ShoppingListItemUpdateBulk, from_item: ShoppingListItemCreate | ShoppingListItemUpdateBulk,
to_item: ShoppingListItemCreate | ShoppingListItemUpdateBulk | ShoppingListItemOut, to_item: ShoppingListItemCreate | ShoppingListItemUpdateBulk | ShoppingListItemOut,
) -> ShoppingListItemUpdate: ) -> ShoppingListItemUpdate:
@@ -81,20 +69,7 @@ class ShoppingListService:
Attributes of the `to_item` take priority over the `from_item`, except extras with overlapping keys 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: if to_item.note != from_item.note:
to_item.note = " | ".join([note for note in [to_item.note, from_item.note] if 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._food_fuzzy_match_threshold = food_fuzzy_match_threshold
self._unit_fuzzy_match_threshold = unit_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._foods_by_alias: dict[str, IngredientFood] | None = None
self._units_by_alias: dict[str, IngredientUnit] | None = None self._units_by_alias: dict[str, IngredientUnit] | None = None
@property @property
def foods_by_id(self) -> dict[UUID4, IngredientFood]: def foods_by_alias(self) -> dict[str, IngredientFood]:
if self._foods_by_id is None: if self._foods_by_alias is None:
foods_repo = self.repos.ingredient_foods foods_repo = self.repos.ingredient_foods
query = PaginationQuery(page=1, per_page=-1) query = PaginationQuery(page=1, per_page=-1)
all_foods = foods_repo.page_all(query).items 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] = {} foods_by_alias: dict[str, IngredientFood] = {}
for food in self.foods_by_id.values(): for food in all_foods:
if food.name: if food.name:
foods_by_alias[IngredientFoodModel.normalize(food.name)] = food foods_by_alias[IngredientFoodModel.normalize(food.name)] = food
if food.plural_name: if food.plural_name:
@@ -76,8 +56,12 @@ class DataMatcher:
@property @property
def units_by_alias(self) -> dict[str, IngredientUnit]: def units_by_alias(self) -> dict[str, IngredientUnit]:
if self._units_by_alias is None: 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] = {} units_by_alias: dict[str, IngredientUnit] = {}
for unit in self.units_by_id.values(): for unit in all_units:
if unit.name: if unit.name:
units_by_alias[IngredientUnitModel.normalize(unit.name)] = unit units_by_alias[IngredientUnitModel.normalize(unit.name)] = unit
if unit.plural_name: if unit.plural_name:

View File

@@ -1,2 +1 @@
from .string_utils import * 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

@@ -46,7 +46,6 @@ dependencies = [
"typing-extensions==4.15.0", "typing-extensions==4.15.0",
"itsdangerous==2.2.0", "itsdangerous==2.2.0",
"ingredient-parser-nlp==2.5.0", "ingredient-parser-nlp==2.5.0",
"pint>=0.25",
] ]
[project.scripts] [project.scripts]
@@ -71,7 +70,7 @@ dev = [
"pytest==9.0.2", "pytest==9.0.2",
"pytest-asyncio==1.3.0", "pytest-asyncio==1.3.0",
"rich==14.3.3", "rich==14.3.3",
"ruff==0.15.2", "ruff==0.15.3",
"types-PyYAML==6.0.12.20250915", "types-PyYAML==6.0.12.20250915",
"types-python-dateutil==2.9.0.20260124", "types-python-dateutil==2.9.0.20260124",
"types-python-slugify==8.0.2.20240310", "types-python-slugify==8.0.2.20240310",

View File

@@ -4,9 +4,6 @@ CWD = Path(__file__).parent
locale_dir = CWD / "locale" 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" backup_version_44e8d670719d_1 = CWD / "backups/backup-version-44e8d670719d-1.zip"
"""44e8d670719d: add extras to shopping lists, list items, and ingredient foods""" """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 CREATED_FOODS = 2687
database = unique_user.repos database = unique_user.repos
# Check that the foods was created
foods = database.ingredient_foods.page_all(PaginationQuery(page=1, per_page=-1)).items foods = database.ingredient_foods.page_all(PaginationQuery(page=1, per_page=-1)).items
assert len(foods) == 0 assert len(foods) == 0
resp = api_client.post(api_routes.groups_seeders_foods, json={"locale": "en-US"}, headers=unique_user.token) resp = api_client.post(api_routes.groups_seeders_foods, json={"locale": "en-US"}, headers=unique_user.token)
assert resp.status_code == 200 assert resp.status_code == 200
# Check that the foods was created
foods = database.ingredient_foods.page_all(PaginationQuery(page=1, per_page=-1)).items foods = database.ingredient_foods.page_all(PaginationQuery(page=1, per_page=-1)).items
assert len(foods) == CREATED_FOODS assert len(foods) == CREATED_FOODS
@@ -29,37 +31,29 @@ def test_seed_units(api_client: TestClient, unique_user: TestUser):
CREATED_UNITS = 24 CREATED_UNITS = 24
database = unique_user.repos database = unique_user.repos
# Check that the foods was created
units = database.ingredient_units.page_all(PaginationQuery(page=1, per_page=-1)).items units = database.ingredient_units.page_all(PaginationQuery(page=1, per_page=-1)).items
assert len(units) == 0 assert len(units) == 0
resp = api_client.post(api_routes.groups_seeders_units, json={"locale": "en-US"}, headers=unique_user.token) resp = api_client.post(api_routes.groups_seeders_units, json={"locale": "en-US"}, headers=unique_user.token)
assert resp.status_code == 200 assert resp.status_code == 200
# Check that the foods was created
units = database.ingredient_units.page_all(PaginationQuery(page=1, per_page=-1)).items units = database.ingredient_units.page_all(PaginationQuery(page=1, per_page=-1)).items
assert len(units) == CREATED_UNITS 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): def test_seed_labels(api_client: TestClient, unique_user: TestUser):
CREATED_LABELS = 32 CREATED_LABELS = 32
database = unique_user.repos 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 labels = database.group_multi_purpose_labels.page_all(PaginationQuery(page=1, per_page=-1)).items
assert len(labels) == 0 assert len(labels) == 0
resp = api_client.post(api_routes.groups_seeders_labels, json={"locale": "en-US"}, headers=unique_user.token) resp = api_client.post(api_routes.groups_seeders_labels, json={"locale": "en-US"}, headers=unique_user.token)
assert resp.status_code == 200 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 labels = database.group_multi_purpose_labels.page_all(PaginationQuery(page=1, per_page=-1)).items
assert len(labels) == CREATED_LABELS assert len(labels) == CREATED_LABELS

View File

@@ -7,7 +7,7 @@ from fastapi.testclient import TestClient
from pydantic import UUID4 from pydantic import UUID4
from mealie.schema.household.group_shopping_list import ShoppingListItemOut, ShoppingListOut 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 import utils
from tests.utils import api_routes from tests.utils import api_routes
from tests.utils.factories import random_int, random_string 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 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( def test_shopping_list_item_extras(
api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut
) -> None: ) -> None:

View File

@@ -2,7 +2,7 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from mealie.schema.recipe.recipe_ingredient import RegisteredParser 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 import api_routes
from tests.utils.fixture_schemas import TestUser 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 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 import Recipe
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient, SaveIngredientUnit from mealie.schema.recipe.recipe_ingredient import RecipeIngredient, SaveIngredientUnit
from mealie.schema.user.user import GroupBase from tests.utils.factories import random_string
from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser 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): def test_unit_merger(unique_user: TestUser):
database = unique_user.repos database = unique_user.repos
recipe: Recipe | None = None recipe: Recipe | None = None
@@ -66,79 +51,3 @@ def test_unit_merger(unique_user: TestUser):
for ingredient in recipe.recipe_ingredient: for ingredient in recipe.recipe_ingredient:
assert ingredient.unit.id == unit_1.id # type: ignore 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 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(): def test_database_restore_data():
""" """
This tests real user backups to make sure the data is restored correctly. The data has been anonymized, but 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 = [ backup_paths = [
test_data.backup_version_1d9a002d7234_1,
test_data.backup_version_44e8d670719d_1, test_data.backup_version_44e8d670719d_1,
test_data.backup_version_44e8d670719d_2, test_data.backup_version_44e8d670719d_2,
test_data.backup_version_44e8d670719d_3, test_data.backup_version_44e8d670719d_3,
@@ -262,7 +245,6 @@ def test_database_restore_data():
_d7c6efd2de42_migrate_favorites_and_ratings_to_user_ratings, _d7c6efd2de42_migrate_favorites_and_ratings_to_user_ratings,
_86054b40fd06_added_query_filter_string_to_cookbook_and_mealplan, _86054b40fd06_added_query_filter_string_to_cookbook_and_mealplan,
_b9e516e2d3b3_add_household_to_recipe_last_made_household_to_foods_and_tools, _b9e516e2d3b3_add_household_to_recipe_last_made_household_to_foods_and_tools,
_a39c7f1826e3_add_unit_standardization_fields,
] ]
settings = get_app_settings() settings = get_app_settings()

42
uv.lock generated
View File

@@ -850,7 +850,6 @@ dependencies = [
{ name = "paho-mqtt" }, { name = "paho-mqtt" },
{ name = "pillow" }, { name = "pillow" },
{ name = "pillow-heif" }, { name = "pillow-heif" },
{ name = "pint" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyhumps" }, { name = "pyhumps" },
@@ -924,7 +923,6 @@ requires-dist = [
{ name = "paho-mqtt", specifier = "==1.6.1" }, { name = "paho-mqtt", specifier = "==1.6.1" },
{ name = "pillow", specifier = "==12.1.1" }, { name = "pillow", specifier = "==12.1.1" },
{ name = "pillow-heif", specifier = "==1.2.1" }, { name = "pillow-heif", specifier = "==1.2.1" },
{ name = "pint", specifier = ">=0.25" },
{ name = "psycopg2-binary", marker = "extra == 'pgsql'", specifier = "==2.9.11" }, { name = "psycopg2-binary", marker = "extra == 'pgsql'", specifier = "==2.9.11" },
{ name = "pydantic", specifier = "==2.12.5" }, { name = "pydantic", specifier = "==2.12.5" },
{ name = "pydantic-settings", specifier = "==2.13.1" }, { name = "pydantic-settings", specifier = "==2.13.1" },
@@ -960,7 +958,7 @@ dev = [
{ name = "pytest", specifier = "==9.0.2" }, { name = "pytest", specifier = "==9.0.2" },
{ name = "pytest-asyncio", specifier = "==1.3.0" }, { name = "pytest-asyncio", specifier = "==1.3.0" },
{ name = "rich", specifier = "==14.3.3" }, { name = "rich", specifier = "==14.3.3" },
{ name = "ruff", specifier = "==0.15.2" }, { name = "ruff", specifier = "==0.15.3" },
{ name = "types-python-dateutil", specifier = "==2.9.0.20260124" }, { name = "types-python-dateutil", specifier = "==2.9.0.20260124" },
{ name = "types-python-slugify", specifier = "==8.0.2.20240310" }, { name = "types-python-slugify", specifier = "==8.0.2.20240310" },
{ name = "types-pyyaml", specifier = "==6.0.12.20250915" }, { name = "types-pyyaml", specifier = "==6.0.12.20250915" },
@@ -1742,27 +1740,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.2" version = "0.15.3"
source = { registry = "https://pypi.org/simple" } 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/3c/3b/20d9a0bc954d51b63f20cf710cf506bfe675d1e6138139342dd5ccc90326/ruff-0.15.3.tar.gz", hash = "sha256:78757853320d8ddb9da24e614ef69a37bcbcfd477e5a6435681188d4bce4eaa1", size = 4569031, upload-time = "2026-02-26T15:39:38.015Z" }
wheels = [ 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/ed/00/c544ab1d70f86dc50a2f2a8e1262e5af5025897ccd820415f559f9f2f63f/ruff-0.15.3-py3-none-linux_armv6l.whl", hash = "sha256:f7df0fd6f889a8d8de2ddb48a9eb55150954400f2157ea15b21a2f49ecaaf988", size = 10444066, upload-time = "2026-02-26T15:39:47.708Z" },
{ 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/fb/15/9dee3f4e891261adbd690f8c6f075418a7cd76e845601b00a0da2ae2ad6e/ruff-0.15.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0198b5445197d443c3bbf2cc358f4bd477fb3951e3c7f2babc13e9bb490614a8", size = 10853125, upload-time = "2026-02-26T15:40:18.943Z" },
{ 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/88/ba/fc5aeda852c89faf821d36c951df866117342e88439e1b1e1e762a07b7fd/ruff-0.15.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:adf95b5be57b25fbbbc07cd68d37414bee8729e807ad0217219558027186967e", size = 10180833, upload-time = "2026-02-26T15:40:13.282Z" },
{ 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/06/87/e2f80a39164476fac4d45752a0d4721d6645f40b7f851e48add12af9947e/ruff-0.15.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b56dbd9cd86489ccbad96bb58fa4c958342b5510fdeb60ea13d9d3566bd845c", size = 10536806, upload-time = "2026-02-26T15:40:24.129Z" },
{ 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/fd/89/2e5bf0ed30ea3778460ea4d8cc6cb4d88ba96d9732d2c0cc33349cd65196/ruff-0.15.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6f263ce511871955d8c5401b62c7e863988ea4d0527aa0a3b1b7ecff4d4abc4", size = 10276093, upload-time = "2026-02-26T15:39:44.654Z" },
{ 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/82/cb/318206d778c7f42917ca7b0f9436cf27652d1731fe434d3c9990c4a611fa/ruff-0.15.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e90fa1bed82ffede5768232b9bd23212c547ab7cd74c752007ecade1d895ee1a", size = 11051593, upload-time = "2026-02-26T15:39:35.157Z" },
{ 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/58/8f/65ee4c1b88e49dd4c0a3fc43e81832536c7942f0c702b6f3d25db0f95d6c/ruff-0.15.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e9d53760b7061ddbe5ea9e25381332c607fc14c40bde78f8a25392a93a68d74", size = 11885820, upload-time = "2026-02-26T15:39:59.504Z" },
{ 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/db/04/d4261f6729ad9a356bc6e3223ba297acf3b66118cef4795b4a8953b255ff/ruff-0.15.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec90e3b78c56c4acca4264d371dd48e29215ecb673cc2fa3c4b799b72050e491", size = 11340583, upload-time = "2026-02-26T15:39:50.781Z" },
{ 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/24/84/490f38b2bc104e0fdc9496c2a66a48fb2d24a01de46ba0c60c4f6c4d4590/ruff-0.15.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7ce448fd395f822e34c8f6f7dfcd84b6726340082950858f92c4daa6baf8915", size = 11160701, upload-time = "2026-02-26T15:40:02.447Z" },
{ 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/ad/25/eae9cb7b6c28b425ed8cbe797da89c78146071102181ba74c4cdfd06bbeb/ruff-0.15.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14f7d763962d385f75b9b3b57fcc5661c56c20d8b1ddc9f5c881b5fa0ba499fa", size = 11111482, upload-time = "2026-02-26T15:39:56.462Z" },
{ 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/95/18/16d0b5ef143cb9e52724f18cbccb4b3c5cd4d4e2debbd95e2be3aeb64c9e/ruff-0.15.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b57084e3a3d65418d376c7023711c37cce023cd2fb038a76ba15ee21f3c2c2ee", size = 10497151, upload-time = "2026-02-26T15:40:10.64Z" },
{ 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/bf/b4/1829314241ddba07c54a742ab387da343fe56a0267a6b6498f3e2ae99821/ruff-0.15.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d567523ff7dcf3112b0f71231d18c3506dd06943359476ee64dea0f9c8f63976", size = 10281955, upload-time = "2026-02-26T15:40:16.033Z" },
{ 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/d7/93/80a4ec4bd3cf58ca9b49dccf2bd232b520db14184912fb7e0eb6f3ecc484/ruff-0.15.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4223088d255bf31a50b6640445b39f668164d64c23e5fa403edfb1e0b11122e5", size = 10766613, upload-time = "2026-02-26T15:40:21.55Z" },
{ 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/da/92/fe016b862295dc57499997e7f2edc58119469b210f4f03ccb763fa65f130/ruff-0.15.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32399ddae088970b2db6efd8d3f49981375cb828075359b6c088ed1fe63d64e1", size = 11262113, upload-time = "2026-02-26T15:39:41.5Z" },
{ 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/42/b1/77dcd05940388d9ba3de03ac4b8b598826d57935728071e1be9f2ef5b714/ruff-0.15.3-py3-none-win32.whl", hash = "sha256:1f1eb95ff614351e3a89a862b6d94e6c42c170e61916e1f20facd6c38477f5f3", size = 10509423, upload-time = "2026-02-26T15:40:05.217Z" },
{ 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/29/d5/76aab0fabbd54e8c77d02fcff2494906ba85b539d22aa9b7124f7100f008/ruff-0.15.3-py3-none-win_amd64.whl", hash = "sha256:2b22dffe5f5e1e537097aa5208684f069e495f980379c4491b1cfb198a444d0c", size = 11637739, upload-time = "2026-02-26T15:39:53.951Z" },
{ 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/61/9b4e3682dfd26054321e1b2fdb67a51361dd6ec2fb63f2b50d711f8832ae/ruff-0.15.3-py3-none-win_arm64.whl", hash = "sha256:82443c14d694d4cbd9e598ede27ef5d6f08389ccad91c933be775ea2f4e66f76", size = 10957794, upload-time = "2026-02-26T15:40:08.045Z" },
] ]
[[package]] [[package]]