mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-04 15:03:10 -05:00
Feature/CRF++ and server side locales (#731)
* add universal toast plugin * add server side locales * integrate CRF++ into CI/CD Pipeline * docs(docs): 📝 add recipe parser docs * feat(backend): ✨ Continued work on ingredient parsers * add new model dest * feat(frontend): ✨ New ingredient parser page * formatting Co-authored-by: Hayden <hay-kot@pm.me>
This commit is contained in:
@@ -9,6 +9,7 @@ from mealie.core.config import get_app_dirs, get_app_settings
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.db.database import get_database
|
||||
from mealie.db.db_setup import SessionLocal
|
||||
from mealie.lang import get_locale_provider
|
||||
from mealie.schema.user.user import PrivateUser
|
||||
|
||||
logger = get_logger()
|
||||
@@ -64,10 +65,11 @@ class BaseHttpService(Generic[T, D], ABC):
|
||||
self.db = get_database(session)
|
||||
self.app_dirs = get_app_dirs()
|
||||
self.settings = get_app_settings()
|
||||
self.t = get_locale_provider().t
|
||||
|
||||
def _existing_factory(dependency: Type[CLS_DEP]) -> classmethod:
|
||||
def cls_method(cls, item_id: T, deps: CLS_DEP = Depends(dependency)):
|
||||
new_class = cls(deps.session, deps.user, deps.bg_task)
|
||||
new_class = cls(session=deps.session, user=deps.user, background_tasks=deps.bg_task)
|
||||
new_class.assert_existing(item_id)
|
||||
return new_class
|
||||
|
||||
@@ -75,7 +77,7 @@ class BaseHttpService(Generic[T, D], ABC):
|
||||
|
||||
def _class_method_factory(dependency: Type[CLS_DEP]) -> classmethod:
|
||||
def cls_method(cls, deps: CLS_DEP = Depends(dependency)):
|
||||
return cls(deps.session, deps.user, deps.bg_task)
|
||||
return cls(session=deps.session, user=deps.user, background_tasks=deps.bg_task)
|
||||
|
||||
return classmethod(cls_method)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
@@ -8,6 +10,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.db.data_access_layer._access_model import AccessModel
|
||||
from mealie.schema.response import ErrorResponse
|
||||
|
||||
C = TypeVar("C", bound=BaseModel)
|
||||
R = TypeVar("R", bound=BaseModel)
|
||||
@@ -29,12 +32,23 @@ class CrudHttpMixins(Generic[C, R, U], ABC):
|
||||
self.item = self.dal.get_one(id)
|
||||
return self.item
|
||||
|
||||
def _create_one(self, data: C, exception_msg="generic-create-error") -> R:
|
||||
def _create_one(self, data: C, default_msg="generic-create-error", exception_msgs: dict | None = None) -> R:
|
||||
try:
|
||||
self.item = self.dal.create(data)
|
||||
except Exception as ex:
|
||||
logger.exception(ex)
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail={"message": exception_msg, "exception": str(ex)})
|
||||
|
||||
msg = default_msg
|
||||
if exception_msgs:
|
||||
msg = exception_msgs.get(type(ex), default_msg)
|
||||
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=ErrorResponse(
|
||||
message=msg,
|
||||
exception=str(ex),
|
||||
).dict(),
|
||||
)
|
||||
|
||||
return self.item
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from mealie.core.config import get_app_dirs, get_app_settings
|
||||
from mealie.lang import get_locale_provider
|
||||
|
||||
|
||||
class BaseService:
|
||||
def __init__(self) -> None:
|
||||
self.app_dirs = get_app_dirs()
|
||||
self.settings = get_app_settings()
|
||||
self.t = get_locale_provider()
|
||||
|
||||
1
mealie/services/parser_services/__init__.py
Normal file
1
mealie/services/parser_services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .ingredient_parser_service import *
|
||||
@@ -38,7 +38,7 @@ def replace_fraction_unicode(string: str):
|
||||
continue
|
||||
if name.startswith("VULGAR FRACTION"):
|
||||
normalized = unicodedata.normalize("NFKC", c)
|
||||
numerator, _slash, denominator = normalized.partition("⁄")
|
||||
numerator, _, denominator = normalized.partition("⁄") # _ = slash
|
||||
text = f" {numerator}/{denominator}"
|
||||
return string.replace(c, text).replace(" ", " ")
|
||||
|
||||
46
mealie/services/parser_services/crfpp/processor.py
Normal file
46
mealie/services/parser_services/crfpp/processor.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import subprocess
|
||||
import tempfile
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
from . import utils
|
||||
from .pre_processor import pre_process_string
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
MODEL_PATH = CWD / "model.crfmodel"
|
||||
|
||||
|
||||
class CRFIngredient(BaseModel):
|
||||
input: str = ""
|
||||
name: str = ""
|
||||
other: str = ""
|
||||
qty: str = ""
|
||||
comment: str = ""
|
||||
unit: str = ""
|
||||
|
||||
@validator("qty", always=True, pre=True)
|
||||
def validate_qty(qty, values): # sourcery skip: merge-nested-ifs
|
||||
if qty is None or qty == "":
|
||||
# Check if other contains a fraction
|
||||
if values["other"] is not None and values["other"].find("/") != -1:
|
||||
return float(Fraction(values["other"])).__round__(1)
|
||||
else:
|
||||
return 1
|
||||
|
||||
return qty
|
||||
|
||||
|
||||
def _exec_crf_test(input_text):
|
||||
with tempfile.NamedTemporaryFile(mode="w") as input_file:
|
||||
input_file.write(utils.export_data(input_text))
|
||||
input_file.flush()
|
||||
return subprocess.check_output(["crf_test", "--verbose=1", "--model", MODEL_PATH, input_file.name]).decode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
|
||||
def convert_list_to_crf_model(list_of_ingrdeint_text: list[str]):
|
||||
crf_output = _exec_crf_test([pre_process_string(x) for x in list_of_ingrdeint_text])
|
||||
return [CRFIngredient(**ingredient) for ingredient in utils.import_data(crf_output.split("\n"))]
|
||||
55
mealie/services/parser_services/ingredient_parser.py
Normal file
55
mealie/services/parser_services/ingredient_parser.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from fractions import Fraction
|
||||
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.schema.recipe import RecipeIngredient
|
||||
from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, CreateIngredientUnit
|
||||
|
||||
from .crfpp.processor import CRFIngredient, convert_list_to_crf_model
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ABCIngredientParser(ABC):
|
||||
"""
|
||||
Abstract class for ingredient parsers.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def parse(self, ingredients: list[str]) -> list[RecipeIngredient]:
|
||||
...
|
||||
|
||||
|
||||
class CRFPPIngredientParser(ABCIngredientParser):
|
||||
"""
|
||||
Class for CRFPP ingredient parsers.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def _crf_to_ingredient(self, crf_model: CRFIngredient) -> RecipeIngredient:
|
||||
ingredient = None
|
||||
|
||||
try:
|
||||
ingredient = RecipeIngredient(
|
||||
title="",
|
||||
note=crf_model.comment,
|
||||
unit=CreateIngredientUnit(name=crf_model.unit),
|
||||
food=CreateIngredientFood(name=crf_model.name),
|
||||
disable_amount=False,
|
||||
quantity=float(sum(Fraction(s) for s in crf_model.qty.split())),
|
||||
)
|
||||
except Exception as e:
|
||||
# TODO: Capture some sort of state for the user to see that an exception occured
|
||||
logger.exception(e)
|
||||
ingredient = RecipeIngredient(
|
||||
title="",
|
||||
note=crf_model.input,
|
||||
)
|
||||
|
||||
return ingredient
|
||||
|
||||
def parse(self, ingredients: list[str]) -> list[RecipeIngredient]:
|
||||
crf_models = convert_list_to_crf_model(ingredients)
|
||||
return [self._crf_to_ingredient(crf_model) for crf_model in crf_models]
|
||||
28
mealie/services/parser_services/ingredient_parser_service.py
Normal file
28
mealie/services/parser_services/ingredient_parser_service.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from mealie.schema.recipe import RecipeIngredient
|
||||
from mealie.services._base_http_service.http_services import UserHttpService
|
||||
|
||||
from .ingredient_parser import ABCIngredientParser, CRFPPIngredientParser
|
||||
|
||||
|
||||
class IngredientParserService(UserHttpService):
|
||||
def __init__(self, parser: ABCIngredientParser = None, *args, **kwargs) -> None:
|
||||
self.parser: ABCIngredientParser = parser() if parser else CRFPPIngredientParser()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def populate_item(self) -> None:
|
||||
"""Satisfy abstract method"""
|
||||
pass
|
||||
|
||||
def parse_ingredient(self, ingredient: str) -> RecipeIngredient:
|
||||
parsed = self.parser.parse([ingredient])
|
||||
|
||||
if parsed:
|
||||
return parsed[0]
|
||||
# TODO: Raise Exception
|
||||
|
||||
def parse_ingredients(self, ingredients: list[str]) -> list[RecipeIngredient]:
|
||||
parsed = self.parser.parse(ingredients)
|
||||
|
||||
if parsed:
|
||||
return parsed
|
||||
# TODO: Raise Exception
|
||||
@@ -7,6 +7,7 @@ from typing import Union
|
||||
from zipfile import ZipFile
|
||||
|
||||
from fastapi import Depends, HTTPException, UploadFile, status
|
||||
from sqlalchemy import exc
|
||||
|
||||
from mealie.core.dependencies.grouped import PublicDeps, UserDeps
|
||||
from mealie.core.root_logger import get_logger
|
||||
@@ -33,6 +34,10 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
|
||||
|
||||
event_func = create_recipe_event
|
||||
|
||||
@cached_property
|
||||
def exception_key(self) -> dict:
|
||||
return {exc.IntegrityError: self.t("recipe.unique-name-error")}
|
||||
|
||||
@cached_property
|
||||
def dal(self) -> RecipeDataAccessModel:
|
||||
return self.db.recipes
|
||||
@@ -53,14 +58,13 @@ class RecipeService(CrudHttpMixins[CreateRecipe, Recipe, Recipe], UserHttpServic
|
||||
if not self.item.settings.public and not self.user:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN)
|
||||
|
||||
# CRUD METHODS
|
||||
def get_all(self, start=0, limit=None):
|
||||
items = self.db.recipes.summary(self.user.group_id, start=start, limit=limit)
|
||||
return [RecipeSummary.construct(**x.__dict__) for x in items]
|
||||
|
||||
def create_one(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe:
|
||||
create_data = recipe_creation_factory(self.user, name=create_data.name, additional_attrs=create_data.dict())
|
||||
self._create_one(create_data, "RECIPE_ALREAD_EXISTS")
|
||||
self._create_one(create_data, self.t("generic.server-error"), self.exception_key)
|
||||
self._create_event(
|
||||
"Recipe Created",
|
||||
f"'{self.item.name}' by {self.user.username} \n {self.settings.BASE_URL}/recipe/{self.item.slug}",
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import subprocess
|
||||
import tempfile
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.schema.recipe import RecipeIngredient
|
||||
from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, CreateIngredientUnit
|
||||
|
||||
from . import utils
|
||||
from .pre_processor import pre_process_string
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
MODEL_PATH = CWD / "model.crfmodel"
|
||||
settings = get_app_settings()
|
||||
|
||||
|
||||
INGREDIENT_TEXT = [
|
||||
"2 tablespoons honey",
|
||||
"1/2 cup flour",
|
||||
"Black pepper, to taste",
|
||||
"2 cups of garlic finely chopped",
|
||||
"2 liters whole milk",
|
||||
]
|
||||
|
||||
|
||||
class CRFIngredient(BaseModel):
|
||||
input: Optional[str] = ""
|
||||
name: Optional[str] = ""
|
||||
other: Optional[str] = ""
|
||||
qty: Optional[str] = ""
|
||||
comment: Optional[str] = ""
|
||||
unit: Optional[str] = ""
|
||||
|
||||
@validator("qty", always=True, pre=True)
|
||||
def validate_qty(qty, values): # sourcery skip: merge-nested-ifs
|
||||
if qty is None or qty == "":
|
||||
# Check if other contains a fraction
|
||||
if values["other"] is not None and values["other"].find("/") != -1:
|
||||
return float(Fraction(values["other"])).__round__(1)
|
||||
else:
|
||||
return 1
|
||||
|
||||
return qty
|
||||
|
||||
|
||||
def _exec_crf_test(input_text):
|
||||
with tempfile.NamedTemporaryFile(mode="w") as input_file:
|
||||
input_file.write(utils.export_data(input_text))
|
||||
input_file.flush()
|
||||
return subprocess.check_output(["crf_test", "--verbose=1", "--model", MODEL_PATH, input_file.name]).decode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
|
||||
def convert_list_to_crf_model(list_of_ingrdeint_text: list[str]):
|
||||
crf_output = _exec_crf_test([pre_process_string(x) for x in list_of_ingrdeint_text])
|
||||
crf_models = [CRFIngredient(**ingredient) for ingredient in utils.import_data(crf_output.split("\n"))]
|
||||
|
||||
for model in crf_models:
|
||||
print(model)
|
||||
|
||||
return crf_models
|
||||
|
||||
|
||||
def convert_crf_models_to_ingredients(crf_models: list[CRFIngredient]):
|
||||
return [
|
||||
RecipeIngredient(
|
||||
title="",
|
||||
note=crf_model.comment,
|
||||
unit=CreateIngredientUnit(name=crf_model.unit),
|
||||
food=CreateIngredientFood(name=crf_model.name),
|
||||
disable_amount=settings.RECIPE_DISABLE_AMOUNT,
|
||||
quantity=float(sum(Fraction(s) for s in crf_model.qty.split())),
|
||||
)
|
||||
for crf_model in crf_models
|
||||
]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
crf_models = convert_list_to_crf_model(INGREDIENT_TEXT)
|
||||
ingredients = convert_crf_models_to_ingredients(crf_models)
|
||||
Reference in New Issue
Block a user