feat: plural foods and units, and aliases (#2674)

* added plural names and alias tables to foods/units

* updated models to include plural names and aliases

* updated parser to include plural and aliases

* fixed migrations

* fixed recursive models

* added plural abbreviation to migration

* updated parser and display prop

* update displays to use plurals

* fix display edgecase and remove print

* added/updated display tests

* fixed model bug and added parser tests

* added tests for aliases

* added new plural options to data management page

* removed unique constraint

* made base dialog more customizable

* added alias management to food and unit data pages

* removed unused awaits

* 🧹
This commit is contained in:
Michael Genson 2023-11-14 09:39:07 -06:00 committed by GitHub
parent 4b55b838ed
commit d440d51ffe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 1181 additions and 104 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

@ -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",
@ -888,7 +889,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.",
@ -899,13 +902,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.",
@ -934,6 +942,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

@ -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

@ -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(