Merge branch 'mealie-next' into mealie-next

This commit is contained in:
Jack Bailey 2023-11-15 08:59:35 +00:00 committed by GitHub
commit 8170e66f4f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1239 additions and 162 deletions

View File

@ -22,7 +22,15 @@ def populate_normalized_fields():
bind = op.get_bind()
session = orm.Session(bind=bind)
units = session.execute(select(IngredientUnitModel)).scalars().all()
units = (
session.execute(
select(IngredientUnitModel).options(
orm.load_only(IngredientUnitModel.name, IngredientUnitModel.abbreviation)
)
)
.scalars()
.all()
)
for unit in units:
if unit.name is not None:
unit.name_normalized = IngredientUnitModel.normalize(unit.name)
@ -32,7 +40,9 @@ def populate_normalized_fields():
session.add(unit)
foods = session.execute(select(IngredientFoodModel)).scalars().all()
foods = (
session.execute(select(IngredientFoodModel).options(orm.load_only(IngredientFoodModel.name))).scalars().all()
)
for food in foods:
if food.name is not None:
food.name_normalized = IngredientFoodModel.normalize(food.name)

View File

@ -10,7 +10,7 @@ from dataclasses import dataclass
from typing import Any
import sqlalchemy as sa
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, load_only
import mealie.db.migration_types
from alembic import op
@ -44,7 +44,7 @@ def _is_postgres():
def _get_duplicates(session: Session, model: SqlAlchemyBase) -> defaultdict[str, list[str]]:
duplicate_map: defaultdict[str, list[str]] = defaultdict(list)
for obj in session.query(model).all():
for obj in session.query(model).options(load_only(model.id, model.group_id, model.name)).all():
key = f"{obj.group_id}$${obj.name}"
duplicate_map[key].append(str(obj.id))
@ -117,9 +117,9 @@ def _resolve_duplivate_foods_units_labels():
continue
keep_id = ids[0]
keep_obj = session.query(model).filter_by(id=keep_id).first()
keep_obj = session.query(model).options(load_only(model.id)).filter_by(id=keep_id).first()
for dupe_id in ids[1:]:
dupe_obj = session.query(model).filter_by(id=dupe_id).first()
dupe_obj = session.query(model).options(load_only(model.id)).filter_by(id=dupe_id).first()
resolve_func(session, keep_obj, dupe_obj)

View File

