mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-24 01:12:54 -04:00
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:
parent
4b55b838ed
commit
d440d51ffe
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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 ###
|
@ -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>
|
@ -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 () {
|
||||
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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:
|
||||
|
@ -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",
|
||||
]
|
||||
|
@ -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):
|
||||
|
@ -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,
|
||||
)
|
||||
|
||||
|
@ -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
|
@ -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(
|
||||
|
Loading…
x
Reference in New Issue
Block a user