@ -0,0 +1,106 @@
"""added plural names and alias tables for foods and units
Revision ID: ba1e4a6cfe99
Revises: dded3119c1fe
Create Date: 2023-10-19 19:22:55.369319
"""
import sqlalchemy as sa
import mealie.db.migration_types
from alembic import op
# revision identifiers, used by Alembic.
revision = "ba1e4a6cfe99"
down_revision = "dded3119c1fe"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"ingredient_units_aliases",
sa.Column("id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("unit_id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("name_normalized", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("update_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["unit_id"],
["ingredient_units.id"],
),
sa.PrimaryKeyConstraint("id", "unit_id"),
)
op.create_index(
op.f("ix_ingredient_units_aliases_created_at"), "ingredient_units_aliases", ["created_at"], unique=False
)
op.create_index(
op.f("ix_ingredient_units_aliases_name_normalized"),
"ingredient_units_aliases",
["name_normalized"],
unique=False,
)
op.create_table(
"ingredient_foods_aliases",
sa.Column("id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("food_id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("name_normalized", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("update_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["food_id"],
["ingredient_foods.id"],
),
sa.PrimaryKeyConstraint("id", "food_id"),
)
op.create_index(
op.f("ix_ingredient_foods_aliases_created_at"), "ingredient_foods_aliases", ["created_at"], unique=False
)
op.create_index(
op.f("ix_ingredient_foods_aliases_name_normalized"),
"ingredient_foods_aliases",
["name_normalized"],
unique=False,
)
op.add_column("ingredient_foods", sa.Column("plural_name", sa.String(), nullable=True))
op.add_column("ingredient_foods", sa.Column("plural_name_normalized", sa.String(), nullable=True))
op.create_index(
op.f("ix_ingredient_foods_plural_name_normalized"), "ingredient_foods", ["plural_name_normalized"], unique=False
)
op.add_column("ingredient_units", sa.Column("plural_name", sa.String(), nullable=True))
op.add_column("ingredient_units", sa.Column("plural_name_normalized", sa.String(), nullable=True))
op.create_index(
op.f("ix_ingredient_units_plural_name_normalized"), "ingredient_units", ["plural_name_normalized"], unique=False
)
op.add_column("ingredient_units", sa.Column("plural_abbreviation", sa.String(), nullable=True))
op.add_column("ingredient_units", sa.Column("plural_abbreviation_normalized", sa.String(), nullable=True))
op.create_index(
op.f("ix_ingredient_units_plural_abbreviation_normalized"),
"ingredient_units",
["plural_abbreviation_normalized"],
unique=False,
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_ingredient_units_plural_abbreviation_normalized"), table_name="ingredient_units")
op.drop_column("ingredient_units", "plural_abbreviation_normalized")
op.drop_column("ingredient_units", "plural_abbreviation")
op.drop_index(op.f("ix_ingredient_units_plural_name_normalized"), table_name="ingredient_units")
op.drop_column("ingredient_units", "plural_name_normalized")
op.drop_column("ingredient_units", "plural_name")
op.drop_index(op.f("ix_ingredient_foods_plural_name_normalized"), table_name="ingredient_foods")
op.drop_column("ingredient_foods", "plural_name_normalized")
op.drop_column("ingredient_foods", "plural_name")
op.drop_index(op.f("ix_ingredient_foods_aliases_name_normalized"), table_name="ingredient_foods_aliases")
op.drop_index(op.f("ix_ingredient_foods_aliases_created_at"), table_name="ingredient_foods_aliases")
op.drop_table("ingredient_foods_aliases")
op.drop_index(op.f("ix_ingredient_units_aliases_name_normalized"), table_name="ingredient_units_aliases")
op.drop_index(op.f("ix_ingredient_units_aliases_created_at"), table_name="ingredient_units_aliases")
op.drop_table("ingredient_units_aliases")
# ### end Alembic commands ###

View File

@ -0,0 +1,141 @@
<template>
<div>
<BaseDialog
v-model="dialog"
:title="$t('data-pages.manage-aliases')"
:icon="$globals.icons.edit"
:submit-icon="$globals.icons.check"
:submit-text="$tc('general.confirm')"
@submit="saveAliases"
@cancel="$emit('cancel')"
>
<v-card-text>
<v-container>
<v-row v-for="alias, i in aliases" :key="i">
<v-col cols="10">
<v-text-field
v-model="alias.name"
:label="$t('general.name')"
:rules="[validators.required]"
/>
</v-col>
<v-col cols="2">
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.delete,
text: $tc('general.delete'),
event: 'delete'
}
]"
@delete="deleteAlias(i)"
/>
</v-col>
</v-row>
</v-container>
</v-card-text>
<template #custom-card-action>
<BaseButton edit @click="createAlias">{{ $t('data-pages.create-alias') }}
<template #icon>
{{ $globals.icons.create }}
</template>
</BaseButton>
</template>
</BaseDialog>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, ref } from "@nuxtjs/composition-api";
import { whenever } from "@vueuse/core";
import { validators } from "~/composables/use-validators";
import { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
export interface GenericAlias {
name: string;
}
export default defineComponent({
props: {
value: {
type: Boolean,
default: false,
},
data: {
type: Object as () => IngredientFood | IngredientUnit,
required: true,
},
},
setup(props, context) {
// V-Model Support
const dialog = computed({
get: () => {
return props.value;
},
set: (val) => {
context.emit("input", val);
},
});
function createAlias() {
aliases.value.push({
"name": "",
})
}
function deleteAlias(index: number) {
aliases.value.splice(index, 1);
}
const aliases = ref<GenericAlias[]>(props.data.aliases || []);
function initAliases() {
aliases.value = [...props.data.aliases || []];
if (!aliases.value.length) {
createAlias();
}
}
initAliases();
whenever(
() => props.value,
() => {
initAliases();
},
)
function saveAliases() {
const seenAliasNames: string[] = [];
const keepAliases: GenericAlias[] = [];
aliases.value.forEach((alias) => {
if (
!alias.name
|| alias.name === props.data.name
|| alias.name === props.data.pluralName
// @ts-ignore only applies to units
|| alias.name === props.data.abbreviation
// @ts-ignore only applies to units
|| alias.name === props.data.pluralAbbreviation
|| seenAliasNames.includes(alias.name)
) {
return;
}
keepAliases.push(alias);
seenAliasNames.push(alias.name);
})
aliases.value = keepAliases;
context.emit("submit", keepAliases);
}
return {
aliases,
createAlias,
dialog,
deleteAlias,
saveAliases,
validators,
}
},
});
</script>

View File

@ -58,8 +58,12 @@
</template>
{{ $t("general.confirm") }}
</BaseButton>
<slot name="custom-card-action"></slot>
<BaseButton v-if="$listeners.submit" type="submit" @click="submitEvent">
{{ submitText }}
<template v-if="submitIcon" #icon>
{{ submitIcon }}
</template>
</BaseButton>
</slot>
</v-card-actions>
@ -109,6 +113,10 @@ export default defineComponent({
default: null,
type: Boolean,
},
submitIcon: {
type: String,
default: null,
},
submitText: {
type: String,
default: function () {

View File

@ -48,4 +48,74 @@ describe(parseIngredientText.name, () => {
expect(parseIngredientText(ingredient, false)).not.toContain("<script>");
});
test("plural test : plural qty : use abbreviation", () => {
const ingredient = createRecipeIngredient({
quantity: 2,
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", abbreviation: "tbsp", pluralAbbreviation: "tbsps", useAbbreviation: true },
food: { id: "1", name: "diced onion", pluralName: "diced onions" }
});
expect(parseIngredientText(ingredient, false)).toEqual("2 tbsps diced onions");
});
test("plural test : plural qty : not abbreviation", () => {
const ingredient = createRecipeIngredient({
quantity: 2,
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", abbreviation: "tbsp", pluralAbbreviation: "tbsps", useAbbreviation: false },
food: { id: "1", name: "diced onion", pluralName: "diced onions" }
});
expect(parseIngredientText(ingredient, false)).toEqual("2 tablespoons diced onions");
});
test("plural test : single qty : use abbreviation", () => {
const ingredient = createRecipeIngredient({
quantity: 1,
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", abbreviation: "tbsp", pluralAbbreviation: "tbsps", useAbbreviation: true },
food: { id: "1", name: "diced onion", pluralName: "diced onions" }
});
expect(parseIngredientText(ingredient, false)).toEqual("1 tbsp diced onion");
});
test("plural test : single qty : not abbreviation", () => {
const ingredient = createRecipeIngredient({
quantity: 1,
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", abbreviation: "tbsp", pluralAbbreviation: "tbsps", useAbbreviation: false },
food: { id: "1", name: "diced onion", pluralName: "diced onions" }
});
expect(parseIngredientText(ingredient, false)).toEqual("1 tablespoon diced onion");
});
test("plural test : small qty : use abbreviation", () => {
const ingredient = createRecipeIngredient({
quantity: 0.5,
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", abbreviation: "tbsp", pluralAbbreviation: "tbsps", useAbbreviation: true },
food: { id: "1", name: "diced onion", pluralName: "diced onions" }
});
expect(parseIngredientText(ingredient, false)).toEqual("0.5 tbsp diced onion");
});
test("plural test : small qty : not abbreviation", () => {
const ingredient = createRecipeIngredient({
quantity: 0.5,
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", abbreviation: "tbsp", pluralAbbreviation: "tbsps", useAbbreviation: false },
food: { id: "1", name: "diced onion", pluralName: "diced onions" }
});
expect(parseIngredientText(ingredient, false)).toEqual("0.5 tablespoon diced onion");
});
test("plural test : zero qty", () => {
const ingredient = createRecipeIngredient({
quantity: 0,
unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", abbreviation: "tbsp", pluralAbbreviation: "tbsps", useAbbreviation: false },
food: { id: "1", name: "diced onion", pluralName: "diced onions" }
});
expect(parseIngredientText(ingredient, false)).toEqual("diced onions");
});
});

View File

@ -1,6 +1,6 @@
import DOMPurify from "isomorphic-dompurify";
import { useFraction } from "./use-fraction";
import { RecipeIngredient } from "~/lib/api/types/recipe";
import { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, RecipeIngredient } from "~/lib/api/types/recipe";
const { frac } = useFraction();
export function sanitizeIngredientHTML(rawHtml: string) {
@ -10,6 +10,31 @@ export function sanitizeIngredientHTML(rawHtml: string) {
});
}
function useFoodName(food: CreateIngredientFood | IngredientFood | undefined, usePlural: boolean) {
if (!food) {
return "";
}
return (usePlural ? food.pluralName || food.name : food.name) || "";
}
function useUnitName(unit: CreateIngredientUnit | IngredientUnit | undefined, usePlural: boolean) {
if (!unit) {
return "";
}
let returnVal = "";
if (unit.useAbbreviation) {
returnVal = (usePlural ? unit.pluralAbbreviation || unit.abbreviation : unit.abbreviation) || "";
}
if (!returnVal) {
returnVal = (usePlural ? unit.pluralName || unit.name : unit.name) || "";
}
return returnVal;
}
export function useParsedIngredientText(ingredient: RecipeIngredient, disableAmount: boolean, scale = 1, includeFormating = true) {
if (disableAmount) {
return {
@ -21,11 +46,11 @@ export function useParsedIngredientText(ingredient: RecipeIngredient, disableAmo
}
const { quantity, food, unit, note } = ingredient;
const usePluralUnit = quantity !== undefined && quantity > 1;
const usePluralFood = (!quantity) || quantity > 1
let returnQty = "";
let unitDisplay = unit?.name;
// casting to number is required as sometimes quantity is a string
if (quantity && Number(quantity) !== 0) {
if (unit?.fraction) {
@ -42,16 +67,15 @@ export function useParsedIngredientText(ingredient: RecipeIngredient, disableAmo
} else {
returnQty = (quantity * scale).toString();
}
}
if (unit?.useAbbreviation && unit.abbreviation) {
unitDisplay = unit.abbreviation;
}
}
const unitName = useUnitName(unit, usePluralUnit);
const foodName = useFoodName(food, usePluralFood);
return {
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
unit: unitDisplay ? sanitizeIngredientHTML(unitDisplay) : undefined,
name: food?.name ? sanitizeIngredientHTML(food.name) : undefined,
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
name: foodName ? sanitizeIngredientHTML(foodName) : undefined,
note: note ? sanitizeIngredientHTML(note) : undefined,
};
}

View File

@ -227,11 +227,11 @@
"group-preferences": "Nastavení skupiny",
"private-group": "Soukromá skupina",
"private-group-description": "Setting your group to private will default all public view options to default. This overrides an individual recipes public view settings.",
"allow-users-outside-of-your-group-to-see-your-recipes": "Allow users outside of your group to see your recipes",
"allow-users-outside-of-your-group-to-see-your-recipes": "Povolit uživatelům mimo vaši skupinu vidět vaše recepty",
"allow-users-outside-of-your-group-to-see-your-recipes-description": "When enabled you can use a public share link to share specific recipes without authorizing the user. When disabled, you can only share recipes with users who are in your group or with a pre-generated private link",
"show-nutrition-information": "Show nutrition information",
"show-nutrition-information-description": "When enabled the nutrition information will be shown on the recipe if available. If there is no nutrition information available, the nutrition information will not be shown",
"show-recipe-assets": "Show recipe assets",
"show-recipe-assets": "Zobrazit položky receptu",
"show-recipe-assets-description": "When enabled the recipe assets will be shown on the recipe if available",
"default-to-landscape-view": "Default to landscape view",
"default-to-landscape-view-description": "When enabled the recipe header section will be shown in landscape view",
@ -242,9 +242,9 @@
"general-preferences": "General Preferences",
"group-recipe-preferences": "Group Recipe Preferences",
"report": "Report",
"group-management": "Group Management",
"group-management": "Správa skupin",
"admin-group-management": "Admin Group Management",
"admin-group-management-text": "Changes to this group will be reflected immediately.",
"admin-group-management-text": "Změny v této skupině budou okamžitě zohledněny.",
"group-id-value": "Group Id: {0}"
},
"meal-plan": {
@ -284,10 +284,10 @@
"editor": "Editor",
"meal-recipe": "Meal Recipe",
"meal-title": "Meal Title",
"meal-note": "Meal Note",
"meal-note": "Poznámka k jídlu",
"note-only": "Note Only",
"random-meal": "Náhodné jídlo",
"random-dinner": "Random Dinner",
"random-dinner": "Náhodná večeře",
"random-side": "Random Side",
"this-rule-will-apply": "This rule will apply {dayCriteria} {mealTypeCriteria}.",
"to-all-days": "to all days",

View File

@ -124,6 +124,7 @@
"no-recipe-found": "No Recipe Found",
"ok": "OK",
"options": "Options:",
"plural-name": "Plural Name",
"print": "Print",
"print-preferences": "Print Preferences",
"random": "Random",
@ -889,7 +890,9 @@
"create-food": "Create Food",
"food-label": "Food Label",
"edit-food": "Edit Food",
"food-data": "Food Data"
"food-data": "Food Data",
"example-food-singular": "ex: Onion",
"example-food-plural": "ex: Onions"
},
"units": {
"seed-dialog-text": "Seed the database with common units based on your local language.",
@ -900,13 +903,18 @@
"merging-unit-into-unit": "Merging {0} into {1}",
"create-unit": "Create Unit",
"abbreviation": "Abbreviation",
"plural-abbreviation": "Plural Abbreviation",
"description": "Description",
"display-as-fraction": "Display as Fraction",
"use-abbreviation": "Use Abbreviation",
"edit-unit": "Edit Unit",
"unit-data": "Unit Data",
"use-abbv": "Use Abbv.",
"fraction": "Fraction"
"fraction": "Fraction",
"example-unit-singular": "ex: Tablespoon",
"example-unit-plural": "ex: Tablespoons",
"example-unit-abbreviation-singular": "ex: Tbsp",
"example-unit-abbreviation-plural": "ex: Tbsps"
},
"labels": {
"seed-dialog-text": "Seed the database with common labels based on your local language.",
@ -935,6 +943,8 @@
"delete-recipes": "Delete Recipes",
"source-unit-will-be-deleted": "Source Unit will be deleted"
},
"create-alias": "Create Alias",
"manage-aliases": "Manage Aliases",
"seed-data": "Seed Data",
"seed": "Seed",
"data-management": "Data Management",

View File

@ -543,7 +543,7 @@
"unit": "Unidades",
"upload-image": "Subir imagen",
"screen-awake": "Mantener la pantalla encendida",
"remove-image": "Remove image"
"remove-image": "Eliminar imagen"
},
"search": {
"advanced-search": "Búsqueda avanzada",
@ -552,7 +552,7 @@
"include": "Incluir",
"max-results": "Resultados máximos",
"or": "O",
"has-any": "Has Any",
"has-any": "Tiene alguna",
"has-all": "Tiene todo",
"results": "Resultados",
"search": "Buscar",
@ -898,19 +898,19 @@
"target-unit": "Target Unit",
"merging-unit-into-unit": "Merging {0} into {1}",
"create-unit": "Create Unit",
"abbreviation": "Abbreviation",
"abbreviation": "Abreviatura",
"description": "Descripción",
"display-as-fraction": "Display as Fraction",
"use-abbreviation": "Use Abbreviation",
"edit-unit": "Edit Unit",
"use-abbreviation": "Usar Abreviaturas",
"edit-unit": "Editar unidad",
"unit-data": "Unit Data",
"use-abbv": "Use Abbv.",
"fraction": "Fraction"
},
"labels": {
"seed-dialog-text": "Añade a la base de datos etiquetas comunes basadas en su idioma local.",
"edit-label": "Edit Label",
"new-label": "New Label",
"edit-label": "Editar etiqueta",
"new-label": "Nueva etiqueta",
"labels": "Labels"
},
"recipes": {
@ -920,17 +920,17 @@
"the-following-recipes-selected-length-will-be-exported": "The following recipes ({0}) will be exported.",
"settings-chosen-explanation": "Settings chosen here, excluding the locked option, will be applied to all selected recipes.",
"selected-length-recipe-s-settings-will-be-updated": "{count} recipe(s) settings will be updated.",
"recipe-data": "Recipe Data",
"recipe-data": "Datos de la receta",
"recipe-data-description": "Use this section to manage the data associated with your recipes. You can perform several bulk actions on your recipes including exporting, deleting, tagging, and assigning categories.",
"recipe-columns": "Recipe Columns",
"data-exports-description": "This section provides links to available exports that are ready to download. These exports do expire, so be sure to grab them while they're still available.",
"data-exports": "Data Exports",
"data-exports": "Exportación de datos",
"tag": "Etiqueta",
"categorize": "Categorize",
"update-settings": "Update Settings",
"categorize": "Clasificar",
"update-settings": "Actualizar configuración",
"tag-recipes": "Tag Recipes",
"categorize-recipes": "Categorize Recipes",
"export-recipes": "Export Recipes",
"categorize-recipes": "Categorizar recetas",
"export-recipes": "Exportar recetas",
"delete-recipes": "Borrar Recetas",
"source-unit-will-be-deleted": "Source Unit will be deleted"
},
@ -938,8 +938,8 @@
"seed": "Seed",
"data-management": "Data Management",
"data-management-description": "Select which data set you want to make changes to.",
"select-data": "Select Data",
"select-language": "Select Language",
"select-data": "Seleccionar datos",
"select-language": "Seleccionar idioma",
"columns": "Columnas",
"combine": "Combinar"
},
@ -958,7 +958,7 @@
"group-name-is-taken": "El nombre de grupo ya está en uso",
"username-is-taken": "El nombre de usuario ya está en uso",
"email-is-taken": "Este correo ya está en uso",
"this-field-is-required": "This Field is Required"
"this-field-is-required": "Este campo es obligatorio"
},
"export": {
"export": "Exportar",
@ -991,7 +991,7 @@
},
"ocr-editor": {
"ocr-editor": "Editor de OCR",
"selection-mode": "Selection mode",
"selection-mode": "Modo de selección",
"pan-and-zoom-picture": "Pan and zoom picture",
"split-text": "Split text",
"preserve-line-breaks": "Preserve original line breaks",

View File

@ -296,7 +296,7 @@
"for-type-meal-types": "kaikille {0} ateriatyypeille",
"meal-plan-rules": "Ateriasuunnitelman määritykset",
"new-rule": "Uusi sääntö",
"meal-plan-rules-description": "You can create rules for auto selecting recipes for your meal plans. These rules are used by the server to determine the random pool of recipes to select from when creating meal plans. Note that if rules have the same day/type constraints then the categories of the rules will be merged. In practice, it's unnecessary to create duplicate rules, but it's possible to do so.",
"meal-plan-rules-description": "Voit luoda sääntöjä reseptien automaattiseen valitsemiseen ateriasuunnitelmia varten. Palvelin käyttää näitä sääntöjä määrittääkseen satunnaisen valikoiman reseptejä, joista valita luodessaan ateriasuunnitelmia. Huomaa, että jos säännöillä on samat päivä-/tyyppirajoitukset, sääntöjen luokat yhdistetään. Käytännössä ei ole tarpeen luoda päällekkäisiä sääntöjä, mutta se on mahdollista.",
"new-rule-description": "Kun luot uuden säännön ateriasuunnitelmalle, voit rajoittaa säännön koskemaan tiettyä viikonpäivää ja/tai tietyntyyppistä ateriaa. Jos haluat soveltaa sääntöä kaikkiin päiviin tai kaikkiin ateriatyyppeihin, voit asettaa säännön asetukseksi \"Mikä tahansa\", jolloin sitä sovelletaan kaikkiin mahdollisiin päivän ja/tai ateriatyypin arvoihin.",
"recipe-rules": "Reseptimääritykset",
"applies-to-all-days": "Sovelletaan kaikkiin päiviin",
@ -348,7 +348,7 @@
"mealie-text": "Mealie voi tuoda reseptejä Mealie sovelluksesta ennen v1.0 julkaisua. Vie reseptisi vanhasta asennuksesta ja lataa zip-tiedosto. Huomaa, että viennistä voidaan tuoda vain reseptejä.",
"plantoeat": {
"title": "Plan to Eat",
"description-long": "Mealie can import recipies from Plan to Eat."
"description-long": "Mealieen voi tuoda reseptejä Plan to Eat -sovelluksesta."
}
},
"new-recipe": {

View File

@ -21,7 +21,7 @@
"production": "Production",
"support": "お問い合わせ",
"version": "バージョン",
"unknown-version": "unknown",
"unknown-version": "不明",
"sponsor": "Sponsor"
},
"asset": {
@ -57,11 +57,11 @@
"event-deleted": "イベントを削除しました",
"event-updated": "イベントを更新しました",
"new-notification-form-description": "Mealie uses the Apprise library to generate notifications. They offer many options for services to use for notifications. Refer to their wiki for a comprehensive guide on how to create the URL for your service. If available, selecting the type of your notification may include extra features.",
"new-version": "New version available!",
"new-version": "新しいバージョンがあります!",
"notification": "通知",
"refresh": "Refresh",
"scheduled": "Scheduled",
"something-went-wrong": "Something Went Wrong!",
"something-went-wrong": "問題が発生しました",
"subscribed-events": "Subscribed Events",
"test-message-sent": "Test Message Sent",
"new-notification": "新着通知",
@ -76,7 +76,7 @@
"cookbook-events": "料理本イベント",
"tag-events": "タグイベント",
"category-events": "カテゴリイベント",
"when-a-new-user-joins-your-group": "When a new user joins your group"
"when-a-new-user-joins-your-group": "新しいユーザーがあなたのグループに参加する際"
},
"general": {
"cancel": "キャンセル",
@ -90,11 +90,11 @@
"custom": "カスタム",
"dashboard": "ダッシュボード",
"delete": "削除",
"disabled": "Disabled",
"disabled": "無効",
"download": "ダウンロード",
"duplicate": "複製",
"edit": "編集",
"enabled": "Enabled",
"enabled": "有効",
"exception": "Exception",
"failed-count": "Failed: {count}",
"failure-uploading-file": "Failure uploading file",
@ -156,10 +156,10 @@
"updated": "更新しました",
"upload": "アップロード",
"url": "URL",
"view": "View",
"view": "表示",
"wednesday": "水曜日",
"yes": "はい",
"foods": "Foods",
"foods": "食材",
"units": "Units",
"back": "戻る",
"next": "次へ",

View File

@ -56,7 +56,7 @@
"event-delete-confirmation": "Tem a certeza que pretende eliminar este evento?",
"event-deleted": "Evento eliminado",
"event-updated": "Evento atualizado",
"new-notification-form-description": "O Mealie usa a biblioteca Apprise para gerar notificações. Eles oferecem muitas opções de serviços para notificações. Consulte a sua wiki para um guia abrangente sobre como criar o URL para o seu serviço. Se disponível, selecionar o tipo de notificação pode incluir recursos extras.",
"new-notification-form-description": "O Mealie usa a biblioteca Apprise para gerar notificações. Eles oferecem muitas opções de serviços para notificações. Consulte a wiki para um guia abrangente sobre como criar o URL para o seu serviço. Se disponível, selecionar o tipo de notificação pode incluir recursos extras.",
"new-version": "Nova versão disponível!",
"notification": "Notificação",
"refresh": "Atualizar",

View File

@ -73,7 +73,7 @@
"mealplan-events": "用餐计划事件",
"when-a-user-in-your-group-creates-a-new-mealplan": "当你群组中的用户创建新用餐计划时",
"shopping-list-events": "购物清单事件",
"cookbook-events": "食谱活动",
"cookbook-events": "食谱合集事件",
"tag-events": "标签事件",
"category-events": "目录事件",
"when-a-new-user-joins-your-group": "当新用户加入您的群组时"
@ -318,7 +318,7 @@
"nextcloud": {
"description": "从Nextcloud Cookbook迁移数据",
"description-long": "Nextcloud食谱可以从存储在Nextcloud云端的含有食谱数据的zip文件导入。请参阅下方的文件夹结构示例确保您的食谱可以被正确导入。",
"title": "Nextcloud 食谱"
"title": "Nextcloud Cookbook"
},
"copymethat": {
"description-long": "Mealie 可以从 Copy Mee That导入食谱。将您的食谱以HTML 格式导出,然后在下面上传 .zip压缩包",
@ -345,7 +345,7 @@
"recipe-1": "食谱 1",
"recipe-2": "食谱 2",
"paprika-text": "Mealie 可以从 Paprika 导入食谱。请从paprika 导出食谱,重命名导出文件并压缩成.zip格式后在下方上传",
"mealie-text": "Mealie can import recipes from the Mealie application from a pre v1.0 release. Export your recipes from your old instance, and upload the zip file below. Note that only recipes can be imported from the export.",
"mealie-text": "Mealie支持从其1.0版本之前的早期版本中导入食谱。你需要在老版本服务器上导出食谱并在下方上传zip文件。注意只有食谱数据能被导入。",
"plantoeat": {
"title": "Plan to Eat",
"description-long": "Mealie支持从 Plan to Eat 中导入食谱。"
@ -716,8 +716,8 @@
"label": "标签",
"linked-item-warning": "This item is linked to one or more recipe. Adjusting the units or foods will yield unexpected results when adding or removing the recipe from this list.",
"toggle-food": "Toggle Food",
"manage-labels": "Manage Labels",
"are-you-sure-you-want-to-delete-this-item": "Are you sure you want to delete this item?",
"manage-labels": "管理标签",
"are-you-sure-you-want-to-delete-this-item": "你确定要删除该条目吗?",
"copy-as-text": "复制文本",
"copy-as-markdown": "以Markdown格式复制",
"delete-checked": "删除选中",
@ -734,7 +734,7 @@
"all-recipes": "全部食谱",
"backups": "备份",
"categories": "分类",
"cookbooks": "食谱",
"cookbooks": "食谱合集",
"dashboard": "仪表盘",
"home-page": "首页",
"manage-users": "管理用户",
@ -749,8 +749,8 @@
"background-tasks": "后台任务",
"parser": "Parser",
"developer": "开发人员",
"cookbook": "食谱",
"create-cookbook": "Create a new cookbook"
"cookbook": "食谱合集",
"create-cookbook": "新建一个食谱合集"
},
"signup": {
"error-signing-up": "注册时出错",
@ -872,7 +872,7 @@
"language-dialog": {
"translated": "已翻译",
"choose-language": "Choose Language",
"select-description": "Choose the language for the Mealie UI. The setting only applies to you, not other users.",
"select-description": "选择Mealie UI的语言。该设置仅对你生效不会影响其他用户。",
"how-to-contribute-description": "Is something not translated yet, mistranslated, or your language missing from the list? {read-the-docs-link} on how to contribute!",
"read-the-docs": "Read the docs"
},
@ -1048,7 +1048,7 @@
"actions-description-destructive": "destructive",
"actions-description-irreversible": "irreversible",
"logs-action-refresh": "Refresh Logs",
"logs-page-title": "Mealie Logs",
"logs-page-title": "Mealie日志",
"logs-tail-lines-label": "Tail Lines"
},
"mainentance": {
@ -1107,7 +1107,7 @@
"looking-for-privacy-settings": "Looking for Privacy Settings?",
"manage-your-api-tokens": "Manage Your API Tokens",
"manage-user-profile": "Manage User Profile",
"manage-cookbooks": "Manage Cookbooks",
"manage-cookbooks": "管理食谱合集",
"manage-members": "管理成员",
"manage-webhooks": "管理 Webhooks",
"manage-notifiers": "Manage Notifiers",
@ -1116,8 +1116,8 @@
"cookbook": {
"cookbooks": "食谱合集",
"description": "Cookbooks are another way to organize recipes by creating cross sections of recipes and tags. Creating a cookbook will add an entry to the side-bar and all the recipes with the tags and categories chosen will be displayed in the cookbook.",
"public-cookbook": "Public Cookbook",
"public-cookbook-description": "Public Cookbooks can be shared with non-mealie users and will be displayed on your groups page.",
"public-cookbook": "公开食谱合集",
"public-cookbook-description": "公开食谱合集可以分享和非Mealie用户同时也会显示在你的群组页面上。",
"filter-options": "Filter Options",
"filter-options-description": "When require all is selected the cookbook will only include recipes that have all of the items selected. This applies to each subset of selectors and not a cross section of the selected items.",
"require-all-categories": "Require All Categories",

View File

@ -56,21 +56,32 @@ export interface CategorySave {
}
export interface CreateIngredientFood {
name: string;
pluralName?: string;
description?: string;
extras?: {
[k: string]: unknown;
};
labelId?: string;
aliases?: CreateIngredientFoodAlias[];
}
export interface CreateIngredientFoodAlias {
name: string;
}
export interface CreateIngredientUnit {
name: string;
pluralName?: string;
description?: string;
extras?: {
[k: string]: unknown;
};
fraction?: boolean;
abbreviation?: string;
pluralAbbreviation?: string;
useAbbreviation?: boolean;
aliases?: CreateIngredientUnitAlias[];
}
export interface CreateIngredientUnitAlias {
name: string;
}
export interface CreateRecipe {
name: string;
@ -113,16 +124,21 @@ export interface IngredientConfidence {
}
export interface IngredientFood {
name: string;
pluralName?: string;
description?: string;
extras?: {
[k: string]: unknown;
};
labelId?: string;
aliases?: IngredientFoodAlias[];
id: string;
label?: MultiPurposeLabelSummary;
createdAt?: string;
updateAt?: string;
}
export interface IngredientFoodAlias {
name: string;
}
export interface MultiPurposeLabelSummary {
name: string;
color?: string;
@ -141,17 +157,23 @@ export interface IngredientRequest {
}
export interface IngredientUnit {
name: string;
pluralName?: string;
description?: string;
extras?: {
[k: string]: unknown;
};
fraction?: boolean;
abbreviation?: string;
pluralAbbreviation?: string;
useAbbreviation?: boolean;
aliases?: IngredientUnitAlias[];
id: string;
createdAt?: string;
updateAt?: string;
}
export interface IngredientUnitAlias {
name: string;
}
export interface IngredientsRequest {
parser?: RegisteredParser & string;
ingredients: string[];
@ -206,7 +228,7 @@ export interface Recipe {
recipeCategory?: RecipeCategory[];
tags?: RecipeTag[];
tools?: RecipeTool[];
rating?: number | null;
rating?: number;
orgURL?: string;
dateAdded?: string;
dateUpdated?: string;
@ -413,22 +435,27 @@ export interface RecipeZipTokenResponse {
}
export interface SaveIngredientFood {
name: string;
pluralName?: string;
description?: string;
extras?: {
[k: string]: unknown;
};
labelId?: string;
aliases?: CreateIngredientFoodAlias[];
groupId: string;
}
export interface SaveIngredientUnit {
name: string;
pluralName?: string;
description?: string;
extras?: {
[k: string]: unknown;
};
fraction?: boolean;
abbreviation?: string;
pluralAbbreviation?: string;
useAbbreviation?: boolean;
aliases?: CreateIngredientUnitAlias[];
groupId: string;
}
export interface ScrapeRecipe {
@ -438,7 +465,7 @@ export interface ScrapeRecipe {
export interface ScrapeRecipeTest {
url: string;
}
export interface SlugResponse {}
export interface SlugResponse { }
export interface TagIn {
name: string;
}
@ -454,6 +481,7 @@ export interface TagSave {
}
export interface UnitFoodBase {
name: string;
pluralName?: string;
description?: string;
extras?: {
[k: string]: unknown;

View File

@ -59,6 +59,7 @@
v-model="createDialog"
:icon="$globals.icons.foods"
:title="$t('data-pages.foods.create-food')"
:submit-icon="$globals.icons.save"
:submit-text="$tc('general.save')"
@submit="createFood"
>
@ -68,8 +69,14 @@
v-model="createTarget.name"
autofocus
:label="$t('general.name')"
:hint="$t('data-pages.foods.example-food-singular')"
:rules="[validators.required]"
></v-text-field>
<v-text-field
v-model="createTarget.pluralName"
:label="$t('general.plural-name')"
:hint="$t('data-pages.foods.example-food-plural')"
></v-text-field>
<v-text-field v-model="createTarget.description" :label="$t('recipe.description')"></v-text-field>
<v-autocomplete
v-model="createTarget.labelId"
@ -83,18 +90,41 @@
</v-form> </v-card-text
></BaseDialog>
<!-- Alias Sub-Dialog -->
<RecipeDataAliasManagerDialog
v-if="editTarget"
:value="aliasManagerDialog"
:data="editTarget"
@submit="updateFoodAlias"
@cancel="aliasManagerDialog = false"
/>
<!-- Edit Dialog -->
<BaseDialog
v-model="editDialog"
:icon="$globals.icons.foods"
:title="$t('data-pages.foods.edit-food')"
:submit-icon="$globals.icons.save"
:submit-text="$tc('general.save')"
@submit="editSaveFood"
>
<v-card-text v-if="editTarget">
<v-form ref="domEditFoodForm">
<v-text-field v-model="editTarget.name" :label="$t('general.name')" :rules="[validators.required]"></v-text-field>
<v-text-field v-model="editTarget.description" :label="$t('recipe.description')"></v-text-field>
<v-text-field
v-model="editTarget.name"
:label="$t('general.name')"
:hint="$t('data-pages.foods.example-food-singular')"
:rules="[validators.required]"
></v-text-field>
<v-text-field
v-model="editTarget.pluralName"
:label="$t('general.plural-name')"
:hint="$t('data-pages.foods.example-food-plural')"
></v-text-field>
<v-text-field
v-model="editTarget.description"
:label="$t('recipe.description')"
></v-text-field>
<v-autocomplete
v-model="editTarget.labelId"
clearable
@ -104,8 +134,12 @@
:label="$t('data-pages.foods.food-label')"
>
</v-autocomplete>
</v-form> </v-card-text
></BaseDialog>
</v-form>
</v-card-text>
<template #custom-card-action>
<BaseButton edit @click="aliasManagerEventHandler">{{ $t('data-pages.manage-aliases') }}</BaseButton>
</template>
</BaseDialog>
<!-- Delete Dialog -->
<BaseDialog
@ -156,16 +190,18 @@
<script lang="ts">
import { defineComponent, onMounted, ref, computed, useContext } from "@nuxtjs/composition-api";
import type { LocaleObject } from "@nuxtjs/i18n";
import RecipeDataAliasManagerDialog from "~/components/Domain/Recipe/RecipeDataAliasManagerDialog.vue";
import { validators } from "~/composables/use-validators";
import { useUserApi } from "~/composables/api";
import { CreateIngredientFood, IngredientFood } from "~/lib/api/types/recipe";
import { CreateIngredientFood, IngredientFood, IngredientFoodAlias } from "~/lib/api/types/recipe";
import MultiPurposeLabel from "~/components/Domain/ShoppingList/MultiPurposeLabel.vue";
import { useLocales } from "~/composables/use-locales";
import { useFoodStore, useLabelStore } from "~/composables/store";
import { VForm } from "~/types/vuetify";
export default defineComponent({
components: { MultiPurposeLabel },
components: { MultiPurposeLabel, RecipeDataAliasManagerDialog },
setup() {
const userApi = useUserApi();
const { i18n } = useContext();
@ -184,6 +220,11 @@ export default defineComponent({
value: "name",
show: true,
},
{
text: i18n.tc("general.plural-name"),
value: "pluralName",
show: true,
},
{
text: i18n.tc("recipe.description"),
value: "description",
@ -264,6 +305,22 @@ export default defineComponent({
deleteDialog.value = false;
}
// ============================================================
// Alias Manager
const aliasManagerDialog = ref(false);
function aliasManagerEventHandler() {
aliasManagerDialog.value = true;
}
function updateFoodAlias(newAliases: IngredientFoodAlias[]) {
if (!editTarget.value) {
return;
}
editTarget.value.aliases = newAliases;
aliasManagerDialog.value = false;
}
// ============================================================
// Merge Foods
@ -337,6 +394,10 @@ export default defineComponent({
deleteEventHandler,
deleteDialog,
deleteFood,
// Alias Manager
aliasManagerDialog,
aliasManagerEventHandler,
updateFoodAlias,
// Merge
canMerge,
mergeFoods,

View File

@ -29,6 +29,7 @@
v-model="createDialog"
:icon="$globals.icons.units"
:title="$t('data-pages.units.create-unit')"
:submit-icon="$globals.icons.save"
:submit-text="$tc('general.save')"
@submit="createUnit"
>
@ -38,9 +39,24 @@
v-model="createTarget.name"
autofocus
:label="$t('general.name')"
:hint="$t('data-pages.units.example-unit-singular')"
:rules="[validators.required]"
></v-text-field>
<v-text-field v-model="createTarget.abbreviation" :label="$t('data-pages.units.abbreviation')"></v-text-field>
<v-text-field
v-model="createTarget.pluralName"
:label="$t('general.plural-name')"
:hint="$t('data-pages.units.example-unit-plural')"
></v-text-field>
<v-text-field
v-model="createTarget.abbreviation"
:label="$t('data-pages.units.abbreviation')"
:hint="$t('data-pages.units.example-unit-abbreviation-singular')"
></v-text-field>
<v-text-field
v-model="createTarget.pluralAbbreviation"
:label="$t('data-pages.units.plural-abbreviation')"
:hint="$t('data-pages.units.example-unit-abbreviation-plural')"
></v-text-field>
<v-text-field v-model="createTarget.description" :label="$t('data-pages.units.description')"></v-text-field>
<v-checkbox v-model="createTarget.fraction" hide-details :label="$t('data-pages.units.display-as-fraction')"></v-checkbox>
<v-checkbox v-model="createTarget.useAbbreviation" hide-details :label="$t('data-pages.units.use-abbreviation')"></v-checkbox>
@ -48,23 +64,55 @@
</v-card-text>
</BaseDialog>
<!-- Alias Sub-Dialog -->
<RecipeDataAliasManagerDialog
v-if="editTarget"
:value="aliasManagerDialog"
:data="editTarget"
@submit="updateUnitAlias"
@cancel="aliasManagerDialog = false"
/>
<!-- Edit Dialog -->
<BaseDialog
v-model="editDialog"
:icon="$globals.icons.units"
:title="$t('data-pages.units.edit-unit')"
:submit-icon="$globals.icons.save"
:submit-text="$tc('general.save')"
@submit="editSaveUnit"
>
<v-card-text v-if="editTarget">
<v-form ref="domEditUnitForm">
<v-text-field v-model="editTarget.name" :label="$t('general.name')" :rules="[validators.required]"></v-text-field>
<v-text-field v-model="editTarget.abbreviation" :label="$t('data-pages.units.abbreviation')"></v-text-field>
<v-text-field
v-model="editTarget.name"
:label="$t('general.name')"
:hint="$t('data-pages.units.example-unit-singular')"
:rules="[validators.required]"
></v-text-field>
<v-text-field
v-model="editTarget.pluralName"
:label="$t('general.plural-name')"
:hint="$t('data-pages.units.example-unit-plural')"
></v-text-field>
<v-text-field
v-model="editTarget.abbreviation"
:label="$t('data-pages.units.abbreviation')"
:hint="$t('data-pages.units.example-unit-abbreviation-singular')"
></v-text-field>
<v-text-field
v-model="editTarget.pluralAbbreviation"
:label="$t('data-pages.units.plural-abbreviation')"
:hint="$t('data-pages.units.example-unit-abbreviation-plural')"
></v-text-field>
<v-text-field v-model="editTarget.description" :label="$t('data-pages.units.description')"></v-text-field>
<v-checkbox v-model="editTarget.fraction" hide-details :label="$t('data-pages.units.display-as-fraction')"></v-checkbox>
<v-checkbox v-model="editTarget.useAbbreviation" hide-details :label="$t('data-pages.units.use-abbreviation')"></v-checkbox>
</v-form>
</v-card-text>
<template #custom-card-action>
<BaseButton edit @click="aliasManagerEventHandler">{{ $t('data-pages.manage-aliases') }}</BaseButton>
</template>
</BaseDialog>
<!-- Delete Dialog -->
@ -159,14 +207,16 @@
<script lang="ts">
import { computed, defineComponent, onMounted, ref, useContext } from "@nuxtjs/composition-api";
import type { LocaleObject } from "@nuxtjs/i18n";
import RecipeDataAliasManagerDialog from "~/components/Domain/Recipe/RecipeDataAliasManagerDialog.vue";
import { validators } from "~/composables/use-validators";
import { useUserApi } from "~/composables/api";
import { CreateIngredientUnit, IngredientUnit } from "~/lib/api/types/recipe";
import { CreateIngredientUnit, IngredientUnit, IngredientUnitAlias } from "~/lib/api/types/recipe";
import { useLocales } from "~/composables/use-locales";
import { useUnitStore } from "~/composables/store";
import { VForm } from "~/types/vuetify";
export default defineComponent({
components: { RecipeDataAliasManagerDialog },
setup() {
const userApi = useUserApi();
const { i18n } = useContext();
@ -185,11 +235,21 @@ export default defineComponent({
value: "name",
show: true,
},
{
text: i18n.t("general.plural-name"),
value: "pluralName",
show: true,
},
{
text: i18n.t("data-pages.units.abbreviation"),
value: "abbreviation",
show: true,
},
{
text: i18n.t("data-pages.units.plural-abbreviation"),
value: "pluralAbbreviation",
show: true,
},
{
text: i18n.t("data-pages.units.use-abbv"),
value: "useAbbreviation",
@ -278,6 +338,22 @@ export default defineComponent({
deleteDialog.value = false;
}
// ============================================================
// Alias Manager
const aliasManagerDialog = ref(false);
function aliasManagerEventHandler() {
aliasManagerDialog.value = true;
}
function updateUnitAlias(newAliases: IngredientUnitAlias[]) {
if (!editTarget.value) {
return;
}
editTarget.value.aliases = newAliases;
aliasManagerDialog.value = false;
}
// ============================================================
// Merge Units
@ -345,13 +421,16 @@ export default defineComponent({
deleteEventHandler,
deleteDialog,
deleteUnit,
// Alias Manager
aliasManagerDialog,
aliasManagerEventHandler,
updateUnitAlias,
// Merge
canMerge,
mergeUnits,
mergeDialog,
fromUnit,
toUnit,
// Seed
seedDatabase,
locales,

View File

@ -25,25 +25,44 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_units", foreign_keys=[group_id])
name: Mapped[str | None] = mapped_column(String)
plural_name: Mapped[str | None] = mapped_column(String)
description: Mapped[str | None] = mapped_column(String)
abbreviation: Mapped[str | None] = mapped_column(String)
plural_abbreviation: Mapped[str | None] = mapped_column(String)
use_abbreviation: Mapped[bool | None] = mapped_column(Boolean, default=False)
fraction: Mapped[bool | None] = mapped_column(Boolean, default=True)
ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
"RecipeIngredientModel", back_populates="unit"
)
aliases: Mapped[list["IngredientUnitAliasModel"]] = orm.relationship(
"IngredientUnitAliasModel", back_populates="unit", cascade="all, delete, delete-orphan"
)
# Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
plural_name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
abbreviation_normalized: Mapped[str | None] = mapped_column(String, index=True)
plural_abbreviation_normalized: Mapped[str | None] = mapped_column(String, index=True)
@auto_init()
def __init__(self, session: Session, name: str | None = None, abbreviation: str | None = None, **_) -> None:
def __init__(
self,
session: Session,
name: str | None = None,
plural_name: str | None = None,
abbreviation: str | None = None,
plural_abbreviation: str | None = None,
**_,
) -> None:
if name is not None:
self.name_normalized = self.normalize(name)
if plural_name is not None:
self.plural_name_normalized = self.normalize(plural_name)
if abbreviation is not None:
self.abbreviation = self.normalize(abbreviation)
self.abbreviation_normalized = self.normalize(abbreviation)
if plural_abbreviation is not None:
self.plural_abbreviation_normalized = self.normalize(plural_abbreviation)
tableargs = [
sa.UniqueConstraint("name", "group_id", name="ingredient_units_name_group_id_key"),
@ -52,11 +71,21 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
"name_normalized",
unique=False,
),
sa.Index(
"ix_ingredient_units_plural_name_normalized",
"plural_name_normalized",
unique=False,
),
sa.Index(
"ix_ingredient_units_abbreviation_normalized",
"abbreviation_normalized",
unique=False,
),
sa.Index(
"ix_ingredient_units_plural_abbreviation_normalized",
"plural_abbreviation_normalized",
unique=False,
),
]
if session.get_bind().name == "postgresql":
@ -71,6 +100,15 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
"name_normalized": "gin_trgm_ops",
},
),
sa.Index(
"ix_ingredient_units_plural_name_normalized_gin",
"name_normalized",
unique=False,
postgresql_using="gin",
postgresql_ops={
"plural_name_normalized": "gin_trgm_ops",
},
),
sa.Index(
"ix_ingredient_units_abbreviation_normalized_gin",
"abbreviation_normalized",
@ -80,6 +118,15 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
"abbreviation_normalized": "gin_trgm_ops",
},
),
sa.Index(
"ix_ingredient_units_plural_abbreviation_normalized_gin",
"plural_abbreviation_normalized",
unique=False,
postgresql_using="gin",
postgresql_ops={
"plural_abbreviation_normalized": "gin_trgm_ops",
},
),
]
)
@ -95,10 +142,15 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_foods", foreign_keys=[group_id])
name: Mapped[str | None] = mapped_column(String)
plural_name: Mapped[str | None] = mapped_column(String)
description: Mapped[str | None] = mapped_column(String)
ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
"RecipeIngredientModel", back_populates="food"
)
aliases: Mapped[list["IngredientFoodAliasModel"]] = orm.relationship(
"IngredientFoodAliasModel", back_populates="food", cascade="all, delete, delete-orphan"
)
extras: Mapped[list[IngredientFoodExtras]] = orm.relationship("IngredientFoodExtras", cascade="all, delete-orphan")
label_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"), index=True)
@ -106,12 +158,15 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
# Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
plural_name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
@api_extras
@auto_init()
def __init__(self, session: Session, name: str | None = None, **_) -> None:
def __init__(self, session: Session, name: str | None = None, plural_name: str | None = None, **_) -> None:
if name is not None:
self.name_normalized = self.normalize(name)
if plural_name is not None:
self.plural_name_normalized = self.normalize(plural_name)
tableargs = [
sa.UniqueConstraint("name", "group_id", name="ingredient_foods_name_group_id_key"),
@ -120,6 +175,11 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
"name_normalized",
unique=False,
),
sa.Index(
"ix_ingredient_foods_plural_name_normalized",
"plural_name_normalized",
unique=False,
),
]
if session.get_bind().name == "postgresql":
@ -133,13 +193,104 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
postgresql_ops={
"name_normalized": "gin_trgm_ops",
},
)
),
sa.Index(
"ix_ingredient_foods_plural_name_normalized_gin",
"plural_name_normalized",
unique=False,
postgresql_using="gin",
postgresql_ops={
"plural_name_normalized": "gin_trgm_ops",
},
),
]
)
self.__table_args__ = tuple(tableargs)
class IngredientUnitAliasModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_units_aliases"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
unit_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("ingredient_units.id"), primary_key=True)
unit: Mapped["IngredientUnitModel"] = orm.relationship("IngredientUnitModel", back_populates="aliases")
name: Mapped[str] = mapped_column(String)
# Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
@auto_init()
def __init__(self, session: Session, name: str, **_) -> None:
self.name_normalized = self.normalize(name)
tableargs = [
sa.Index(
"ix_ingredient_units_aliases_name_normalized",
"name_normalized",
unique=False,
),
]
if session.get_bind().name == "postgresql":
tableargs.extend(
[
sa.Index(
"ix_ingredient_units_aliases_name_normalized_gin",
"name_normalized",
unique=False,
postgresql_using="gin",
postgresql_ops={
"name_normalized": "gin_trgm_ops",
},
),
]
)
self.__table_args__ = tableargs
class IngredientFoodAliasModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_foods_aliases"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
food_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("ingredient_foods.id"), primary_key=True)
food: Mapped["IngredientFoodModel"] = orm.relationship("IngredientFoodModel", back_populates="aliases")
name: Mapped[str] = mapped_column(String)
# Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
@auto_init()
def __init__(self, session: Session, name: str, **_) -> None:
self.name_normalized = self.normalize(name)
tableargs = [
sa.Index(
"ix_ingredient_foods_aliases_name_normalized",
"name_normalized",
unique=False,
),
]
if session.get_bind().name == "postgresql":
tableargs.extend(
[
sa.Index(
"ix_ingredient_foods_aliases_name_normalized_gin",
"name_normalized",
unique=False,
postgresql_using="gin",
postgresql_ops={
"name_normalized": "gin_trgm_ops",
},
),
]
)
self.__table_args__ = tableargs
class RecipeIngredientModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipes_ingredients"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
@ -221,6 +372,14 @@ def receive_unit_name(target: IngredientUnitModel, value: str | None, oldvalue,
target.name_normalized = None
@event.listens_for(IngredientUnitModel.plural_name, "set")
def receive_plural_unit_name(target: IngredientUnitModel, value: str | None, oldvalue, initiator):
if value is not None:
target.plural_name_normalized = IngredientUnitModel.normalize(value)
else:
target.plural_name_normalized = None
@event.listens_for(IngredientUnitModel.abbreviation, "set")
def receive_unit_abbreviation(target: IngredientUnitModel, value: str | None, oldvalue, initiator):
if value is not None:
@ -229,6 +388,14 @@ def receive_unit_abbreviation(target: IngredientUnitModel, value: str | None, ol
target.abbreviation_normalized = None
@event.listens_for(IngredientUnitModel.plural_abbreviation, "set")
def receive_unit_plural_abbreviation(target: IngredientUnitModel, value: str | None, oldvalue, initiator):
if value is not None:
target.plural_abbreviation_normalized = IngredientUnitModel.normalize(value)
else:
target.plural_abbreviation_normalized = None
@event.listens_for(IngredientFoodModel.name, "set")
def receive_food_name(target: IngredientFoodModel, value: str | None, oldvalue, initiator):
if value is not None:
@ -237,6 +404,24 @@ def receive_food_name(target: IngredientFoodModel, value: str | None, oldvalue,
target.name_normalized = None
@event.listens_for(IngredientFoodModel.plural_name, "set")
def receive_food_plural_name(target: IngredientFoodModel, value: str | None, oldvalue, initiator):
if value is not None:
target.plural_name_normalized = IngredientFoodModel.normalize(value)
else:
target.plural_name_normalized = None
@event.listens_for(IngredientUnitAliasModel.name, "set")
def receive_unit_alias_name(target: IngredientUnitAliasModel, value: str, oldvalue, initiator):
target.name_normalized = IngredientUnitAliasModel.normalize(value)
@event.listens_for(IngredientFoodAliasModel.name, "set")
def receive_food_alias_name(target: IngredientFoodAliasModel, value: str, oldvalue, initiator):
target.name_normalized = IngredientFoodAliasModel.normalize(value)
@event.listens_for(RecipeIngredientModel.note, "set")
def receive_ingredient_note(target: RecipeIngredientModel, value: str | None, oldvalue, initiator):
if value is not None:

View File

@ -44,8 +44,8 @@
"castor-sugar": "castor sugar",
"cayenne-pepper": "cayenne pepper",
"celeriac": "celeriac",
"celery": "celery",
"cereal-grains": "cereal grains",
"celery": "セロリ",
"cereal-grains": "穀物",
"rice": "米",
"chard": "chard",
"cheese": "チーズ",
@ -61,7 +61,7 @@
"coffee": "コーヒー",
"confectioners-sugar": "粉糖",
"coriander": "coriander",
"corn": "corn",
"corn": "トウモロコシ",
"corn-syrup": "corn syrup",
"cottonseed-oil": "cottonseed oil",
"courgette": "courgette",
@ -93,10 +93,10 @@
"trout": "trout",
"tuna": "tuna",
"five-spice-powder": "five spice powder",
"flour": "flour",
"flour": "小麦粉",
"frisee": "frisee",
"fructose": "fructose",
"fruit": "fruit",
"fruit": "果物",
"apple": "りんご",
"oranges": "オレンジ",
"pear": "ナシ",
@ -136,7 +136,7 @@
"beans": "beans",
"lentils": "lentils",
"lemongrass": "lemongrass",
"lettuce": "lettuce",
"lettuce": "レタス",
"liver": "liver",
"maple-syrup": "maple syrup",
"meat": "meat",
@ -150,7 +150,7 @@
"nuts": "ナッツ",
"nanaimo-bar-mix": "nanaimo bar mix",
"octopuses": "タコ",
"oils": "oils",
"oils": "",
"olive-oil": "オリーブ油",
"okra": "okra",
"olive": "オリーブ",
@ -172,10 +172,10 @@
"potatoes": "ジャガイモ",
"poultry": "鶏肉",
"powdered-sugar": "粉糖",
"pumpkin": "pumpkin",
"pumpkin": "カボチャ",
"pumpkin-seeds": "pumpkin seeds",
"radish": "radish",
"raw-sugar": "raw sugar",
"raw-sugar": "生の砂糖",
"refined-sugar": "refined sugar",
"rice-flour": "rice flour",
"rock-sugar": "rock sugar",
@ -185,7 +185,7 @@
"seeds": "seeds",
"sesame-seeds": "sesame seeds",
"sunflower-seeds": "sunflower seeds",
"soda": "soda",
"soda": "ソーダ",
"soda-baking": "soda, baking",
"soybean": "soybean",
"spaghetti-squash": "spaghetti squash",

View File

@ -24,7 +24,7 @@
"name": "Печива"
},
{
"name": "Продукти в консерва"
"name": "Canned Goods"
},
{
"name": "Допълнения"

View File

@ -47,13 +47,17 @@ from .recipe_comments import (
from .recipe_image_types import RecipeImageTypes
from .recipe_ingredient import (
CreateIngredientFood,
CreateIngredientFoodAlias,
CreateIngredientUnit,
CreateIngredientUnitAlias,
IngredientConfidence,
IngredientFood,
IngredientFoodAlias,
IngredientFoodPagination,
IngredientRequest,
IngredientsRequest,
IngredientUnit,
IngredientUnitAlias,
IngredientUnitPagination,
MergeFood,
MergeUnit,
@ -88,25 +92,6 @@ __all__ = [
"RecipeToolOut",
"RecipeToolResponse",
"RecipeToolSave",
"RecipeTimelineEventCreate",
"RecipeTimelineEventIn",
"RecipeTimelineEventOut",
"RecipeTimelineEventPagination",
"RecipeTimelineEventUpdate",
"TimelineEventImage",
"TimelineEventType",
"RecipeAsset",
"RecipeSettings",
"RecipeShareToken",
"RecipeShareTokenCreate",
"RecipeShareTokenSave",
"RecipeShareTokenSummary",
"RecipeDuplicate",
"RecipeSlug",
"RecipeZipTokenResponse",
"SlugResponse",
"UpdateImageResponse",
"RecipeNote",
"CategoryBase",
"CategoryIn",
"CategoryOut",
@ -117,12 +102,6 @@ __all__ = [
"TagIn",
"TagOut",
"TagSave",
"RecipeCommentCreate",
"RecipeCommentOut",
"RecipeCommentPagination",
"RecipeCommentSave",
"RecipeCommentUpdate",
"UserBase",
"AssignCategories",
"AssignSettings",
"AssignTags",
@ -130,28 +109,19 @@ __all__ = [
"ExportBase",
"ExportRecipes",
"ExportTypes",
"IngredientReferences",
"RecipeStep",
"RecipeShareToken",
"RecipeShareTokenCreate",
"RecipeShareTokenSave",
"RecipeShareTokenSummary",
"ScrapeRecipe",
"ScrapeRecipeTest",
"RecipeCommentCreate",
"RecipeCommentOut",
"RecipeCommentPagination",
"RecipeCommentSave",
"RecipeCommentUpdate",
"UserBase",
"RecipeImageTypes",
"Nutrition",
"CreateIngredientFood",
"CreateIngredientUnit",
"IngredientConfidence",
"IngredientFood",
"IngredientFoodPagination",
"IngredientRequest",
"IngredientUnit",
"IngredientUnitPagination",
"IngredientsRequest",
"MergeFood",
"MergeUnit",
"ParsedIngredient",
"RecipeIngredient",
"RecipeIngredientBase",
"RegisteredParser",
"SaveIngredientFood",
"SaveIngredientUnit",
"UnitFoodBase",
"CreateRecipe",
"CreateRecipeBulk",
"CreateRecipeByUrlBulk",
@ -165,6 +135,44 @@ __all__ = [
"RecipeTagPagination",
"RecipeTool",
"RecipeToolPagination",
"ScrapeRecipe",
"ScrapeRecipeTest",
"IngredientReferences",
"RecipeStep",
"CreateIngredientFood",
"CreateIngredientFoodAlias",
"CreateIngredientUnit",
"CreateIngredientUnitAlias",
"IngredientConfidence",
"IngredientFood",
"IngredientFoodAlias",
"IngredientFoodPagination",
"IngredientRequest",
"IngredientUnit",
"IngredientUnitAlias",
"IngredientUnitPagination",
"IngredientsRequest",
"MergeFood",
"MergeUnit",
"ParsedIngredient",
"RecipeIngredient",
"RecipeIngredientBase",
"RegisteredParser",
"SaveIngredientFood",
"SaveIngredientUnit",
"UnitFoodBase",
"RecipeAsset",
"RecipeTimelineEventCreate",
"RecipeTimelineEventIn",
"RecipeTimelineEventOut",
"RecipeTimelineEventPagination",
"RecipeTimelineEventUpdate",
"TimelineEventImage",
"TimelineEventType",
"RecipeDuplicate",
"RecipeSlug",
"RecipeZipTokenResponse",
"SlugResponse",
"UpdateImageResponse",
"Nutrition",
"RecipeSettings",
"RecipeNote",
]

View File

@ -33,12 +33,23 @@ def display_fraction(fraction: Fraction):
class UnitFoodBase(MealieModel):
name: str
plural_name: str | None = None
description: str = ""
extras: dict | None = {}
class CreateIngredientFoodAlias(MealieModel):
name: str
class IngredientFoodAlias(CreateIngredientFoodAlias):
class Config:
orm_mode = True
class CreateIngredientFood(UnitFoodBase):
label_id: UUID4 | None = None
aliases: list[CreateIngredientFoodAlias] = []
class SaveIngredientFood(CreateIngredientFood):
@ -48,10 +59,12 @@ class SaveIngredientFood(CreateIngredientFood):
class IngredientFood(CreateIngredientFood):
id: UUID4
label: MultiPurposeLabelSummary | None = None
aliases: list[IngredientFoodAlias] = []
created_at: datetime.datetime | None
update_at: datetime.datetime | None
_searchable_properties: ClassVar[list[str]] = ["name_normalized"]
_searchable_properties: ClassVar[list[str]] = ["name_normalized", "plural_name_normalized"]
_normalize_search: ClassVar[bool] = True
class Config:
@ -67,10 +80,21 @@ class IngredientFoodPagination(PaginationBase):
items: list[IngredientFood]
class CreateIngredientUnitAlias(MealieModel):
name: str
class IngredientUnitAlias(CreateIngredientUnitAlias):
class Config:
orm_mode = True
class CreateIngredientUnit(UnitFoodBase):
fraction: bool = True
abbreviation: str = ""
plural_abbreviation: str | None = ""
use_abbreviation: bool = False
aliases: list[CreateIngredientUnitAlias] = []
class SaveIngredientUnit(CreateIngredientUnit):
@ -79,10 +103,17 @@ class SaveIngredientUnit(CreateIngredientUnit):
class IngredientUnit(CreateIngredientUnit):
id: UUID4
aliases: list[IngredientUnitAlias] = []
created_at: datetime.datetime | None
update_at: datetime.datetime | None
_searchable_properties: ClassVar[list[str]] = ["name_normalized", "abbreviation_normalized"]
_searchable_properties: ClassVar[list[str]] = [
"name_normalized",
"plural_name_normalized",
"abbreviation_normalized",
"plural_abbreviation_normalized",
]
_normalize_search: ClassVar[bool] = True
class Config:
@ -165,6 +196,36 @@ class RecipeIngredientBase(MealieModel):
return f"{whole_number} {display_fraction(qty)}"
def _format_unit_for_display(self) -> str:
if not self.unit:
return ""
use_plural = self.quantity and self.quantity > 1
unit_val = ""
if self.unit.use_abbreviation:
if use_plural:
unit_val = self.unit.plural_abbreviation or self.unit.abbreviation
else:
unit_val = self.unit.abbreviation
if not unit_val:
if use_plural:
unit_val = self.unit.plural_name or self.unit.name
else:
unit_val = self.unit.name
return unit_val
def _format_food_for_display(self) -> str:
if not self.food:
return ""
use_plural = (not self.quantity) or self.quantity > 1
if use_plural:
return self.food.plural_name or self.food.name
else:
return self.food.name
def _format_display(self) -> str:
components = []
@ -183,15 +244,15 @@ class RecipeIngredientBase(MealieModel):
components.append(self.note or "")
else:
if self.quantity and self.unit:
components.append(self.unit.abbreviation if self.unit.use_abbreviation else self.unit.name)
components.append(self._format_unit_for_display())
if self.food:
components.append(self.food.name)
components.append(self._format_food_for_display())
if self.note:
components.append(self.note)
return " ".join(components)
return " ".join(components).strip()
class IngredientUnitPagination(PaginationBase):

View File

@ -38,36 +38,60 @@ class ABCIngredientParser(ABC):
self.group_id = group_id
self.session = session
self._foods_by_name: dict[str, IngredientFood] | None = None
self._units_by_name: dict[str, IngredientUnit] | None = None
self._foods_by_alias: dict[str, IngredientFood] | None = None
self._units_by_alias: dict[str, IngredientUnit] | None = None
@property
def _repos(self) -> AllRepositories:
return get_repositories(self.session)
@property
def foods_by_normalized_name(self) -> dict[str, IngredientFood]:
if self._foods_by_name is None:
def foods_by_alias(self) -> dict[str, IngredientFood]:
if self._foods_by_alias is None:
foods_repo = self._repos.ingredient_foods.by_group(self.group_id)
query = PaginationQuery(page=1, per_page=-1)
all_foods = foods_repo.page_all(query).items
self._foods_by_name = {IngredientFoodModel.normalize(food.name): food for food in all_foods if food.name}
return self._foods_by_name
foods_by_alias: dict[str, IngredientFood] = {}
for food in all_foods:
if food.name:
foods_by_alias[IngredientFoodModel.normalize(food.name)] = food
if food.plural_name:
foods_by_alias[IngredientFoodModel.normalize(food.plural_name)] = food
for alias in food.aliases or []:
if alias.name:
foods_by_alias[IngredientFoodModel.normalize(alias.name)] = food
self._foods_by_alias = foods_by_alias
return self._foods_by_alias
@property
def units_by_normalized_name_or_abbreviation(self) -> dict[str, IngredientUnit]:
if self._units_by_name is None:
def units_by_alias(self) -> dict[str, IngredientUnit]:
if self._units_by_alias is None:
units_repo = self._repos.ingredient_units.by_group(self.group_id)
query = PaginationQuery(page=1, per_page=-1)
all_units = units_repo.page_all(query).items
self._units_by_name = {
IngredientUnitModel.normalize(unit.name): unit for unit in all_units if unit.name
} | {IngredientUnitModel.normalize(unit.abbreviation): unit for unit in all_units if unit.abbreviation}
return self._units_by_name
units_by_alias: dict[str, IngredientUnit] = {}
for unit in all_units:
if unit.name:
units_by_alias[IngredientUnitModel.normalize(unit.name)] = unit
if unit.plural_name:
units_by_alias[IngredientUnitModel.normalize(unit.plural_name)] = unit
if unit.abbreviation:
units_by_alias[IngredientUnitModel.normalize(unit.abbreviation)] = unit
if unit.plural_abbreviation:
units_by_alias[IngredientUnitModel.normalize(unit.plural_abbreviation)] = unit
for alias in unit.aliases or []:
if alias.name:
units_by_alias[IngredientUnitModel.normalize(alias.name)] = unit
self._units_by_alias = units_by_alias
return self._units_by_alias
@property
def food_fuzzy_match_threshold(self) -> int:
@ -111,7 +135,7 @@ class ABCIngredientParser(ABC):
match_value = IngredientFoodModel.normalize(food.name)
return self.find_match(
match_value,
store_map=self.foods_by_normalized_name,
store_map=self.foods_by_alias,
fuzzy_match_threshold=self.food_fuzzy_match_threshold,
)
@ -122,7 +146,7 @@ class ABCIngredientParser(ABC):
match_value = IngredientUnitModel.normalize(unit.name)
return self.find_match(
match_value,
store_map=self.units_by_normalized_name_or_abbreviation,
store_map=self.units_by_alias,
fuzzy_match_threshold=self.unit_fuzzy_match_threshold,
)

View File

@ -0,0 +1,202 @@
from uuid import uuid4
import pytest
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit, RecipeIngredient
from tests.utils.factories import random_string
@pytest.mark.parametrize(
["quantity", "quantity_display_decimal", "quantity_display_fraction", "expect_plural_unit", "expect_plural_food"],
[
[0, "", "", False, True],
[0.5, "0.5", "¹/₂", False, False],
[1, "1", "1", False, False],
[1.5, "1.5", "1 ¹/₂", True, True],
[2, "2", "2", True, True],
],
)
@pytest.mark.parametrize(
["unit", "expect_display_fraction", "expected_unit_singular_string", "expected_unit_plural_string"],
[
[
IngredientUnit(
id=uuid4(),
name="tablespoon",
plural_name=None,
abbreviation="tbsp",
plural_abbreviation=None,
use_abbreviation=False,
fraction=True,
),
True,
"tablespoon",
"tablespoon",
],
[
IngredientUnit(
id=uuid4(),
name="tablespoon",
plural_name=None,
abbreviation="tbsp",
plural_abbreviation=None,
use_abbreviation=False,
fraction=False,
),
False,
"tablespoon",
"tablespoon",
],
[
IngredientUnit(
id=uuid4(),
name="tablespoon",
plural_name=None,
abbreviation="tbsp",
plural_abbreviation=None,
use_abbreviation=True,
fraction=True,
),
True,
"tbsp",
"tbsp",
],
[
IngredientUnit(
id=uuid4(),
name="tablespoon",
plural_name=None,
abbreviation="tbsp",
plural_abbreviation=None,
use_abbreviation=True,
fraction=False,
),
False,
"tbsp",
"tbsp",
],
[
IngredientUnit(
id=uuid4(),
name="tablespoon",
plural_name="tablespoons",
abbreviation="tbsp",
plural_abbreviation="tbsps",
use_abbreviation=False,
fraction=True,
),
True,
"tablespoon",
"tablespoons",
],
[
IngredientUnit(
id=uuid4(),
name="tablespoon",
plural_name="tablespoons",
abbreviation="tbsp",
plural_abbreviation="tbsps",
use_abbreviation=False,
fraction=False,
),
False,
"tablespoon",
"tablespoons",
],
[
IngredientUnit(
id=uuid4(),
name="tablespoon",
plural_name="tablespoons",
abbreviation="tbsp",
plural_abbreviation="tbsps",
use_abbreviation=True,
fraction=True,
),
True,
"tbsp",
"tbsps",
],
[
IngredientUnit(
id=uuid4(),
name="tablespoon",
plural_name="tablespoons",
abbreviation="tbsp",
plural_abbreviation="tbsps",
use_abbreviation=True,
fraction=False,
),
False,
"tbsp",
"tbsps",
],
],
)
@pytest.mark.parametrize(
["food", "expected_food_singular_string", "expected_food_plural_string"],
[
[
IngredientFood(id=uuid4(), name="chopped onion", plural_name=None),
"chopped onion",
"chopped onion",
],
[
IngredientFood(id=uuid4(), name="chopped onion", plural_name="chopped onions"),
"chopped onion",
"chopped onions",
],
],
)
@pytest.mark.parametrize("note", ["very thin", ""])
@pytest.mark.parametrize("use_food", [True, False])
def test_ingredient_display(
quantity: float | None,
quantity_display_decimal: str,
quantity_display_fraction: str,
unit: IngredientUnit,
food: IngredientFood,
note: str,
use_food: bool,
expect_display_fraction: bool,
expect_plural_unit: bool,
expect_plural_food: bool,
expected_unit_singular_string: str,
expected_unit_plural_string: str,
expected_food_singular_string: str,
expected_food_plural_string: str,
):
expected_components = []
if use_food:
if expect_display_fraction:
expected_components.append(quantity_display_fraction)
else:
expected_components.append(quantity_display_decimal)
if quantity:
if expect_plural_unit:
expected_components.append(expected_unit_plural_string)
else:
expected_components.append(expected_unit_singular_string)
if expect_plural_food:
expected_components.append(expected_food_plural_string)
else:
expected_components.append(expected_food_singular_string)
expected_components.append(note)
else:
if quantity != 0 and quantity != 1:
if expect_display_fraction:
expected_components.append(quantity_display_fraction)
else:
expected_components.append(quantity_display_decimal)
expected_components.append(note)
expected_display_value = " ".join(c for c in expected_components if c)
ingredient = RecipeIngredient(
quantity=quantity, unit=unit, food=food, note=note, use_food=use_food, disable_amount=not use_food
)
assert ingredient.display == expected_display_value

View File

@ -9,7 +9,9 @@ from mealie.db.db_setup import session_context
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe_ingredient import (
CreateIngredientFood,
CreateIngredientFoodAlias,
CreateIngredientUnit,
CreateIngredientUnitAlias,
IngredientFood,
IngredientUnit,
ParsedIngredient,
@ -67,6 +69,12 @@ def parsed_ingredient_data(
SaveIngredientFood(name="fresh ginger", group_id=unique_local_group_id),
SaveIngredientFood(name="ground ginger", group_id=unique_local_group_id),
SaveIngredientFood(name="ñör̃m̈ãl̈ĩz̈ẽm̈ẽ", group_id=unique_local_group_id),
SaveIngredientFood(name="PluralFoodTest", plural_name="myfoodisplural", group_id=unique_local_group_id),
SaveIngredientFood(
name="IHaveAnAlias",
group_id=unique_local_group_id,
aliases=[CreateIngredientFoodAlias(name="thisismyalias")],
),
]
)
@ -86,6 +94,18 @@ def parsed_ingredient_data(
SaveIngredientUnit(name="Teaspoon", group_id=unique_local_group_id),
SaveIngredientUnit(name="Stalk", group_id=unique_local_group_id),
SaveIngredientUnit(name="My Very Long Unit Name", abbreviation="mvlun", group_id=unique_local_group_id),
SaveIngredientUnit(
name="PluralUnitName",
plural_name="abc123",
abbreviation="doremiabc",
plural_abbreviation="doremi123",
group_id=unique_local_group_id,
),
SaveIngredientUnit(
name="IHaveAnAliasToo",
group_id=unique_local_group_id,
aliases=[CreateIngredientUnitAlias(name="thisismyalias")],
),
]
)
@ -267,6 +287,46 @@ def test_brute_parser(unique_user: TestUser):
True,
id="normalization",
),
pytest.param(
build_parsed_ing(unit=None, food="myfoodisplural"),
None,
"PluralFoodTest",
False,
True,
id="plural food name",
),
pytest.param(
build_parsed_ing(unit="abc123", food=None),
"PluralUnitName",
None,
True,
False,
id="plural unit name",
),
pytest.param(
build_parsed_ing(unit="doremi123", food=None),
"PluralUnitName",
None,
True,
False,
id="plural unit abbreviation",
),
pytest.param(
build_parsed_ing(unit=None, food="thisismyalias"),
None,
"IHaveAnAlias",
False,
True,
id="food alias",
),
pytest.param(
build_parsed_ing(unit="thisismyalias", food=None),
"IHaveAnAliasToo",
None,
True,
False,
id="unit alias",
),
),
)
def test_parser_ingredient_match(