Compare commits

...

45 Commits

Author SHA1 Message Date
Michael Genson
21261bcd9f add missing lang strings 2026-02-22 22:51:59 +00:00
Michael Genson
6c08b1cba4 fix tests 2026-02-22 22:31:23 +00:00
Michael Genson
eebff3f481 fix fluid ounce -> fluid_ounce 2026-02-22 22:25:15 +00:00
Michael Genson
89b1629d68 Merge branch 'mealie-next' into feat/standardize-units 2026-02-22 16:08:20 -06:00
Michael Genson
0380baedb1 add to data management page 2026-02-22 21:35:08 +00:00
Michael Genson
74c73f051d code gen 2026-02-22 20:19:57 +00:00
Michael Genson
0fc4ff9c75 add migration data/test 2026-02-22 20:12:01 +00:00
Michael Genson
2c5ab9e40e add shopping list merge tests 2026-02-22 20:11:41 +00:00
Michael Genson
efab33ccc5 find unit during shopping list item creation 2026-02-22 19:17:07 +00:00
Michael Genson
6b8b929483 refactor datamatcher to provide units/foods by id 2026-02-22 19:16:28 +00:00
Michael Genson
96c056adfd updated seeder test 2026-02-22 18:31:49 +00:00
Michael Genson
212560c822 add unit repo tests 2026-02-22 17:58:59 +00:00
Michael Genson
787fdf5d74 don't overwrite user-provided standardization data 2026-02-22 17:57:30 +00:00
Michael Genson
a48a9fa10c add uc tests 2026-02-22 17:33:43 +00:00
Michael Genson
1a32bcc1fd re-org parser tests 2026-02-22 17:33:35 +00:00
Michael Genson
ee482afbd2 fix oz -> fl oz conversion in merge 2026-02-22 17:32:51 +00:00
Michael Genson
48cdf27ea9 better error info 2026-02-22 17:32:26 +00:00
Michael Genson
fedd1d9eb6 ??? 2026-02-22 05:50:53 +00:00
Hayden
3a01925e48 chore(l10n): New Crowdin updates (#7116) 2026-02-22 05:31:27 +00:00
Michael Genson
3af9b05bd8 add auto-standardization to migration 2026-02-22 02:56:48 +00:00
Michael Genson
fe9dadefea inject known standardized units upon unit creation 2026-02-22 02:19:14 +00:00
Michael Genson
122ef2d867 add key to locale config 2026-02-22 02:13:01 +00:00
Michael Genson
5edd95ed6d refactor seeders to move file management to class level 2026-02-22 02:12:46 +00:00
Michael Genson
6cd7cdff77 merge units in shopping list using conversions 2026-02-21 22:25:53 +00:00
Michael Genson
be92363538 add helper function for merging mealie units 2026-02-21 22:25:33 +00:00
Michael Genson
74a0671c70 add missing return self to validator 2026-02-21 20:38:51 +00:00
Michael Genson
e772bb6834 add unit utils 2026-02-21 20:16:48 +00:00
Michael Genson
6bf80adca1 add pint lib 2026-02-21 20:06:13 +00:00
Michael Genson
492492939e add to schema 2026-02-21 20:06:03 +00:00
Michael Genson
d9b7f0a3a1 add unit standardization fields 2026-02-21 18:01:32 +00:00
Hayden
16e2386f5a chore(l10n): New Crowdin updates (#7112) 2026-02-21 17:27:20 +00:00
renovate[bot]
bbfa105e99 fix(deps): update dependency fastapi to v0.129.1 (#7111)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-21 14:17:34 +00:00
Hayden
c94c9940b2 chore(l10n): New Crowdin updates (#7110)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2026-02-21 05:44:05 +00:00
Michael Genson
29c6176d89 docs: Remove redoc API generation (#7109) 2026-02-20 20:49:43 +00:00
renovate[bot]
0c0d7d11a5 chore(deps): update dependency pylint to v4.0.5 (#7106)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-20 18:14:48 +00:00
renovate[bot]
e75fc6d391 chore(deps): update dependency ruff to v0.15.2 (#7104)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-20 05:06:50 +00:00
Hayden
f308869154 chore(l10n): New Crowdin updates (#7105) 2026-02-20 04:45:05 +00:00
Michael Genson
af30b8bdfa docs: Add missing release tags to OpenAI docs (#7102) 2026-02-19 14:31:58 -06:00
renovate[bot]
de4f22c3f6 fix(deps): update dependency pydantic-settings to v2.13.1 (#7101)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-19 18:06:46 +00:00
renovate[bot]
4c55b282d6 chore(deps): update dependency rich to v14.3.3 (#7100)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-19 18:06:43 +00:00
Hayden
8d2b2eb581 chore(l10n): New Crowdin updates (#7098)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2026-02-19 00:24:41 +00:00
Michael Genson
e9daac5fc4 feat: Auto-adjust shopping list item autofocus (#7096) 2026-02-18 16:37:32 -06:00
renovate[bot]
ee1205cfdc chore(deps): update dependency mkdocs-material to v9.7.2 (#7093)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-18 21:07:43 +00:00
renovate[bot]
a165b707af fix(deps): update dependency pillow-heif to v1.2.1 (#7092)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-18 21:07:11 +00:00
Hayden
564385eb83 chore(l10n): New Crowdin updates (#7088) 2026-02-17 23:53:36 +00:00
60 changed files with 2559 additions and 1559 deletions

View File

@@ -25,16 +25,9 @@ dotenv:
- .env
- .dev.env
tasks:
docs:gen:
desc: runs the API documentation generator
cmds:
- uv run python dev/code-generation/gen_docs_api.py
docs:
desc: runs the documentation server
dir: docs
deps:
- docs:gen
cmds:
- uv run python -m mkdocs serve
@@ -81,7 +74,6 @@ tasks:
desc: run code generators
cmds:
- uv run python dev/code-generation/main.py {{ .CLI_ARGS }}
- task: docs:gen
- task: py:format
dev:services:
@@ -350,4 +342,4 @@ tasks:
vars: { WAIT_UNTIL_HEALTHY: true }
- defer: { task: e2e:stop-server }
- task: e2e:test
vars: { PREVENT_REPORT_OPEN: true }
vars: { PREVENT_REPORT_OPEN: true }

View File

@@ -1,80 +0,0 @@
import json
from datetime import UTC, datetime
from typing import Any
from fastapi import FastAPI
from mealie.app import app
from mealie.core.config import determine_data_dir
DATA_DIR = determine_data_dir()
"""Script to export the ReDoc documentation page into a standalone HTML file."""
HTML_TEMPLATE = """<!-- Custom HTML site displayed as the Home chapter -->
{% extends "main.html" %}
{% block tabs %}
{{ super() }}
<style>
body {
margin: 0;
padding: 0;
}
</style>
<div id="redoc-container"></div>
<script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"> </script>
<script>
var spec = MY_SPECIFIC_TEXT;
Redoc.init(spec, {}, document.getElementById("redoc-container"));
</script>
{% endblock %}
{% block content %}{% endblock %}
{% block footer %}{% endblock %}
"""
HTML_PATH = DATA_DIR.parent.parent.joinpath("docs/docs/overrides/api.html")
CONSTANT_DT = datetime(2025, 10, 24, 15, 53, 0, 0, tzinfo=UTC)
def normalize_timestamps(s: dict[str, Any]) -> dict[str, Any]:
field_format = s.get("format")
is_timestamp = field_format in ["date-time", "date", "time"]
has_default = s.get("default")
if not is_timestamp:
for k, v in s.items():
if isinstance(v, dict):
s[k] = normalize_timestamps(v)
elif isinstance(v, list):
s[k] = [normalize_timestamps(i) if isinstance(i, dict) else i for i in v]
return s
elif not has_default:
return s
if field_format == "date-time":
s["default"] = CONSTANT_DT.isoformat()
elif field_format == "date":
s["default"] = CONSTANT_DT.date().isoformat()
elif field_format == "time":
s["default"] = CONSTANT_DT.time().isoformat()
return s
def generate_api_docs(my_app: FastAPI):
openapi_schema = my_app.openapi()
openapi_schema = normalize_timestamps(openapi_schema)
with open(HTML_PATH, "w") as fd:
text = HTML_TEMPLATE.replace("MY_SPECIFIC_TEXT", json.dumps(openapi_schema))
fd.write(text)
if __name__ == "__main__":
generate_api_docs(app)

View File

@@ -1,4 +0,0 @@
---
title: API
template: api.html
---

View File

@@ -124,16 +124,16 @@ For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values
| Variables | Default | Description |
|---------------------------------------------------|:-------:|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| OPENAI_BASE_URL<super>[&dagger;][secrets]</super> | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
| OPENAI_API_KEY<super>[&dagger;][secrets]</super> | None | Your OpenAI API Key. Enables OpenAI-related features |
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
| OPENAI_CUSTOM_HEADERS | None | Custom HTTP headers to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
| OPENAI_CUSTOM_PARAMS | None | Custom HTTP query params to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
| OPENAI_ENABLE_IMAGE_SERVICES | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
| OPENAI_REQUEST_TIMEOUT | 300 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
| OPENAI_CUSTOM_PROMPT_DIR | None | Path to custom prompt files. Only existing files in your custom directory will override the defaults; any missing or empty custom files will automatically fall back to the system defaults. See https://github.com/mealie-recipes/mealie/tree/mealie-next/mealie/services/openai/prompts for expected file names. |
| OPENAI_BASE_URL<super>[&dagger;][secrets]</super> | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
| OPENAI_API_KEY<super>[&dagger;][secrets]</super> | None | Your OpenAI API Key. Enables OpenAI-related features |
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
| OPENAI_CUSTOM_HEADERS <br/> :octicons-tag-24: v2.0.0 | None | Custom HTTP headers to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
| OPENAI_CUSTOM_PARAMS <br/> :octicons-tag-24: v2.0.0 | None | Custom HTTP query params to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
| OPENAI_ENABLE_IMAGE_SERVICES <br/> :octicons-tag-24: v1.12.0 | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
| OPENAI_REQUEST_TIMEOUT | 300 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
| OPENAI_CUSTOM_PROMPT_DIR <br/> :octicons-tag-24: v3.10.0 | None | Path to custom prompt files. Only existing files in your custom directory will override the defaults; any missing or empty custom files will automatically fall back to the system defaults. See https://github.com/mealie-recipes/mealie/tree/mealie-next/mealie/services/openai/prompts for expected file names. |
### Theming

File diff suppressed because one or more lines are too long

View File

@@ -93,7 +93,7 @@ nav:
- iOS Shortcut: "documentation/community-guide/ios-shortcut.md"
- Reverse Proxy (SWAG): "documentation/community-guide/swag.md"
- API Reference: "api/redoc.md"
- API Reference: "https://demo.mealie.io/docs"
- Contributors Guide:
- Non-Code: "contributors/non-coders.md"

View File

@@ -30,6 +30,7 @@
:items="foods"
:label="$t('shopping-list.food')"
:icon="$globals.icons.foods"
:autofocus="autoFocus === 'food'"
create
@create="createAssignFood"
/>
@@ -41,7 +42,7 @@
:label="$t('shopping-list.note')"
rows="1"
auto-grow
autofocus
:autofocus="autoFocus === 'note'"
@keypress="handleNoteKeyPress"
/>
</div>
@@ -165,6 +166,8 @@ export default defineNuxtComponent({
},
);
const autoFocus = !listItem.value.food && listItem.value.note ? "note" : "food";
async function createAssignFood(val: string) {
// keep UI reactive
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
@@ -204,6 +207,7 @@ export default defineNuxtComponent({
return {
listItem,
autoFocus,
createAssignFood,
createAssignUnit,
assignLabelToFood,

View File

@@ -1423,8 +1423,8 @@
"is-greater-than-or-equal-to": "er større end eller lig med (Automatic Translation)",
"is-less-than": "er mindre end (Automatic Translation)",
"is-less-than-or-equal-to": "er mindre end eller lig med (Automatic Translation)",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
"is-older-than": "er ældre end",
"is-newer-than": "er nyere end"
},
"relational-keywords": {
"is": "er",
@@ -1436,7 +1436,7 @@
"is-not-like": "er ikke som"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
"days-ago": "dage siden|dag siden|dage siden"
}
},
"validators": {

View File

@@ -1436,7 +1436,7 @@
"is-not-like": "ist nicht wie"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
"days-ago": "vor Tagen|vor Tag|vor Tagen"
}
},
"validators": {

View File

@@ -1134,7 +1134,21 @@
"example-unit-singular": "ex: Tablespoon",
"example-unit-plural": "ex: Tablespoons",
"example-unit-abbreviation-singular": "ex: Tbsp",
"example-unit-abbreviation-plural": "ex: Tbsps"
"example-unit-abbreviation-plural": "ex: Tbsps",
"standardization": "Standardization",
"standard-unit": "Standard Unit",
"standard-quantity": "Standard Quantity",
"unit-conversion": "Unit Conversion",
"standard-unit-labels": {
"fluid-ounce": "fluid ounce",
"cup": "cup",
"ounce": "ounce",
"pound": "pound",
"milliliter": "milliliter",
"liter": "liter",
"gram": "gram",
"kilogram": "kilogram"
}
},
"labels": {
"seed-dialog-text": "Seed the database with common labels based on your local language.",

View File

@@ -370,7 +370,7 @@
"applies-to-all-days": "S'applique à tous les jours",
"applies-on-days": "S'applique les {0}s",
"meal-plan-settings": "Paramètres des menus",
"add-all-to-list": "Ajouter tout à la liste",
"add-all-to-list": "Tout ajouter a une liste",
"add-day-to-list": "Ajouter un jour à la liste"
},
"migration": {
@@ -1423,8 +1423,8 @@
"is-greater-than-or-equal-to": "est plus grand que ou égal à",
"is-less-than": "est inférieur à",
"is-less-than-or-equal-to": "est inférieur ou égal à",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
"is-older-than": "est plus ancien que",
"is-newer-than": "est plus récent que"
},
"relational-keywords": {
"is": "est",
@@ -1436,7 +1436,7 @@
"is-not-like": "n'est pas similaire à"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
"days-ago": "jours|jour|jours"
}
},
"validators": {

View File

@@ -1423,8 +1423,8 @@
"is-greater-than-or-equal-to": "þann eða eftir þann",
"is-less-than": "fyrir þann",
"is-less-than-or-equal-to": "fyrir þann eða þann",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
"is-older-than": "er eldra en",
"is-newer-than": "er nýrra en"
},
"relational-keywords": {
"is": "er",
@@ -1436,7 +1436,7 @@
"is-not-like": "is not like"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
"days-ago": "daga gamalt|dags gamalt|daga gamalt"
}
},
"validators": {

View File

@@ -1,7 +1,7 @@
{
"about": {
"about": "약/대략",
"about-mealie": "Mealie에 대해",
"about-mealie": "Mealie 정보",
"api-docs": "API 문서",
"api-port": "API 포트",
"application-mode": "애플리케이션 모드",
@@ -299,8 +299,8 @@
"private-household-description": "귀하의 가구를 비공개로 설정하면 모든 공개 보기 옵션이 비활성화됩니다. 이는 개별 공개 보기 설정을 재정의합니다.",
"lock-recipe-edits-from-other-households": "다른 가구의 레시피 편집 잠금",
"lock-recipe-edits-from-other-households-description": "이 기능을 활성화하면 귀하의 가족 구성원만 귀하의 가족이 만든 요리법을 편집할 수 있습니다.",
"household-recipe-preferences": "가정용 레시피 선호도",
"default-recipe-preferences-description": "이는 가에서 새로운 레시피를 만들 때의 기본 설정입니다. 레시피 설정 메뉴에서 개별 레시피에 대해 이를 변경할 수 있습니다.",
"household-recipe-preferences": "가 레시피 설정",
"default-recipe-preferences-description": "이는 가에서 새로운 레시피를 만들 때의 기본 설정입니다. 레시피 설정 메뉴에서 개별 레시피에 대해 이를 변경할 수 있습니다.",
"allow-users-outside-of-your-household-to-see-your-recipes": "가족 외의 사용자에게도 요리법을 볼 수 있도록 허용",
"allow-users-outside-of-your-household-to-see-your-recipes-description": "활성화하면 공개 공유 링크를 사용하여 사용자에게 권한을 부여하지 않고도 특정 레시피를 공유할 수 있습니다. 비활성화하면 가족 구성원 또는 사전 생성된 비공개 링크로만 레시피를 공유할 수 있습니다.",
"household-preferences": "가구 설정"
@@ -1423,8 +1423,8 @@
"is-greater-than-or-equal-to": "다음보다 크거나 같음",
"is-less-than": "다음보다 작음",
"is-less-than-or-equal-to": "다음보다 작거나 같음",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
"is-older-than": "~보다 오래됨",
"is-newer-than": "~보다 최근임"
},
"relational-keywords": {
"is": "같음",
@@ -1436,7 +1436,7 @@
"is-not-like": "다음과 같지 않음"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
"days-ago": "일 전|일 전|일 전"
}
},
"validators": {

View File

@@ -1423,8 +1423,8 @@
"is-greater-than-or-equal-to": "is groter dan of gelijk aan",
"is-less-than": "is kleiner dan",
"is-less-than-or-equal-to": "is kleiner dan of gelijk aan",
"is-older-than": "is older than",
"is-newer-than": "is newer than"
"is-older-than": "is ouder dan",
"is-newer-than": "is nieuwer dan"
},
"relational-keywords": {
"is": "is",
@@ -1436,7 +1436,7 @@
"is-not-like": "is niet zoals"
},
"dates": {
"days-ago": "days ago|day ago|days ago"
"days-ago": "dagen geleden|dag geleden|dagen geleden"
}
},
"validators": {

View File

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

View File

@@ -58,3 +58,13 @@ export interface QueryFilterJSONPart {
relationalOperator?: RelationalKeyword | RelationalOperator | null;
value?: string | string[] | null;
}
export type StandardizedUnitType
= | "fluid_ounce"
| "cup"
| "ounce"
| "pound"
| "milliliter"
| "liter"
| "gram"
| "kilogram";

View File

@@ -85,6 +85,8 @@ export interface CreateIngredientUnit {
pluralAbbreviation?: string | null;
useAbbreviation?: boolean;
aliases?: CreateIngredientUnitAlias[];
standardQuantity?: number | null;
standardUnit?: string | null;
}
export interface CreateIngredientUnitAlias {
name: string;
@@ -174,6 +176,8 @@ export interface IngredientUnit {
pluralAbbreviation?: string | null;
useAbbreviation?: boolean;
aliases?: IngredientUnitAlias[];
standardQuantity?: number | null;
standardUnit?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
@@ -498,6 +502,8 @@ export interface SaveIngredientUnit {
pluralAbbreviation?: string | null;
useAbbreviation?: boolean;
aliases?: CreateIngredientUnitAlias[];
standardQuantity?: number | null;
standardUnit?: string | null;
groupId: string;
}
export interface ScrapeRecipe {

View File

@@ -219,7 +219,7 @@ export default defineNuxtConfig({
},
workbox: {
navigateFallback: "/",
navigateFallbackAllowlist: [/^(?!\/api|\/docs|\/redoc)/],
navigateFallbackAllowlist: [/^(?!\/api|\/docs)/],
globPatterns: ["**/*.{js,css,html,png,svg,ico}"],
globIgnores: ["404.html", "200.html"],
cleanupOutdatedCaches: true,

View File

@@ -88,6 +88,34 @@
hide-details
:label="$t('data-pages.units.use-abbreviation')"
/>
<v-divider />
<v-card-text class="text-h6 mt-2 mb-3 pa-0">
{{ $t('data-pages.units.standardization') }}
</v-card-text>
<v-card-text class="ma-0 pa-0">
{{ $t('data-pages.units.unit-conversion') }}
</v-card-text>
<div class="d-flex flex-nowrap">
<v-number-input
v-model="createTarget.standardQuantity"
variant="underlined"
control-variant="hidden"
density="compact"
inset
:min="0"
:precision="null"
hide-details
class="mt-2"
style="max-width: 125px;"
/>
<v-autocomplete
v-model="createTarget.standardUnit"
:items="standardUnitItems"
clearable
hide-details
class="ml-2"
/>
</div>
</v-form>
</v-card-text>
</BaseDialog>
@@ -149,6 +177,34 @@
hide-details
:label="$t('data-pages.units.use-abbreviation')"
/>
<v-divider />
<v-card-text class="text-h6 mt-2 mb-3 pa-0">
{{ $t('data-pages.units.standardization') }}
</v-card-text>
<v-card-text class="ma-0 pa-0">
{{ $t('data-pages.units.unit-conversion') }}
</v-card-text>
<div class="d-flex flex-nowrap">
<v-number-input
v-model="editTarget.standardQuantity"
variant="underlined"
control-variant="hidden"
density="compact"
inset
:min="0"
:precision="null"
hide-details
class="mt-2"
style="max-width: 125px;"
/>
<v-autocomplete
v-model="editTarget.standardUnit"
:items="standardUnitItems"
clearable
hide-details
class="ml-2"
/>
</div>
</v-form>
</v-card-text>
<template #custom-card-action>
@@ -314,11 +370,17 @@ import RecipeDataAliasManagerDialog from "~/components/Domain/Recipe/RecipeDataA
import { validators } from "~/composables/use-validators";
import { useUserApi } from "~/composables/api";
import type { CreateIngredientUnit, IngredientUnit, IngredientUnitAlias } from "~/lib/api/types/recipe";
import type { StandardizedUnitType } from "~/lib/api/types/non-generated";
import { useLocales } from "~/composables/use-locales";
import { normalizeFilter } from "~/composables/use-utils";
import { useUnitStore } from "~/composables/store";
import type { VForm } from "~/types/auto-forms";
interface StandardUnitItem {
title: string;
value: StandardizedUnitType;
};
export default defineNuxtComponent({
components: { RecipeDataAliasManagerDialog },
setup() {
@@ -376,6 +438,16 @@ export default defineNuxtComponent({
show: true,
sortable: true,
},
{
text: i18n.t("data-pages.units.standard-quantity"),
value: "standardQuantity",
show: false,
},
{
text: i18n.t("data-pages.units.standard-unit"),
value: "standardUnit",
show: false,
},
{
text: i18n.t("general.date-added"),
value: "createdAt",
@@ -384,6 +456,41 @@ export default defineNuxtComponent({
},
];
const standardUnitItems = computed<StandardUnitItem[]>(() => [
{
title: i18n.t("data-pages.units.standard-unit-labels.fluid-ounce"),
value: "fluid_ounce",
},
{
title: i18n.t("data-pages.units.standard-unit-labels.cup"),
value: "cup",
},
{
title: i18n.t("data-pages.units.standard-unit-labels.ounce"),
value: "ounce",
},
{
title: i18n.t("data-pages.units.standard-unit-labels.pound"),
value: "pound",
},
{
title: i18n.t("data-pages.units.standard-unit-labels.milliliter"),
value: "milliliter",
},
{
title: i18n.t("data-pages.units.standard-unit-labels.liter"),
value: "liter",
},
{
title: i18n.t("data-pages.units.standard-unit-labels.gram"),
value: "gram",
},
{
title: i18n.t("data-pages.units.standard-unit-labels.kilogram"),
value: "kilogram",
},
]);
const { store, actions: unitActions } = useUnitStore();
// ============================================================
@@ -536,6 +643,7 @@ export default defineNuxtComponent({
return {
tableConfig,
tableHeaders,
standardUnitItems,
store,
validators,
normalizeFilter,

View File

@@ -0,0 +1,106 @@
"""add unit standardization fields
Revision ID: a39c7f1826e3
Revises: 1d9a002d7234
Create Date: 2026-02-21 17:59:01.161812
"""
import sqlalchemy as sa
from sqlalchemy import orm
from alembic import op
from mealie.repos.repository_units import RepositoryUnit
from mealie.core.root_logger import get_logger
from mealie.db.models._model_utils.guid import GUID
from mealie.repos.seed.seeders import IngredientUnitsSeeder
from mealie.lang.locale_config import LOCALE_CONFIG
# revision identifiers, used by Alembic.
revision = "a39c7f1826e3"
down_revision: str | None = "1d9a002d7234"
branch_labels: str | tuple[str, ...] | None = None
depends_on: str | tuple[str, ...] | None = None
logger = get_logger()
class SqlAlchemyBase(orm.DeclarativeBase): ...
class IngredientUnitModel(SqlAlchemyBase):
__tablename__ = "ingredient_units"
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
name: orm.Mapped[str | None] = orm.mapped_column(sa.String)
plural_name: orm.Mapped[str | None] = orm.mapped_column(sa.String)
abbreviation: orm.Mapped[str | None] = orm.mapped_column(sa.String)
plural_abbreviation: orm.Mapped[str | None] = orm.mapped_column(sa.String)
standard_quantity: orm.Mapped[float | None] = orm.mapped_column(sa.Float)
standard_unit: orm.Mapped[str | None] = orm.mapped_column(sa.String)
def populate_standards() -> None:
bind = op.get_bind()
session = orm.Session(bind)
# We aren't using most of the functionality of this class, so we pass dummy args
repo = RepositoryUnit(None, None, None, None, group_id=None) # type: ignore
stmt = sa.select(IngredientUnitModel)
units = session.execute(stmt).scalars().all()
if not units:
return
# Manually build repo._standardized_unit_map with all locales
repo._standardized_unit_map = {}
for locale in LOCALE_CONFIG:
locale_file = IngredientUnitsSeeder.get_file(locale)
for unit_key, unit in IngredientUnitsSeeder.load_file(locale_file).items():
for prop in ["name", "plural_name", "abbreviation"]:
val = unit.get(prop)
if val and isinstance(val, str):
repo._standardized_unit_map[val.strip().lower()] = unit_key
for unit in units:
unit_data = {
"name": unit.name,
"plural_name": unit.plural_name,
"abbreviation": unit.abbreviation,
"plural_abbreviation": unit.plural_abbreviation,
}
standardized_data = repo._add_standardized_unit(unit_data)
std_q = standardized_data.get("standard_quantity")
std_u = standardized_data.get("standard_unit")
if std_q and std_u:
logger.info(f"Found unit '{unit.name}', which is standardized as '{std_q} * {std_u}'")
unit.standard_quantity = std_q
unit.standard_unit = std_u
session.commit()
session.close()
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("ingredient_units", schema=None) as batch_op:
batch_op.add_column(sa.Column("standard_quantity", sa.Float(), nullable=True))
batch_op.add_column(sa.Column("standard_unit", sa.String(), nullable=True))
# ### end Alembic commands ###
# Populate standardized units for existing records
try:
populate_standards()
except Exception:
logger.exception("Failed to populate unit standards, skipping...")
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("ingredient_units", schema=None) as batch_op:
batch_op.drop_column("standard_unit")
batch_op.drop_column("standard_quantity")
# ### end Alembic commands ###

View File

@@ -102,7 +102,6 @@ app = FastAPI(
description=description,
version=APP_VERSION,
docs_url=settings.DOCS_URL,
redoc_url=settings.REDOC_URL,
lifespan=lifespan_fn,
)

View File

@@ -199,10 +199,6 @@ class AppSettings(AppLoggingSettings):
def DOCS_URL(self) -> str | None:
return "/docs" if self.API_DOCS else None
@property
def REDOC_URL(self) -> str | None:
return "/redoc" if self.API_DOCS else None
# ===============================================
# Database Configuration

View File

@@ -52,6 +52,10 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
cascade="all, delete, delete-orphan",
)
# Standardization
standard_quantity: Mapped[float | None] = mapped_column(Float)
standard_unit: Mapped[str | None] = mapped_column(String)
# Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
plural_name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)

View File

@@ -15,52 +15,63 @@ class LocalePluralFoodHandling(StrEnum):
@dataclass
class LocaleConfig:
key: str
name: str
dir: LocaleTextDirection = LocaleTextDirection.LTR
plural_food_handling: LocalePluralFoodHandling = LocalePluralFoodHandling.ALWAYS
LOCALE_CONFIG: dict[str, LocaleConfig] = {
"af-ZA": LocaleConfig(name="Afrikaans (Afrikaans)"),
"ar-SA": LocaleConfig(name="العربية (Arabic)", dir=LocaleTextDirection.RTL),
"bg-BG": LocaleConfig(name="Български (Bulgarian)"),
"ca-ES": LocaleConfig(name="Català (Catalan)"),
"cs-CZ": LocaleConfig(name="Čeština (Czech)"),
"da-DK": LocaleConfig(name="Dansk (Danish)"),
"de-DE": LocaleConfig(name="Deutsch (German)"),
"el-GR": LocaleConfig(name="Ελληνικά (Greek)"),
"en-GB": LocaleConfig(name="British English", plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT),
"en-US": LocaleConfig(name="American English", plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT),
"es-ES": LocaleConfig(name="Español (Spanish)"),
"et-EE": LocaleConfig(name="Eesti (Estonian)"),
"fi-FI": LocaleConfig(name="Suomi (Finnish)"),
"fr-BE": LocaleConfig(name="Belge (Belgian)"),
"fr-CA": LocaleConfig(name="Français canadien (Canadian French)"),
"fr-FR": LocaleConfig(name="Français (French)"),
"gl-ES": LocaleConfig(name="Galego (Galician)"),
"he-IL": LocaleConfig(name="עברית (Hebrew)", dir=LocaleTextDirection.RTL),
"hr-HR": LocaleConfig(name="Hrvatski (Croatian)"),
"hu-HU": LocaleConfig(name="Magyar (Hungarian)"),
"is-IS": LocaleConfig(name="Íslenska (Icelandic)"),
"it-IT": LocaleConfig(name="Italiano (Italian)"),
"ja-JP": LocaleConfig(name="日本語 (Japanese)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"ko-KR": LocaleConfig(name="한국어 (Korean)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"lt-LT": LocaleConfig(name="Lietuvių (Lithuanian)"),
"lv-LV": LocaleConfig(name="Latviešu (Latvian)"),
"nl-NL": LocaleConfig(name="Nederlands (Dutch)"),
"no-NO": LocaleConfig(name="Norsk (Norwegian)"),
"pl-PL": LocaleConfig(name="Polski (Polish)"),
"pt-BR": LocaleConfig(name="Português do Brasil (Brazilian Portuguese)"),
"pt-PT": LocaleConfig(name="Português (Portuguese)"),
"ro-RO": LocaleConfig(name="Română (Romanian)"),
"ru-RU": LocaleConfig(name="Pусский (Russian)"),
"sk-SK": LocaleConfig(name="Slovenčina (Slovak)"),
"sl-SI": LocaleConfig(name="Slovenščina (Slovenian)"),
"sr-SP": LocaleConfig(name="српски (Serbian)"),
"sv-SE": LocaleConfig(name="Svenska (Swedish)"),
"tr-TR": LocaleConfig(name="Türkçe (Turkish)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"uk-UA": LocaleConfig(name="Українська (Ukrainian)"),
"vi-VN": LocaleConfig(name="Tiếng Việt (Vietnamese)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"zh-CN": LocaleConfig(name="简体中文 (Chinese simplified)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"zh-TW": LocaleConfig(name="繁體中文 (Chinese traditional)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"af-ZA": LocaleConfig(key="af-ZA", name="Afrikaans (Afrikaans)"),
"ar-SA": LocaleConfig(key="ar-SA", name="العربية (Arabic)", dir=LocaleTextDirection.RTL),
"bg-BG": LocaleConfig(key="bg-BG", name="Български (Bulgarian)"),
"ca-ES": LocaleConfig(key="ca-ES", name="Català (Catalan)"),
"cs-CZ": LocaleConfig(key="cs-CZ", name="Čeština (Czech)"),
"da-DK": LocaleConfig(key="da-DK", name="Dansk (Danish)"),
"de-DE": LocaleConfig(key="de-DE", name="Deutsch (German)"),
"el-GR": LocaleConfig(key="el-GR", name="Ελληνικά (Greek)"),
"en-GB": LocaleConfig(
key="en-GB", name="British English", plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT
),
"en-US": LocaleConfig(
key="en-US", name="American English", plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT
),
"es-ES": LocaleConfig(key="es-ES", name="Español (Spanish)"),
"et-EE": LocaleConfig(key="et-EE", name="Eesti (Estonian)"),
"fi-FI": LocaleConfig(key="fi-FI", name="Suomi (Finnish)"),
"fr-BE": LocaleConfig(key="fr-BE", name="Belge (Belgian)"),
"fr-CA": LocaleConfig(key="fr-CA", name="Français canadien (Canadian French)"),
"fr-FR": LocaleConfig(key="fr-FR", name="Français (French)"),
"gl-ES": LocaleConfig(key="gl-ES", name="Galego (Galician)"),
"he-IL": LocaleConfig(key="he-IL", name="עברית (Hebrew)", dir=LocaleTextDirection.RTL),
"hr-HR": LocaleConfig(key="hr-HR", name="Hrvatski (Croatian)"),
"hu-HU": LocaleConfig(key="hu-HU", name="Magyar (Hungarian)"),
"is-IS": LocaleConfig(key="is-IS", name="Íslenska (Icelandic)"),
"it-IT": LocaleConfig(key="it-IT", name="Italiano (Italian)"),
"ja-JP": LocaleConfig(key="ja-JP", name="日本語 (Japanese)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"ko-KR": LocaleConfig(key="ko-KR", name="한국어 (Korean)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"lt-LT": LocaleConfig(key="lt-LT", name="Lietuvių (Lithuanian)"),
"lv-LV": LocaleConfig(key="lv-LV", name="Latviešu (Latvian)"),
"nl-NL": LocaleConfig(key="nl-NL", name="Nederlands (Dutch)"),
"no-NO": LocaleConfig(key="no-NO", name="Norsk (Norwegian)"),
"pl-PL": LocaleConfig(key="pl-PL", name="Polski (Polish)"),
"pt-BR": LocaleConfig(key="pt-BR", name="Português do Brasil (Brazilian Portuguese)"),
"pt-PT": LocaleConfig(key="pt-PT", name="Português (Portuguese)"),
"ro-RO": LocaleConfig(key="ro-RO", name="Română (Romanian)"),
"ru-RU": LocaleConfig(key="ru-RU", name="Pусский (Russian)"),
"sk-SK": LocaleConfig(key="sk-SK", name="Slovenčina (Slovak)"),
"sl-SI": LocaleConfig(key="sl-SI", name="Slovenščina (Slovenian)"),
"sr-SP": LocaleConfig(key="sr-SP", name="српски (Serbian)"),
"sv-SE": LocaleConfig(key="sv-SE", name="Svenska (Swedish)"),
"tr-TR": LocaleConfig(key="tr-TR", name="Türkçe (Turkish)", plural_food_handling=LocalePluralFoodHandling.NEVER),
"uk-UA": LocaleConfig(key="uk-UA", name="Українська (Ukrainian)"),
"vi-VN": LocaleConfig(
key="vi-VN", name="Tiếng Việt (Vietnamese)", plural_food_handling=LocalePluralFoodHandling.NEVER
),
"zh-CN": LocaleConfig(
key="zh-CN", name="简体中文 (Chinese simplified)", plural_food_handling=LocalePluralFoodHandling.NEVER
),
"zh-TW": LocaleConfig(
key="zh-TW", name="繁體中文 (Chinese traditional)", plural_food_handling=LocalePluralFoodHandling.NEVER
),
}

View File

@@ -1,17 +1,119 @@
from pydantic import UUID4
from collections.abc import Iterable
from pydantic import UUID4, BaseModel
from sqlalchemy import select
from mealie.db.models.recipe.ingredient import IngredientUnitModel
from mealie.schema.recipe.recipe_ingredient import IngredientUnit
from mealie.lang.providers import get_locale_context
from mealie.schema.recipe.recipe_ingredient import IngredientUnit, StandardizedUnitType
from .repository_generic import GroupRepositoryGeneric
class RepositoryUnit(GroupRepositoryGeneric[IngredientUnit, IngredientUnitModel]):
_standardized_unit_map: dict[str, str] | None = None
@property
def standardized_unit_map(self) -> dict[str, str]:
"""A map of potential known units to its standardized name in our seed data"""
if self._standardized_unit_map is None:
from .seed.seeders import IngredientUnitsSeeder
ctx = get_locale_context()
if ctx:
locale = ctx[1].key
else:
locale = None
self._standardized_unit_map = {}
locale_file = IngredientUnitsSeeder.get_file(locale=locale)
for unit_key, unit in IngredientUnitsSeeder.load_file(locale_file).items():
for prop in ["name", "plural_name", "abbreviation"]:
val = unit.get(prop)
if val and isinstance(val, str):
self._standardized_unit_map[val.strip().lower()] = unit_key
return self._standardized_unit_map
def _get_unit(self, id: UUID4) -> IngredientUnitModel:
stmt = select(self.model).filter_by(**self._filter_builder(**{"id": id}))
return self.session.execute(stmt).scalars().one()
def _add_standardized_unit(self, data: BaseModel | dict) -> dict:
if not isinstance(data, dict):
data = data.model_dump()
# Don't overwrite user data if it exists
if data.get("standard_quantity") is not None or data.get("standard_unit") is not None:
return data
# Compare name attrs to translation files and see if there's a match to a known standard unit
for prop in ["name", "plural_name", "abbreviation", "plural_abbreviation"]:
val = data.get(prop)
if not (val and isinstance(val, str)):
continue
standardized_unit_key = self.standardized_unit_map.get(val.strip().lower())
if not standardized_unit_key:
continue
match standardized_unit_key:
case "teaspoon":
data["standard_quantity"] = 1 / 6
data["standard_unit"] = StandardizedUnitType.FLUID_OUNCE
case "tablespoon":
data["standard_quantity"] = 1 / 2
data["standard_unit"] = StandardizedUnitType.FLUID_OUNCE
case "cup":
data["standard_quantity"] = 1
data["standard_unit"] = StandardizedUnitType.CUP
case "fluid-ounce":
data["standard_quantity"] = 1
data["standard_unit"] = StandardizedUnitType.FLUID_OUNCE
case "pint":
data["standard_quantity"] = 2
data["standard_unit"] = StandardizedUnitType.CUP
case "quart":
data["standard_quantity"] = 4
data["standard_unit"] = StandardizedUnitType.CUP
case "gallon":
data["standard_quantity"] = 16
data["standard_unit"] = StandardizedUnitType.CUP
case "milliliter":
data["standard_quantity"] = 1
data["standard_unit"] = StandardizedUnitType.MILLILITER
case "liter":
data["standard_quantity"] = 1
data["standard_unit"] = StandardizedUnitType.LITER
case "pound":
data["standard_quantity"] = 1
data["standard_unit"] = StandardizedUnitType.POUND
case "ounce":
data["standard_quantity"] = 1
data["standard_unit"] = StandardizedUnitType.OUNCE
case "gram":
data["standard_quantity"] = 1
data["standard_unit"] = StandardizedUnitType.GRAM
case "kilogram":
data["standard_quantity"] = 1
data["standard_unit"] = StandardizedUnitType.KILOGRAM
case "milligram":
data["standard_quantity"] = 1 / 1000
data["standard_unit"] = StandardizedUnitType.GRAM
case _:
continue
return data
def create(self, data: IngredientUnit | dict) -> IngredientUnit:
data = self._add_standardized_unit(data)
return super().create(data)
def create_many(self, data: Iterable[IngredientUnit | dict]) -> list[IngredientUnit]:
data = [self._add_standardized_unit(i) for i in data]
return super().create_many(data)
def merge(self, from_unit: UUID4, to_unit: UUID4) -> IngredientUnit | None:
from_model = self._get_unit(from_unit)
to_model = self._get_unit(to_unit)

View File

@@ -1,3 +1,4 @@
import json
from abc import ABC, abstractmethod
from logging import Logger
from pathlib import Path
@@ -11,6 +12,8 @@ class AbstractSeeder(ABC):
Abstract class for seeding data.
"""
resources = Path(__file__).parent / "resources"
def __init__(self, db: AllRepositories, logger: Logger | None = None):
"""
Initialize the abstract seeder.
@@ -19,7 +22,14 @@ class AbstractSeeder(ABC):
"""
self.repos = db
self.logger = logger or get_logger("Data Seeder")
self.resources = Path(__file__).parent / "resources"
@classmethod
@abstractmethod
def get_file(self, locale: str | None = None) -> Path: ...
@classmethod
def load_file(self, file: Path) -> dict[str, dict]:
return json.loads(file.read_text(encoding="utf-8"))
@abstractmethod
def seed(self, locale: str | None = None) -> None: ...

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -980,7 +980,7 @@
"aliases": [],
"description": "",
"name": "Kastanienpüree",
"plural_name": "chestnut purée"
"plural_name": "Kastanienpüree"
},
"prickly pear": {
"aliases": [],
@@ -1232,7 +1232,7 @@
"aliases": [],
"description": "",
"name": "Horngurke",
"plural_name": "kiwanos"
"plural_name": "Horn gurken"
}
}
},
@@ -1295,7 +1295,7 @@
"black fungu": {
"aliases": [],
"description": "",
"name": "black fungus",
"name": "Mu-Err-Pilze",
"plural_name": "Mu-Err-Pilze"
},
"black truffle": {
@@ -1361,7 +1361,7 @@
"white fungu": {
"aliases": [],
"description": "",
"name": "white fungus",
"name": "Tremella",
"plural_name": "Weiße Pilze"
},
"pioppini": {
@@ -1373,7 +1373,7 @@
"snow fungu": {
"aliases": [],
"description": "",
"name": "snow fungus",
"name": "Tremella",
"plural_name": "Schneepilze"
},
"white beech mushroom": {
@@ -1385,7 +1385,7 @@
"boletu": {
"aliases": [],
"description": "",
"name": "boletus",
"name": "Steinpilz",
"plural_name": "Steinpilze"
},
"huitlacoche": {
@@ -1404,7 +1404,7 @@
"aliases": [],
"description": "",
"name": "Nameko",
"plural_name": "namekos"
"plural_name": "Nameko Pilz"
},
"djon djon mushroom": {
"aliases": [],
@@ -1427,7 +1427,7 @@
"honey fungu": {
"aliases": [],
"description": "",
"name": "honey fungus",
"name": "Hallimasche",
"plural_name": "Hallimasche"
},
"caesar's mushroom": {
@@ -2017,16 +2017,16 @@
"cream cheese": {
"aliases": [],
"description": "",
"name": "cream cheese",
"plural_name": "cream cheese"
"name": "Frischkäse",
"plural_name": "Frischkäse"
},
"sharp cheddar cheese": {
"aliases": [
"sharp cheddar"
"Kräftiger Cheddar"
],
"description": "",
"name": "sharp cheddar cheese",
"plural_name": "sharp cheddar cheese"
"name": "Kräftiger Cheddar",
"plural_name": "Kräftiger Cheddar"
},
"cheese": {
"aliases": [],
@@ -2055,14 +2055,14 @@
"cheddar-jack cheese": {
"aliases": [],
"description": "",
"name": "cheddar-jack cheese",
"plural_name": "cheddar-jack cheese"
"name": "Cheddar-se",
"plural_name": "Cheddar-se"
},
"monterey jack cheese": {
"aliases": [],
"description": "",
"name": "monterey jack cheese",
"plural_name": "monterey jack cheese"
"name": "Monterey-Jack-Käse",
"plural_name": "Monterey-Jack-Käse"
},
"blue cheese": {
"aliases": [],
@@ -2074,25 +2074,25 @@
"aliases": [],
"description": "",
"name": "Ziegenkäse",
"plural_name": "goat cheese"
"plural_name": "Ziegenkäse"
},
"fresh mozzarella cheese": {
"aliases": [],
"description": "",
"name": "fresh mozzarella cheese",
"plural_name": "fresh mozzarella cheese"
"name": "Frischer Mozzarella",
"plural_name": "Frischer Mozzarella"
},
"swis cheese": {
"aliases": [],
"description": "",
"name": "swis cheese",
"plural_name": "swis cheese"
"name": "Schweizer Käse",
"plural_name": "Schweizer Käse"
},
"pecorino cheese": {
"aliases": [],
"description": "",
"name": "pecorino cheese",
"plural_name": "pecorino cheese"
"name": "Pecorino",
"plural_name": "Pecorino"
},
"gruyere cheese": {
"aliases": [],
@@ -2103,8 +2103,8 @@
"mascarpone cheese": {
"aliases": [],
"description": "",
"name": "mascarpone cheese",
"plural_name": "mascarpone cheese"
"name": "Mascarpone",
"plural_name": "Mascarpone"
},
"cottage cheese": {
"aliases": [],
@@ -2115,26 +2115,26 @@
"american cheese": {
"aliases": [],
"description": "",
"name": "american cheese",
"plural_name": "american cheese"
"name": "Schmelzkäse",
"plural_name": "Schmelzkäse"
},
"provolone cheese": {
"aliases": [],
"description": "",
"name": "Provolone-Käse",
"plural_name": "provolone cheese"
"plural_name": "Provolone"
},
"mexican cheese blend": {
"aliases": [],
"description": "",
"name": "mexican cheese blend",
"plural_name": "mexican cheese blend"
"name": "mexikanische Käsemischung",
"plural_name": "mexikanische Käsemischung"
},
"pepper jack cheese": {
"aliases": [],
"description": "",
"name": "pepper jack cheese",
"plural_name": "pepper jack cheese"
"name": "Pfefferkäse",
"plural_name": "Pfefferkäse"
},
"brie cheese": {
"aliases": [],
@@ -2145,26 +2145,26 @@
"paneer cheese": {
"aliases": [],
"description": "",
"name": "paneer cheese",
"plural_name": "paneer cheese"
"name": "Paneer",
"plural_name": "Paneer"
},
"fontina cheese": {
"aliases": [],
"description": "",
"name": "fontina cheese",
"plural_name": "fontina cheese"
"name": "Fontina",
"plural_name": "Fontina"
},
"queso fresco cheese": {
"aliases": [],
"description": "",
"name": "queso fresco cheese",
"plural_name": "queso fresco cheese"
"name": "Cottage Käse",
"plural_name": "Cottage Käse"
},
"quark cheese": {
"aliases": [],
"description": "",
"name": "quark cheese",
"plural_name": "quark cheese"
"name": "Quark",
"plural_name": "Quark"
},
"gouda cheese": {
"aliases": [],
@@ -2175,128 +2175,128 @@
"cotija cheese": {
"aliases": [],
"description": "",
"name": "cotija cheese",
"plural_name": "cotija cheese"
"name": "Cotija",
"plural_name": "Cotija"
},
"asiago cheese": {
"aliases": [],
"description": "",
"name": "asiago cheese",
"plural_name": "asiago cheese"
"name": "Asiago",
"plural_name": "Asiago"
},
"smoked cheese": {
"aliases": [],
"description": "",
"name": "smoked cheese",
"plural_name": "smoked cheese"
"name": "geräucherter Käse",
"plural_name": "geräucherter Käse"
},
"halloumi cheese": {
"aliases": [],
"description": "",
"name": "halloumi cheese",
"plural_name": "halloumi cheese"
"name": "Halloumi",
"plural_name": "Halloumi"
},
"chevre cheese": {
"aliases": [],
"description": "",
"name": "chevre cheese",
"plural_name": "chevre cheese"
"name": "Chèvre",
"plural_name": "Chèvre"
},
"manchego cheese": {
"aliases": [],
"description": "",
"name": "manchego cheese",
"plural_name": "manchego cheese"
"name": "Manchego se",
"plural_name": "Manchego se"
},
"italian cheese blend": {
"aliases": [],
"description": "",
"name": "italian cheese blend",
"plural_name": "italian cheese blend"
"name": "italienische Käsemischung",
"plural_name": "italienische Käsemischung"
},
"neufchatel cheese": {
"aliases": [],
"description": "",
"name": "neufchatel cheese",
"plural_name": "neufchatel cheese"
"name": "Neufchâtel se",
"plural_name": "Neufchâtel se"
},
"herb cream cheese": {
"aliases": [],
"description": "",
"name": "herb cream cheese",
"plural_name": "herb cream cheese"
"name": "Kräuterfrischkäse",
"plural_name": "Kräuterfrischkäse"
},
"burrata cheese": {
"aliases": [],
"description": "",
"name": "burrata cheese",
"plural_name": "burrata cheese"
"name": "Burrata",
"plural_name": "Burrata"
},
"havarti cheese": {
"aliases": [],
"description": "",
"name": "havarti cheese",
"plural_name": "havarti cheese"
"name": "Havarti se",
"plural_name": "Havarti se"
},
"colby cheese": {
"aliases": [],
"description": "",
"name": "colby cheese",
"plural_name": "colby cheese"
"name": "Colby se",
"plural_name": "Colby se"
},
"grana-padano cheese": {
"aliases": [],
"description": "",
"name": "grana-padano cheese",
"plural_name": "grana-padano cheese"
"name": "Grana Padano",
"plural_name": "Grana Padano"
},
"muenster cheese": {
"aliases": [],
"description": "",
"name": "muenster cheese",
"plural_name": "muenster cheese"
"name": "Munster",
"plural_name": "Munster"
},
"string cheese": {
"aliases": [],
"description": "",
"name": "string cheese",
"plural_name": "string cheese"
"name": "Fadenkäse",
"plural_name": "Fadenkäse"
},
"camembert cheese": {
"aliases": [],
"description": "",
"name": "camembert cheese",
"plural_name": "camembert cheese"
"name": "Camembert",
"plural_name": "Camembert"
},
"soft cheese": {
"aliases": [],
"description": "",
"name": "soft cheese",
"plural_name": "soft cheese"
"name": "Weichkäse",
"plural_name": "Weichkäse"
},
"stilton cheese": {
"aliases": [],
"description": "",
"name": "stilton cheese",
"plural_name": "stilton cheese"
"name": "Stilton",
"plural_name": "Stilton"
},
"raclette cheese": {
"aliases": [],
"description": "",
"name": "raclette cheese",
"plural_name": "raclette cheese"
"name": "Raclettese",
"plural_name": "Raclettese"
},
"colby-jack cheese": {
"aliases": [],
"description": "",
"name": "colby-jack cheese",
"plural_name": "colby-jack cheese"
"name": "Colby-Jack se",
"plural_name": "Colby-Jack se"
},
"jarlsberg cheese": {
"aliases": [],
"description": "",
"name": "jarlsberg cheese",
"plural_name": "jarlsberg cheese"
"name": "Jarlsberg se",
"plural_name": "Jarlsberg se"
},
"taleggio cheese": {
"aliases": [],
@@ -2307,14 +2307,14 @@
"oaxaca cheese": {
"aliases": [],
"description": "",
"name": "oaxaca cheese",
"plural_name": "oaxaca cheese"
"name": "Oaxaca se",
"plural_name": "Oaxaca se"
},
"labneh cheese": {
"aliases": [],
"description": "",
"name": "labneh cheese",
"plural_name": "labneh cheese"
"name": "Labneh",
"plural_name": "Labneh"
},
"edam cheese": {
"aliases": [],
@@ -2325,104 +2325,104 @@
"creamy cheese wedge": {
"aliases": [],
"description": "",
"name": "creamy cheese wedge",
"plural_name": "creamy cheese wedges"
"name": "Käseecke",
"plural_name": "Käseecken"
},
"cheese powder cheese": {
"aliases": [],
"description": "",
"name": "cheese powder cheese",
"plural_name": "cheese powder cheese"
"name": "Käsepulver",
"plural_name": "Käsepulver"
},
"fromage blanc cheese": {
"aliases": [],
"description": "",
"name": "fromage blanc cheese",
"plural_name": "fromage blanc cheese"
"name": "Fromage Blanc",
"plural_name": "Fromage Blanc"
},
"asadero cheese": {
"aliases": [],
"description": "",
"name": "asadero cheese",
"plural_name": "asadero cheese"
"name": "Asadero se",
"plural_name": "Asadero se"
},
"marble cheese": {
"aliases": [],
"description": "",
"name": "marble cheese",
"plural_name": "marble cheese"
"name": "Marmorkäse",
"plural_name": "Marmorkäse"
},
"leicester cheese": {
"aliases": [],
"description": "",
"name": "leicester cheese",
"plural_name": "leicester cheese"
"name": "Leicester se",
"plural_name": "Leicester se"
},
"kefalotyri cheese": {
"aliases": [],
"description": "",
"name": "kefalotyri cheese",
"plural_name": "kefalotyri cheese"
"name": "Kefalotyri se",
"plural_name": "Kefalotyri se"
},
"mizithra cheese": {
"aliases": [],
"description": "",
"name": "mizithra cheese",
"plural_name": "mizithra cheese"
"name": "Mizithra se",
"plural_name": "Mizithra se"
},
"lancashire cheese": {
"aliases": [],
"description": "",
"name": "lancashire cheese",
"plural_name": "lancashire cheese"
"name": "Lancashire se",
"plural_name": "Lancashire se"
},
"kasseri cheese": {
"aliases": [],
"description": "",
"name": "kasseri cheese",
"plural_name": "kasseri cheese"
"name": "Kasseri se",
"plural_name": "Kasseri se"
},
"babybel cheese": {
"aliases": [],
"description": "",
"name": "babybel cheese",
"plural_name": "babybel cheese"
"name": "Babybel",
"plural_name": "Babybel"
},
"panela cheese": {
"aliases": [],
"description": "",
"name": "panela cheese",
"plural_name": "panela cheese"
"name": "Panela se",
"plural_name": "Panela se"
},
"longhorn cheese": {
"aliases": [],
"description": "",
"name": "longhorn cheese",
"plural_name": "longhorn cheese"
"name": "Langhorn se",
"plural_name": "Langhorn se"
},
"seasoned feta cheese": {
"aliases": [],
"description": "",
"name": "seasoned feta cheese",
"plural_name": "seasoned feta cheese"
"name": "Gewürzter Feta",
"plural_name": "Gewürzter Feta"
},
"comté cheese": {
"aliases": [],
"description": "",
"name": "comté cheese",
"plural_name": "comté cheese"
"name": "Comté",
"plural_name": "Comté"
},
"graviera cheese": {
"aliases": [],
"description": "",
"name": "graviera cheese",
"plural_name": "graviera cheese"
"name": "Graviera",
"plural_name": "Graviera"
},
"wensleydale cheese": {
"aliases": [],
"description": "",
"name": "wensleydale cheese",
"plural_name": "wensleydale cheese"
"name": "Wensleydale se",
"plural_name": "Wensleydale se"
},
"scamorza cheese": {
"aliases": [],
@@ -2433,38 +2433,38 @@
"cambozola cheese": {
"aliases": [],
"description": "",
"name": "cambozola cheese",
"plural_name": "cambozola cheese"
"name": "Cambozola",
"plural_name": "Cambozola"
},
"cheshire cheese": {
"aliases": [],
"description": "",
"name": "cheshire cheese",
"plural_name": "cheshire cheese"
"name": "Cheshire-Käse",
"plural_name": "Cheshire-Käse"
},
"anthotyro cheese": {
"aliases": [],
"description": "",
"name": "anthotyro cheese",
"plural_name": "anthotyro cheese"
"name": "Anthotyro se",
"plural_name": "Anthotyro se"
},
"chenna cheese": {
"aliases": [],
"description": "",
"name": "chenna cheese",
"plural_name": "chenna cheese"
"name": "Chhena se",
"plural_name": "Chhena se"
},
"hard goat cheese": {
"aliases": [],
"description": "",
"name": "hard goat cheese",
"plural_name": "hard goat cheese"
"name": "Ziegen-Hartkäse",
"plural_name": "Ziegen-Hartkäse"
},
"kashkaval cheese": {
"aliases": [],
"description": "",
"name": "kashkaval cheese",
"plural_name": "kashkaval cheese"
"name": "Kashkaval se",
"plural_name": "Kashkaval se"
},
"sheep cheese": {
"aliases": [],
@@ -2475,25 +2475,25 @@
"amul cheese": {
"aliases": [],
"description": "",
"name": "amul cheese",
"plural_name": "amul cheese"
"name": "Amul se",
"plural_name": "Amul se"
},
"reblochon cheese": {
"aliases": [],
"description": "",
"name": "reblochon cheese",
"plural_name": "reblochon cheese"
"name": "Reblochons",
"plural_name": "Reblochons"
},
"robiola cheese": {
"aliases": [],
"description": "",
"name": "robiola cheese",
"plural_name": "robiola cheese"
"name": "Robiola se",
"plural_name": "Robiola se"
},
"brick cheese": {
"aliases": [],
"description": "",
"name": "brick cheese",
"name": "Backstein Käse",
"plural_name": "brick cheese"
},
"quick-melt cheese": {
@@ -5772,7 +5772,7 @@
"aliases": [],
"description": "",
"name": "Ulva",
"plural_name": "sea lettuce"
"plural_name": "Ulva"
},
"korean seaweed": {
"aliases": [],
@@ -5830,7 +5830,7 @@
"aliases": [],
"description": "",
"name": "Koriander",
"plural_name": "cilantro"
"plural_name": "Koriander"
},
"cumin": {
"aliases": [],
@@ -6004,7 +6004,7 @@
"aliases": [],
"description": "",
"name": "Gemahlener Pfeffer",
"plural_name": "cracked pepper"
"plural_name": "Gemahlener Pfeffer"
},
"peppercorn": {
"aliases": [],
@@ -6274,13 +6274,13 @@
"aliases": [],
"description": "",
"name": "Getrockneter Koriander",
"plural_name": "dried cilantro"
"plural_name": "Getrockneter Koriander"
},
"lemon balm": {
"aliases": [],
"description": "",
"name": "Zitronenmelisse",
"plural_name": "lemon balm"
"plural_name": "Zitronenmelisse"
},
"dill seed": {
"aliases": [],
@@ -6304,7 +6304,7 @@
"aliases": [],
"description": "",
"name": "Wasabi Pulver",
"plural_name": "wasabi powder"
"plural_name": "Wasabi Pulver"
},
"achiote seed": {
"aliases": [],
@@ -6340,13 +6340,13 @@
"aliases": [],
"description": "",
"name": "vietnamesischer Zimt",
"plural_name": "saigon cinnamon"
"plural_name": "vietnamesischer Zimt"
},
"lemongrass paste": {
"aliases": [],
"description": "",
"name": "Zitronengras-Paste",
"plural_name": "lemongrass paste"
"plural_name": "Zitronengras-Paste"
},
"shiso": {
"aliases": [],
@@ -6358,7 +6358,7 @@
"aliases": [],
"description": "",
"name": "Sellerieknollenpulver",
"plural_name": "celery powder"
"plural_name": "Sellerieknollenpulver"
},
"black cumin": {
"aliases": [],
@@ -6476,26 +6476,26 @@
"molasses": {
"aliases": [],
"description": "",
"name": "molass",
"plural_name": "molasses"
"name": "Melasse",
"plural_name": "Melasse"
},
"stevia": {
"aliases": [],
"description": "",
"name": "Stevia",
"plural_name": "stevia"
"plural_name": "Stevia"
},
"agave nectar": {
"aliases": [],
"description": "",
"name": "Agavendicksaft",
"plural_name": "agave nectar"
"plural_name": "Agavendicksaft"
},
"sugar syrup": {
"aliases": [],
"description": "",
"name": "Zuckersirup",
"plural_name": "sugar syrup"
"plural_name": "Zuckersirup"
},
"isomalt": {
"aliases": [],
@@ -6507,31 +6507,31 @@
"aliases": [],
"description": "",
"name": "Erythrit",
"plural_name": "erythritol"
"plural_name": "Erythrit"
},
"vanilla sugar": {
"aliases": [],
"description": "",
"name": "Vanillezucker",
"plural_name": "vanilla sugar"
"plural_name": "Vanillezucker"
},
"demerara sugar": {
"aliases": [],
"description": "",
"name": "Demerara-Zucker",
"plural_name": "demerara sugar"
"plural_name": "Demerara-Zucker"
},
"caramel syrup": {
"aliases": [],
"description": "",
"name": "Karamellsirup",
"plural_name": "caramel syrup"
"plural_name": "Karamellsirup"
},
"chocolate syrup": {
"aliases": [],
"description": "",
"name": "Schokoladensirup",
"plural_name": "chocolate syrup"
"plural_name": "Schokoladensirup"
},
"jaggery": {
"aliases": [],
@@ -6543,61 +6543,61 @@
"aliases": [],
"description": "",
"name": "Rohzucker",
"plural_name": "raw sugar"
"plural_name": "Rohzucker"
},
"golden syrup": {
"aliases": [],
"description": "",
"name": "Zuckerrübensirup",
"plural_name": "golden syrup"
"plural_name": "Zuckerrübensirup"
},
"cinnamon sugar": {
"aliases": [],
"description": "",
"name": "Zimtzucker",
"plural_name": "cinnamon sugar"
"plural_name": "Zimtzucker"
},
"liquid stevia": {
"aliases": [],
"description": "",
"name": "Flüssiges Stevia",
"plural_name": "liquid stevia"
"plural_name": "Flüssiger Stevia"
},
"grenadine": {
"aliases": [],
"description": "",
"name": "Grenadine",
"plural_name": "grenadine"
"plural_name": "Grenadine"
},
"coarse sugar": {
"aliases": [],
"description": "",
"name": "Hagelzucker",
"plural_name": "coarse sugar"
"plural_name": "Hagelzucker"
},
"salted caramel syrup": {
"aliases": [],
"description": "",
"name": "Gesalzenes Karamellsirup",
"plural_name": "salted caramel syrup"
"plural_name": "Gesalzener Karamellsirup"
},
"sanding sugar": {
"aliases": [],
"description": "",
"name": "Puderzucker",
"plural_name": "sanding sugar"
"plural_name": "Puderzucker"
},
"dark corn syrup": {
"aliases": [],
"description": "",
"name": "Dunkler Maissirup",
"plural_name": "dark corn syrup"
"plural_name": "Dunkler Maissirup"
},
"sucralose": {
"aliases": [],
"description": "",
"name": "Sucralose",
"plural_name": "sucralose"
"plural_name": "Sucralose"
},
"monk fruit sweetener": {
"aliases": [],
@@ -6609,7 +6609,7 @@
"aliases": [],
"description": "",
"name": "Ahornzucker",
"plural_name": "maple sugar"
"plural_name": "Ahornzucker"
},
"blackstrap molass": {
"aliases": [],
@@ -6879,7 +6879,7 @@
"aliases": [],
"description": "",
"name": "Malzsirup",
"plural_name": "malt syrup"
"plural_name": "Malzsirup"
},
"hot honey": {
"aliases": [],

View File

@@ -7110,8 +7110,8 @@
"himalayan salt": {
"aliases": [],
"description": "",
"name": "himalayan salt",
"plural_name": "himalayan salt"
"name": "Sal del Himalaya",
"plural_name": "Sal del Himalaya"
},
"lemon & pepper seasoning": {
"aliases": [],
@@ -7326,8 +7326,8 @@
"smoked salt": {
"aliases": [],
"description": "",
"name": "smoked salt",
"plural_name": "smoked salt"
"name": "Sal ahumada",
"plural_name": "Sal ahumada"
},
"dash seasoning": {
"aliases": [],
@@ -7428,8 +7428,8 @@
"truffle salt": {
"aliases": [],
"description": "",
"name": "truffle salt",
"plural_name": "truffle salt"
"name": "Sal de trufa",
"plural_name": "Sal de trufa"
},
"biryani masala": {
"aliases": [],
@@ -10030,8 +10030,8 @@
"bread": {
"aliases": [],
"description": "",
"name": "bread",
"plural_name": "breads"
"name": "Pan",
"plural_name": "Panes"
}
}
},
@@ -10052,20 +10052,20 @@
"flour tortilla": {
"aliases": [],
"description": "",
"name": "flour tortilla",
"plural_name": "flour tortillas"
"name": "Tortilla de harina",
"plural_name": "Tortillas de harina"
},
"almond flour tortilla": {
"aliases": [],
"description": "",
"name": "almond flour tortilla",
"plural_name": "almond flour tortillas"
"name": "Tortilla de harina de almendra",
"plural_name": "Tortillas de harina de almendras"
},
"corn tortilla": {
"aliases": [],
"description": "",
"name": "corn tortilla",
"plural_name": "corn tortillas"
"name": "Tortilla de maíz",
"plural_name": "Tortillas de maíz"
},
"cracker": {
"aliases": [],
@@ -10082,14 +10082,14 @@
"tortilla chip": {
"aliases": [],
"description": "",
"name": "tortilla chip",
"plural_name": "tortilla chips"
"name": "totopo",
"plural_name": "totopos"
},
"pita": {
"aliases": [],
"description": "",
"name": "pita",
"plural_name": "pitas"
"name": "Pan de pita",
"plural_name": "Panes de pita"
},
"pretzel": {
"aliases": [],
@@ -10106,14 +10106,14 @@
"rustic italian bread": {
"aliases": [],
"description": "",
"name": "rustic italian bread",
"plural_name": "rustic italian breads"
"name": "Pan italiano rústico",
"plural_name": "Panes italianos rústicos"
},
"popcorn": {
"aliases": [],
"description": "",
"name": "popcorn",
"plural_name": "popcorns"
"name": "Palomita",
"plural_name": "Palomitas"
},
"crouton": {
"aliases": [],
@@ -10124,8 +10124,8 @@
"whole-wheat tortilla": {
"aliases": [],
"description": "",
"name": "whole-wheat tortilla",
"plural_name": "whole-wheat tortillas"
"name": "Tortilla de harina integral",
"plural_name": "Tortillas de harina integral"
},
"english muffin": {
"aliases": [],

View File

@@ -169,7 +169,7 @@
"aliases": [],
"description": "",
"name": "rucola",
"plural_name": "arugula"
"plural_name": "rucolaa"
},
"leek": {
"aliases": [],
@@ -279,8 +279,8 @@
"mixed vegetables": {
"aliases": [],
"description": "",
"name": "mixed vegetables",
"plural_name": "mixed vegetables"
"name": "sekakasvikset",
"plural_name": "sekakasvikset"
},
"poblano pepper": {
"aliases": [],
@@ -419,8 +419,8 @@
"corn on the cob": {
"aliases": [],
"description": "",
"name": "corn on the cob",
"plural_name": "corn on the cob"
"name": "tähkämaissi",
"plural_name": "tähkämaissia"
},
"radicchio": {
"aliases": [],
@@ -488,13 +488,13 @@
],
"description": "",
"name": "maize",
"plural_name": "maize"
"plural_name": "maissia"
},
"collard greens": {
"aliases": [],
"description": "",
"name": "collard greens",
"plural_name": "collard greens"
"name": "lehtikaali",
"plural_name": "lehtikaalia"
},
"french-fried onion": {
"aliases": [],
@@ -1241,8 +1241,8 @@
"button mushroom": {
"aliases": [],
"description": "",
"name": "button mushroom",
"plural_name": "button mushrooms"
"name": "pieni herkkusieni",
"plural_name": "pieniä herkkusieniä"
},
"shiitake mushroom": {
"aliases": [],
@@ -1253,8 +1253,8 @@
"portobello mushroom": {
"aliases": [],
"description": "",
"name": "portobello mushroom",
"plural_name": "portobello mushrooms"
"name": "herkkusieni",
"plural_name": "herkkusieniä"
},
"wild mushroom": {
"aliases": [],
@@ -1503,8 +1503,8 @@
"dried cherry": {
"aliases": [],
"description": "",
"name": "dried cherry",
"plural_name": "dried cherries"
"name": "kuivattu kirsikka",
"plural_name": "kuivattuja kirsikoita"
},
"juniper berry": {
"aliases": [],

File diff suppressed because it is too large Load Diff

View File

@@ -5425,14 +5425,14 @@
"amberjack": {
"aliases": [],
"description": "",
"name": "amberjack",
"plural_name": "amberjacks"
"name": "sériole",
"plural_name": "sérioles"
},
"korean fish cake": {
"aliases": [],
"description": "",
"name": "korean fish cake",
"plural_name": "korean fish cakes"
"name": "fish cake coréen",
"plural_name": "fish cakes coréens"
},
"mullet": {
"aliases": [],
@@ -5467,8 +5467,8 @@
"threadfin": {
"aliases": [],
"description": "",
"name": "threadfin",
"plural_name": "threadfins"
"name": "cohana",
"plural_name": "cohanas"
},
"tiny fish": {
"aliases": [],
@@ -5479,8 +5479,8 @@
"tuna belly": {
"aliases": [],
"description": "",
"name": "tuna belly",
"plural_name": "tuna bellies"
"name": "ventrèche de thon",
"plural_name": "ventrèches de thon"
},
"beluga caviar": {
"aliases": [],

View File

@@ -65,7 +65,7 @@
"aliases": [],
"description": "",
"name": "jalapeño",
"plural_name": "jalapeños"
"plural_name": "jalapeño"
},
"avocado": {
"aliases": [],
@@ -132,8 +132,8 @@
"baby greens": {
"aliases": [],
"description": "",
"name": "baby greens",
"plural_name": "baby greens"
"name": "verdura tenera",
"plural_name": "verdure tenere"
},
"pumpkin": {
"aliases": [],
@@ -163,13 +163,13 @@
"aliases": [],
"description": "",
"name": "cavolo riccio",
"plural_name": "kale"
"plural_name": "cavoli"
},
"arugula": {
"aliases": [],
"description": "",
"name": "rucola",
"plural_name": "arugula"
"plural_name": "rucole"
},
"leek": {
"aliases": [],
@@ -199,7 +199,7 @@
"aliases": [],
"description": "",
"name": "lattuga romana",
"plural_name": "romaine lettuce"
"plural_name": "lattuga romana"
},
"beetroot": {
"aliases": [],
@@ -261,8 +261,8 @@
"mixed greens": {
"aliases": [],
"description": "",
"name": "mixed greens",
"plural_name": "mixed greens"
"name": "verdura mista",
"plural_name": "verdure miste"
},
"parsnip": {
"aliases": [],
@@ -279,8 +279,8 @@
"mixed vegetables": {
"aliases": [],
"description": "",
"name": "mixed vegetables",
"plural_name": "mixed vegetables"
"name": "verdure miste",
"plural_name": "verdure miste"
},
"poblano pepper": {
"aliases": [],
@@ -304,7 +304,7 @@
"aliases": [],
"description": "",
"name": "pepe di Caienna",
"plural_name": "cayenne pepper"
"plural_name": "peperoncini di Cayenna"
},
"green tomato": {
"aliases": [],
@@ -419,8 +419,8 @@
"corn on the cob": {
"aliases": [],
"description": "",
"name": "corn on the cob",
"plural_name": "corn on the cob"
"name": "pannocchia",
"plural_name": "pannocchie"
},
"radicchio": {
"aliases": [],
@@ -438,7 +438,7 @@
"aliases": [],
"description": "",
"name": "broccoli spinarolo",
"plural_name": "tenderstem broccoli"
"plural_name": "broccoletti"
},
"plantain": {
"aliases": [],
@@ -5730,7 +5730,7 @@
"aliases": [],
"description": "",
"name": "abalone",
"plural_name": "abalones"
"plural_name": "abaloni"
},
"seaweed salad": {
"aliases": [],
@@ -6309,8 +6309,8 @@
"achiote seed": {
"aliases": [],
"description": "",
"name": "achiote seed",
"plural_name": "achiote seeds"
"name": "seme di achiote",
"plural_name": "semi di achiote"
},
"savory herb": {
"aliases": [],
@@ -6405,8 +6405,8 @@
"achiote paste": {
"aliases": [],
"description": "",
"name": "achiote paste",
"plural_name": "achiote paste"
"name": "pasta di achiote",
"plural_name": "pasta di achiote"
},
"summer savory": {
"aliases": [],
@@ -9774,8 +9774,8 @@
"acini di pepe": {
"aliases": [],
"description": "",
"name": "acini di pepe",
"plural_name": "acini di pepes"
"name": "acino di pepe",
"plural_name": "acini di pepe"
},
"cavatelli": {
"aliases": [],
@@ -10687,7 +10687,7 @@
"aliases": [],
"description": "",
"name": "olio di semi di girasole",
"plural_name": "sunflower oil"
"plural_name": "olio di semi di girasole"
},
"avocado oil": {
"aliases": [],
@@ -10988,8 +10988,8 @@
"achiote oil": {
"aliases": [],
"description": "",
"name": "achiote oil",
"plural_name": "achiote oil"
"name": "olio di achiote",
"plural_name": "olio di achiote"
},
"jojoba oil": {
"aliases": [],
@@ -15676,7 +15676,7 @@
"aliases": [],
"description": "",
"name": "succo alla bacca di acai",
"plural_name": "acai berry juice"
"plural_name": "succo di acai"
},
"lemon crystal": {
"aliases": [],
@@ -15938,7 +15938,7 @@
"aliases": [],
"description": "",
"name": "polvere di bacca di acai",
"plural_name": "acai powder"
"plural_name": "polvere di acai"
},
"hemp protein": {
"aliases": [],
@@ -16105,8 +16105,8 @@
"activated charcoal": {
"aliases": [],
"description": "",
"name": "activated charcoal",
"plural_name": "activated charcoals"
"name": "carbone attivo",
"plural_name": "carboni attivi"
},
"egg powder": {
"aliases": [],
@@ -16231,7 +16231,7 @@
"sunflower lecithin": {
"aliases": [],
"description": "",
"name": "sunflower lecithin",
"name": "lecitina di girasole",
"plural_name": "lecitina di girasole"
},
"thc": {
@@ -16304,7 +16304,7 @@
"aliases": [],
"description": "",
"name": "maltodestrina",
"plural_name": "maltodextrins"
"plural_name": "maltodestrine"
}
}
}

View File

@@ -2254,13 +2254,13 @@
"aliases": [],
"description": "",
"name": "muenster kaas",
"plural_name": "muenster cheese"
"plural_name": "Munster kaas"
},
"string cheese": {
"aliases": [],
"description": "",
"name": "string cheese",
"plural_name": "string cheese"
"name": "stripkaas",
"plural_name": "stripkaas"
},
"camembert cheese": {
"aliases": [],
@@ -2283,8 +2283,8 @@
"raclette cheese": {
"aliases": [],
"description": "",
"name": "raclette cheese",
"plural_name": "raclette cheese"
"name": "raclette kaas",
"plural_name": "raclette kaas"
},
"colby-jack cheese": {
"aliases": [],

View File

@@ -484,7 +484,7 @@
},
"maize": {
"aliases": [
"corn husk"
"łuska kukurydzy"
],
"description": "",
"name": "maize",
@@ -3469,8 +3469,8 @@
"vegan chicken": {
"aliases": [],
"description": "",
"name": "vegan chicken",
"plural_name": "vegan chickens"
"name": "wegański kurczak",
"plural_name": "wegański kurczak"
},
"coconut paste": {
"aliases": [],
@@ -3709,8 +3709,8 @@
"vegan chicken nugget": {
"aliases": [],
"description": "",
"name": "vegan chicken nugget",
"plural_name": "vegan chicken nuggets"
"name": "wegański nuggets z kurczaka",
"plural_name": "wegańskie nuggetsy z kurczaka"
},
"vegan starter culture": {
"aliases": [],
@@ -4353,20 +4353,20 @@
"chicken breast": {
"aliases": [],
"description": "",
"name": "chicken breast",
"plural_name": "chicken breasts"
"name": "pierś z kurczaka",
"plural_name": "piersi z kurczaka"
},
"chicken thigh": {
"aliases": [],
"description": "",
"name": "chicken thigh",
"plural_name": "chicken thighs"
"name": "udko z kurczaka",
"plural_name": "udka z kurczaka"
},
"cooked chicken": {
"aliases": [],
"description": "",
"name": "cooked chicken",
"plural_name": "cooked chickens"
"name": "gotowany kurczak",
"plural_name": "gotowany kurczak"
},
"ground turkey": {
"aliases": [],
@@ -4395,8 +4395,8 @@
"chicken wing": {
"aliases": [],
"description": "",
"name": "chicken wing",
"plural_name": "chicken wings"
"name": "skrzydełko z kurczaka",
"plural_name": "skrzydełka z kurczaka"
},
"turkey breast": {
"aliases": [],
@@ -4407,20 +4407,20 @@
"ground chicken": {
"aliases": [],
"description": "",
"name": "ground chicken",
"plural_name": "ground chickens"
"name": "zmielone mięso z kurczaka",
"plural_name": "zmielone mięso z kurczaka"
},
"rotisserie chicken": {
"aliases": [],
"description": "",
"name": "rotisserie chicken",
"plural_name": "rotisserie chickens"
"name": "zmielone mięso z kurczaka",
"plural_name": "kurczak z rożna"
},
"chicken tender": {
"aliases": [],
"description": "",
"name": "chicken tender",
"plural_name": "chicken tenders"
"name": "polędwica z kurczaka",
"plural_name": "polędwiczki z kurczaka"
},
"turkey sausage": {
"aliases": [],
@@ -4455,14 +4455,14 @@
"boneless chicken": {
"aliases": [],
"description": "",
"name": "boneless chicken",
"plural_name": "boneless chickens"
"name": "kurczak bez kości",
"plural_name": "kurczak bez kości"
},
"chicken liver": {
"aliases": [],
"description": "",
"name": "chicken liver",
"plural_name": "chicken livers"
"name": "wątroba z kurczaka",
"plural_name": "wątroba z kurczaka"
},
"cornish hen": {
"aliases": [],
@@ -4491,8 +4491,8 @@
"chicken quarter": {
"aliases": [],
"description": "",
"name": "chicken quarter",
"plural_name": "chicken quarters"
"name": "ćwiartka z kurczaka",
"plural_name": "ćwiartka z kurczaka"
},
"ground turkey sausage": {
"aliases": [],
@@ -4557,8 +4557,8 @@
"chicken bone": {
"aliases": [],
"description": "",
"name": "chicken bone",
"plural_name": "chicken bones"
"name": "kość z kurczaka",
"plural_name": "kości z kurczaka"
},
"turkey meatball": {
"aliases": [],
@@ -4575,8 +4575,8 @@
"chicken giblet": {
"aliases": [],
"description": "",
"name": "chicken giblet",
"plural_name": "chicken giblets"
"name": "podroby z kurczaka",
"plural_name": "podroby z kurczaka"
},
"turkey wing": {
"aliases": [],
@@ -4599,8 +4599,8 @@
"chicken nugget": {
"aliases": [],
"description": "",
"name": "chicken nugget",
"plural_name": "chicken nuggets"
"name": "nuggets z kurczaka",
"plural_name": "nuggetsy z kurczaka"
},
"turkey burger": {
"aliases": [],

View File

@@ -487,7 +487,7 @@
"corn husk"
],
"description": "",
"name": "maize",
"name": "milho",
"plural_name": "maize"
},
"collard greens": {

View File

@@ -18,13 +18,13 @@
"abbreviation": "ч.ч."
},
"fluid-ounce": {
"name": "fluid ounce",
"name": "течни унции",
"plural_name": "течни унции",
"description": "",
"abbreviation": "fl oz"
"abbreviation": "течна унция"
},
"pint": {
"name": "pint",
"name": "пинта",
"plural_name": "пинти",
"description": "",
"abbreviation": "pt"
@@ -139,8 +139,8 @@
"abbreviation": ""
},
"sprig": {
"name": "sprig",
"plural_name": "sprigs",
"name": "клонче",
"plural_name": "клончета",
"description": "",
"abbreviation": ""
}

View File

@@ -139,8 +139,8 @@
"abbreviation": ""
},
"sprig": {
"name": "sprig",
"plural_name": "sprigs",
"name": "rametto",
"plural_name": "rametti",
"description": "",
"abbreviation": ""
}

View File

@@ -139,8 +139,8 @@
"abbreviation": ""
},
"sprig": {
"name": "sprig",
"plural_name": "sprigs",
"name": "веточка",
"plural_name": "веточки",
"description": "",
"abbreviation": ""
}

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import datetime
import enum
from enum import StrEnum
from fractions import Fraction
from typing import ClassVar
from uuid import UUID, uuid4
@@ -34,6 +35,28 @@ def display_fraction(fraction: Fraction):
)
class StandardizedUnitType(StrEnum):
"""
An arbitrary list of standardized units supported by unit conversions.
The backend doesn't really care what standardized unit you use, as long as it's recognized,
but defining them here keeps it consistant with the frontend.
"""
# Imperial
FLUID_OUNCE = "fluid_ounce"
CUP = "cup"
OUNCE = "ounce"
POUND = "pound"
# Metric
MILLILITER = "milliliter"
LITER = "liter"
GRAM = "gram"
KILOGRAM = "kilogram"
class UnitFoodBase(MealieModel):
id: UUID4 | None = None
name: str
@@ -109,9 +132,6 @@ class IngredientFood(CreateIngredientFood):
except AttributeError:
return v
def is_on_hand(self, household_slug: str) -> bool:
return household_slug in self.households_with_tool
class IngredientFoodPagination(PaginationBase):
items: list[IngredientFood]
@@ -130,7 +150,21 @@ class CreateIngredientUnit(UnitFoodBase):
abbreviation: str = ""
plural_abbreviation: str | None = ""
use_abbreviation: bool = False
aliases: list[CreateIngredientUnitAlias] = []
standard_quantity: float | None = None
standard_unit: str | None = None
@model_validator(mode="after")
def validate_standardization_fields(self):
# If one is set, the other must be set.
# If quantity is <= 0, it's considered not set.
if not self.standard_unit:
self.standard_quantity = self.standard_unit = None
elif not ((self.standard_quantity or 0) > 0):
self.standard_quantity = self.standard_unit = None
return self
class SaveIngredientUnit(CreateIngredientUnit):

View File

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

View File

@@ -28,6 +28,7 @@ from mealie.schema.recipe.recipe_ingredient import (
)
from mealie.schema.response.pagination import OrderDirection, PaginationQuery
from mealie.services.parser_services._base import DataMatcher
from mealie.services.parser_services.parser_utils import UnitConverter, merge_quantity_and_unit
class ShoppingListService:
@@ -41,8 +42,7 @@ class ShoppingListService:
self.list_refs = repos.group_shopping_list_recipe_refs
self.data_matcher = DataMatcher(self.repos, food_fuzzy_match_threshold=self.DEFAULT_FOOD_FUZZY_MATCH_THRESHOLD)
@staticmethod
def can_merge(item1: ShoppingListItemBase, item2: ShoppingListItemBase) -> bool:
def can_merge(self, item1: ShoppingListItemBase, item2: ShoppingListItemBase) -> bool:
"""Check to see if this item can be merged with another item"""
if any(
@@ -50,16 +50,28 @@ class ShoppingListService:
item1.checked,
item2.checked,
item1.food_id != item2.food_id,
item1.unit_id != item2.unit_id,
]
):
return False
# check if units match or if they're compatable
if item1.unit_id != item2.unit_id:
item1_unit = item1.unit or self.data_matcher.units_by_id.get(item1.unit_id)
item2_unit = item2.unit or self.data_matcher.units_by_id.get(item2.unit_id)
if not (item1_unit and item1_unit.standard_unit):
return False
if not (item2_unit and item2_unit.standard_unit):
return False
uc = UnitConverter()
if not uc.can_convert(item1_unit.standard_unit, item2_unit.standard_unit):
return False
# if foods match, we can merge, otherwise compare the notes
return bool(item1.food_id) or item1.note == item2.note
@staticmethod
def merge_items(
self,
from_item: ShoppingListItemCreate | ShoppingListItemUpdateBulk,
to_item: ShoppingListItemCreate | ShoppingListItemUpdateBulk | ShoppingListItemOut,
) -> ShoppingListItemUpdate:
@@ -69,7 +81,20 @@ class ShoppingListService:
Attributes of the `to_item` take priority over the `from_item`, except extras with overlapping keys
"""
to_item.quantity += from_item.quantity
to_item_unit = to_item.unit or self.data_matcher.units_by_id.get(to_item.unit_id)
from_item_unit = from_item.unit or self.data_matcher.units_by_id.get(from_item.unit_id)
if to_item_unit and to_item_unit.standard_unit and from_item_unit and from_item_unit.standard_unit:
merged_qty, merged_unit = merge_quantity_and_unit(
from_item.quantity or 0, from_item_unit, to_item.quantity or 0, to_item_unit
)
to_item.quantity = merged_qty
to_item.unit_id = merged_unit.id
to_item.unit = merged_unit
else:
# No conversion needed, just sum the quantities
to_item.quantity += from_item.quantity
if to_item.note != from_item.note:
to_item.note = " | ".join([note for note in [to_item.note, from_item.note] if note])

View File

@@ -28,18 +28,38 @@ class DataMatcher:
self._food_fuzzy_match_threshold = food_fuzzy_match_threshold
self._unit_fuzzy_match_threshold = unit_fuzzy_match_threshold
self._foods_by_id: dict[UUID4, IngredientFood] | None = None
self._units_by_id: dict[UUID4, IngredientUnit] | None = None
self._foods_by_alias: dict[str, IngredientFood] | None = None
self._units_by_alias: dict[str, IngredientUnit] | None = None
@property
def foods_by_alias(self) -> dict[str, IngredientFood]:
if self._foods_by_alias is None:
def foods_by_id(self) -> dict[UUID4, IngredientFood]:
if self._foods_by_id is None:
foods_repo = self.repos.ingredient_foods
query = PaginationQuery(page=1, per_page=-1)
all_foods = foods_repo.page_all(query).items
self._foods_by_id = {food.id: food for food in all_foods}
return self._foods_by_id
@property
def units_by_id(self) -> dict[UUID4, IngredientUnit]:
if self._units_by_id is None:
units_repo = self.repos.ingredient_units
query = PaginationQuery(page=1, per_page=-1)
all_units = units_repo.page_all(query).items
self._units_by_id = {unit.id: unit for unit in all_units}
return self._units_by_id
@property
def foods_by_alias(self) -> dict[str, IngredientFood]:
if self._foods_by_alias is None:
foods_by_alias: dict[str, IngredientFood] = {}
for food in all_foods:
for food in self.foods_by_id.values():
if food.name:
foods_by_alias[IngredientFoodModel.normalize(food.name)] = food
if food.plural_name:
@@ -56,12 +76,8 @@ class DataMatcher:
@property
def units_by_alias(self) -> dict[str, IngredientUnit]:
if self._units_by_alias is None:
units_repo = self.repos.ingredient_units
query = PaginationQuery(page=1, per_page=-1)
all_units = units_repo.page_all(query).items
units_by_alias: dict[str, IngredientUnit] = {}
for unit in all_units:
for unit in self.units_by_id.values():
if unit.name:
units_by_alias[IngredientUnitModel.normalize(unit.name)] = unit
if unit.plural_name:

View File

@@ -1 +1,2 @@
from .string_utils import *
from .unit_utils import *

View File

@@ -0,0 +1,146 @@
from typing import TYPE_CHECKING, Literal, overload
from pint import Quantity, Unit, UnitRegistry
if TYPE_CHECKING:
from mealie.schema.recipe.recipe_ingredient import CreateIngredientUnit
class UnitNotFound(Exception):
"""Raised when trying to access a unit not found in the unit registry."""
def __init__(self, message: str = "Unit not found in unit registry"):
self.message = message
super().__init__(self.message)
def __str__(self):
return f"{self.message}"
class UnitConverter:
def __init__(self):
self.ureg = UnitRegistry()
def _resolve_ounce(self, unit_1: Unit, unit_2: Unit) -> tuple[Unit, Unit]:
"""
Often times "ounce" is used in place of "fluid ounce" in recipes.
When trying to convert/combine ounces with a volume, we can assume it should have been a fluid ounce.
This function will convert ounces to fluid ounces if the other unit is a volume.
"""
OUNCE = self.ureg("ounce")
FL_OUNCE = self.ureg("fluid_ounce")
VOLUME = "[length] ** 3"
if unit_1 == OUNCE and unit_2.dimensionality == VOLUME:
return FL_OUNCE, unit_2
if unit_2 == OUNCE and unit_1.dimensionality == VOLUME:
return unit_1, FL_OUNCE
return unit_1, unit_2
@overload
def parse(self, unit: str | Unit, strict: Literal[False] = False) -> str | Unit: ...
@overload
def parse(self, unit: str | Unit, strict: Literal[True]) -> Unit: ...
def parse(self, unit: str | Unit, strict: bool = False) -> str | Unit:
"""
Parse a string unit into a pint.Unit.
If strict is False (default), returns a pint.Unit if it exists, otherwise returns the original string.
If strict is True, raises UnitNotFound instead of returning a string.
If the input is already a parsed pint.Unit, returns it as-is.
"""
if isinstance(unit, Unit):
return unit
try:
return self.ureg(unit).units
except Exception as e:
if strict:
raise UnitNotFound(f"Unit '{unit}' not found in unit registry") from e
return unit
def can_convert(self, unit: str | Unit, to_unit: str | Unit) -> bool:
"""Whether or not a given unit can be converted into another unit."""
unit = self.parse(unit)
to_unit = self.parse(to_unit)
if not (isinstance(unit, Unit) and isinstance(to_unit, Unit)):
return False
unit, to_unit = self._resolve_ounce(unit, to_unit)
return unit.is_compatible_with(to_unit)
def convert(self, quantity: float, unit: str | Unit, to_unit: str | Unit) -> tuple[float, Unit]:
"""
Convert a quantity and a unit into another unit.
Returns tuple[quantity, unit]
"""
unit = self.parse(unit, strict=True)
to_unit = self.parse(to_unit, strict=True)
unit, to_unit = self._resolve_ounce(unit, to_unit)
qty = quantity * unit
converted = qty.to(to_unit)
return float(converted.magnitude), converted.units
def merge(self, quantity_1: float, unit_1: str | Unit, quantity_2: float, unit_2: str | Unit) -> tuple[float, Unit]:
"""Merge two quantities together"""
unit_1 = self.parse(unit_1, strict=True)
unit_2 = self.parse(unit_2, strict=True)
unit_1, unit_2 = self._resolve_ounce(unit_1, unit_2)
q1 = quantity_1 * unit_1
q2 = quantity_2 * unit_2
out: Quantity = q1 + q2
return float(out.magnitude), out.units
def merge_quantity_and_unit[T: CreateIngredientUnit](
qty_1: float, unit_1: T, qty_2: float, unit_2: T
) -> tuple[float, T]:
"""
Merge a quantity and unit.
Returns tuple[quantity, unit]
"""
if not (unit_1.standard_quantity and unit_1.standard_unit and unit_2.standard_quantity and unit_2.standard_unit):
raise ValueError("Both units must contain standardized unit data")
PINT_UNIT_1_TXT = "_mealie_unit_1"
PINT_UNIT_2_TXT = "_mealie_unit_2"
uc = UnitConverter()
# pre-process units to account for ounce -> fluid_ounce conversion
unit_1_standard = uc.parse(unit_1.standard_unit, strict=True)
unit_2_standard = uc.parse(unit_2.standard_unit, strict=True)
unit_1_standard, unit_2_standard = uc._resolve_ounce(unit_1_standard, unit_2_standard)
# create custon unit definition so pint can handle them natively
uc.ureg.define(f"{PINT_UNIT_1_TXT} = {unit_1.standard_quantity} * {unit_1_standard}")
uc.ureg.define(f"{PINT_UNIT_2_TXT} = {unit_2.standard_quantity} * {unit_2_standard}")
pint_unit_1 = uc.parse(PINT_UNIT_1_TXT)
pint_unit_2 = uc.parse(PINT_UNIT_2_TXT)
merged_q, merged_u = uc.merge(qty_1, pint_unit_1, qty_2, pint_unit_2)
# Convert to the bigger unit if quantity >= 1, else the smaller unit
merged_q, merged_u = uc.convert(merged_q, merged_u, max(pint_unit_1, pint_unit_2))
if abs(merged_q) < 1:
merged_q, merged_u = uc.convert(merged_q, merged_u, min(pint_unit_1, pint_unit_2))
if str(merged_u) == PINT_UNIT_1_TXT:
return merged_q, unit_1
else:
return merged_q, unit_2

View File

@@ -17,7 +17,7 @@ dependencies = [
"apprise==1.9.7",
"bcrypt==5.0.0",
"extruct==0.18.0",
"fastapi==0.129.0",
"fastapi==0.129.1",
"httpx==0.28.1",
"lxml==6.0.2",
"orjson==3.11.7",
@@ -39,13 +39,14 @@ dependencies = [
"authlib==1.6.8",
"html2text==2025.4.15",
"paho-mqtt==1.6.1",
"pydantic-settings==2.13.0",
"pillow-heif==1.2.0",
"pydantic-settings==2.13.1",
"pillow-heif==1.2.1",
"pyjwt==2.11.0",
"openai==2.21.0",
"typing-extensions==4.15.0",
"itsdangerous==2.2.0",
"ingredient-parser-nlp==2.5.0",
"pint>=0.25",
]
[project.scripts]
@@ -58,19 +59,19 @@ pgsql = [
[dependency-groups]
docs = [
"mkdocs-material==9.7.1",
"mkdocs-material==9.7.2",
]
dev = [
"coverage==7.13.4",
"coveragepy-lcov==0.1.2",
"mkdocs-material==9.7.1",
"mkdocs-material==9.7.2",
"mypy==1.19.1",
"pre-commit==4.5.1",
"pylint==4.0.4",
"pylint==4.0.5",
"pytest==9.0.2",
"pytest-asyncio==1.3.0",
"rich==14.3.2",
"ruff==0.15.1",
"rich==14.3.3",
"ruff==0.15.2",
"types-PyYAML==6.0.12.20250915",
"types-python-dateutil==2.9.0.20260124",
"types-python-slugify==8.0.2.20240310",

View File

@@ -4,6 +4,9 @@ CWD = Path(__file__).parent
locale_dir = CWD / "locale"
backup_version_1d9a002d7234_1 = CWD / "backups/backup-version-1d9a002d7234-1.zip"
"""1d9a002d7234: add referenced_recipe to ingredients"""
backup_version_44e8d670719d_1 = CWD / "backups/backup-version-44e8d670719d-1.zip"
"""44e8d670719d: add extras to shopping lists, list items, and ingredient foods"""

Binary file not shown.

View File

@@ -15,14 +15,12 @@ def test_seed_foods(api_client: TestClient, unique_user: TestUser):
CREATED_FOODS = 2687
database = unique_user.repos
# Check that the foods was created
foods = database.ingredient_foods.page_all(PaginationQuery(page=1, per_page=-1)).items
assert len(foods) == 0
resp = api_client.post(api_routes.groups_seeders_foods, json={"locale": "en-US"}, headers=unique_user.token)
assert resp.status_code == 200
# Check that the foods was created
foods = database.ingredient_foods.page_all(PaginationQuery(page=1, per_page=-1)).items
assert len(foods) == CREATED_FOODS
@@ -31,29 +29,37 @@ def test_seed_units(api_client: TestClient, unique_user: TestUser):
CREATED_UNITS = 24
database = unique_user.repos
# Check that the foods was created
units = database.ingredient_units.page_all(PaginationQuery(page=1, per_page=-1)).items
assert len(units) == 0
resp = api_client.post(api_routes.groups_seeders_units, json={"locale": "en-US"}, headers=unique_user.token)
assert resp.status_code == 200
# Check that the foods was created
units = database.ingredient_units.page_all(PaginationQuery(page=1, per_page=-1)).items
assert len(units) == CREATED_UNITS
# Check that the "pint" unit was created and includes standardized data
pint_found = False
for unit in units:
if unit.name != "pint":
continue
pint_found = True
assert unit.standard_quantity == 2
assert unit.standard_unit == "cup"
assert pint_found
def test_seed_labels(api_client: TestClient, unique_user: TestUser):
CREATED_LABELS = 32
database = unique_user.repos
# Check that the foods was created
labels = database.group_multi_purpose_labels.page_all(PaginationQuery(page=1, per_page=-1)).items
assert len(labels) == 0
resp = api_client.post(api_routes.groups_seeders_labels, json={"locale": "en-US"}, headers=unique_user.token)
assert resp.status_code == 200
# Check that the foods was created
labels = database.group_multi_purpose_labels.page_all(PaginationQuery(page=1, per_page=-1)).items
assert len(labels) == CREATED_LABELS

View File

@@ -7,7 +7,7 @@ from fastapi.testclient import TestClient
from pydantic import UUID4
from mealie.schema.household.group_shopping_list import ShoppingListItemOut, ShoppingListOut
from mealie.schema.recipe.recipe_ingredient import SaveIngredientFood
from mealie.schema.recipe.recipe_ingredient import IngredientUnit, SaveIngredientFood
from tests import utils
from tests.utils import api_routes
from tests.utils.factories import random_int, random_string
@@ -641,6 +641,96 @@ def test_shopping_list_items_with_zero_quantity(
assert len(as_json["listItems"]) == len(normal_items + zero_qty_items) - 1
def test_shopping_list_merge_standard_unit(
api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut
):
unit_1_cup_data = {"name": random_string(), "standardQuantity": 1, "standardUnit": "cup"}
unit_2_cup_data = {"name": random_string(), "standardQuantity": 2, "standardUnit": "cup"}
unit_1_out = api_client.post(api_routes.units, json=unit_1_cup_data, headers=unique_user.token)
unit_2_out = api_client.post(api_routes.units, json=unit_2_cup_data, headers=unique_user.token)
unit_1 = IngredientUnit.model_validate(unit_1_out.json())
unit_2 = IngredientUnit.model_validate(unit_2_out.json())
list_item_1_data = create_item(shopping_list.id, unit_id=str(unit_1.id), note="mealie-food")
list_item_2_data = create_item(shopping_list.id, unit_id=str(unit_2.id), note="mealie-food")
response = api_client.post(
api_routes.households_shopping_items_create_bulk,
json=[list_item_1_data, list_item_2_data],
headers=unique_user.token,
)
as_json = utils.assert_deserialize(response, 201)
assert len(as_json["createdItems"]) == 1
item_out = as_json["createdItems"][0]
# should use larger "2 cup" unit (a la "pint")
assert item_out["unitId"] == str(unit_2.id)
# calculate quantity by summing base "cup" amount and dividing by 2 (a la pints)
assert item_out["quantity"] == (list_item_1_data["quantity"] + (list_item_2_data["quantity"] * 2)) / 2
def test_shopping_list_merge_standard_unit_different_foods(
api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut
):
unit_1_cup_data = {"name": random_string(), "standardQuantity": 1, "standardUnit": "cup"}
unit_2_cup_data = {"name": random_string(), "standardQuantity": 2, "standardUnit": "cup"}
unit_1_out = api_client.post(api_routes.units, json=unit_1_cup_data, headers=unique_user.token)
unit_2_out = api_client.post(api_routes.units, json=unit_2_cup_data, headers=unique_user.token)
unit_1 = IngredientUnit.model_validate(unit_1_out.json())
unit_2 = IngredientUnit.model_validate(unit_2_out.json())
list_item_1_data = create_item(shopping_list.id, unit_id=str(unit_1.id), note="mealie-food-1")
list_item_2_data = create_item(shopping_list.id, unit_id=str(unit_2.id), note="mealie-food-2")
response = api_client.post(
api_routes.households_shopping_items_create_bulk,
json=[list_item_1_data, list_item_2_data],
headers=unique_user.token,
)
as_json = utils.assert_deserialize(response, 201)
assert len(as_json["createdItems"]) == 2
for in_data, out_data in zip(
[list_item_1_data, list_item_2_data], [as_json["createdItems"][0], as_json["createdItems"][1]], strict=True
):
assert in_data["quantity"] == out_data["quantity"]
assert out_data["unit"]
assert in_data["unit_id"] == out_data["unit"]["id"]
assert in_data["note"] == out_data["note"]
def test_shopping_list_merge_standard_unit_incompatible_units(
api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut
):
unit_1_data = {"name": random_string(), "standardQuantity": 1, "standardUnit": "cup"}
unit_2_data = {"name": random_string(), "standardQuantity": 2, "standardUnit": "gram"}
unit_1_out = api_client.post(api_routes.units, json=unit_1_data, headers=unique_user.token)
unit_2_out = api_client.post(api_routes.units, json=unit_2_data, headers=unique_user.token)
unit_1 = IngredientUnit.model_validate(unit_1_out.json())
unit_2 = IngredientUnit.model_validate(unit_2_out.json())
list_item_1_data = create_item(shopping_list.id, unit_id=str(unit_1.id), note="mealie-food")
list_item_2_data = create_item(shopping_list.id, unit_id=str(unit_2.id), note="mealie-food")
response = api_client.post(
api_routes.households_shopping_items_create_bulk,
json=[list_item_1_data, list_item_2_data],
headers=unique_user.token,
)
as_json = utils.assert_deserialize(response, 201)
assert len(as_json["createdItems"]) == 2
for in_data, out_data in zip(
[list_item_1_data, list_item_2_data], [as_json["createdItems"][0], as_json["createdItems"][1]], strict=True
):
assert in_data["quantity"] == out_data["quantity"]
assert out_data["unit"]
assert in_data["unit_id"] == out_data["unit"]["id"]
assert in_data["note"] == out_data["note"]
def test_shopping_list_item_extras(
api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut
) -> None:

View File

@@ -2,7 +2,7 @@ import pytest
from fastapi.testclient import TestClient
from mealie.schema.recipe.recipe_ingredient import RegisteredParser
from tests.unit_tests.test_ingredient_parser import TestIngredient
from tests.unit_tests.ingredient_parser.test_ingredient_parser import TestIngredient
from tests.utils import api_routes
from tests.utils.fixture_schemas import TestUser

View File

@@ -0,0 +1,309 @@
import pint
import pytest
from mealie.schema.recipe.recipe_ingredient import CreateIngredientUnit
from mealie.services.parser_services.parser_utils import UnitConverter, UnitNotFound, merge_quantity_and_unit
from tests.utils import random_string
def test_uc_parse_string():
uc = UnitConverter()
parsed = uc.parse("cup")
assert isinstance(parsed, pint.Unit)
assert (str(parsed)) == "cup"
def test_uc_parse_unit():
uc = UnitConverter()
parsed = uc.parse(uc.parse("cup"))
assert isinstance(parsed, pint.Unit)
assert (str(parsed)) == "cup"
def test_uc_parse_invalid():
uc = UnitConverter()
input_str = random_string()
parsed = uc.parse(input_str)
assert not isinstance(parsed, pint.Unit)
assert parsed == input_str
def test_uc_parse_invalid_strict():
uc = UnitConverter()
input_str = random_string()
with pytest.raises(UnitNotFound):
uc.parse(input_str, strict=True)
@pytest.mark.parametrize("pre_parse_1", [True, False])
@pytest.mark.parametrize("pre_parse_2", [True, False])
def test_can_convert(pre_parse_1: bool, pre_parse_2: bool):
unit_1 = "cup"
unit_2 = "pint"
uc = UnitConverter()
if pre_parse_1:
unit_1 = uc.parse(unit_1)
if pre_parse_2:
unit_2 = uc.parse(unit_2)
assert uc.can_convert(unit_1, unit_2)
@pytest.mark.parametrize("pre_parse_1", [True, False])
@pytest.mark.parametrize("pre_parse_2", [True, False])
def test_cannot_convert(pre_parse_1: bool, pre_parse_2: bool):
unit_1 = "cup"
unit_2 = "pound"
uc = UnitConverter()
if pre_parse_1:
unit_1 = uc.parse(unit_1)
if pre_parse_2:
unit_2 = uc.parse(unit_2)
assert not uc.can_convert(unit_1, unit_2)
def test_cannot_convert_invalid_unit():
uc = UnitConverter()
assert not uc.can_convert("cup", random_string())
assert not uc.can_convert(random_string(), "cup")
def test_can_convert_same_unit():
uc = UnitConverter()
assert uc.can_convert("cup", "cup")
def test_can_convert_volume_ounce():
uc = UnitConverter()
assert uc.can_convert("ounce", "cup")
assert uc.can_convert("cup", "ounce")
def test_convert_simple():
uc = UnitConverter()
quantity, unit = uc.convert(1, "cup", "pint")
assert isinstance(unit, pint.Unit)
assert str(unit) == "pint"
assert quantity == 1 / 2
@pytest.mark.parametrize("pre_parse_1", [True, False])
@pytest.mark.parametrize("pre_parse_2", [True, False])
def test_convert_pre_parsed(pre_parse_1: bool, pre_parse_2: bool):
unit_1 = "cup"
unit_2 = "pint"
uc = UnitConverter()
if pre_parse_1:
unit_1 = uc.parse(unit_1)
if pre_parse_2:
unit_2 = uc.parse(unit_2)
quantity, unit = uc.convert(1, unit_1, unit_2)
assert isinstance(unit, pint.Unit)
assert str(unit) == "pint"
assert quantity == 1 / 2
def test_convert_weight():
uc = UnitConverter()
quantity, unit = uc.convert(16, "ounce", "pound")
assert isinstance(unit, pint.Unit)
assert str(unit) == "pound"
assert quantity == 1
def test_convert_zero_quantity():
uc = UnitConverter()
quantity, unit = uc.convert(0, "cup", "pint")
assert isinstance(unit, pint.Unit)
assert quantity == 0
def test_convert_invalid_unit():
uc = UnitConverter()
with pytest.raises(UnitNotFound):
uc.convert(1, "pound", random_string())
def test_convert_incompatible_units():
uc = UnitConverter()
with pytest.raises(pint.errors.DimensionalityError):
uc.convert(1, "pound", "cup")
def test_convert_volume_ounce():
uc = UnitConverter()
quantity, unit = uc.convert(8, "ounce", "cup")
assert isinstance(unit, pint.Unit)
assert str(unit) == "cup"
assert quantity == 1
def test_merge_same_unit():
uc = UnitConverter()
quantity, unit = uc.merge(1, "cup", 2, "cup")
assert isinstance(unit, pint.Unit)
assert str(unit) == "cup"
assert quantity == 3
@pytest.mark.parametrize("pre_parse_1", [True, False])
@pytest.mark.parametrize("pre_parse_2", [True, False])
def test_merge_compatible_units(pre_parse_1: bool, pre_parse_2: bool):
unit_1 = "cup"
unit_2 = "pint"
uc = UnitConverter()
if pre_parse_1:
unit_1 = uc.parse(unit_1)
if pre_parse_2:
unit_2 = uc.parse(unit_2)
quantity, unit = uc.merge(1, unit_1, 1, unit_2)
assert isinstance(unit, pint.Unit)
# 1 cup + 1 pint = 1 cup + 2 cups = 3 cups
assert quantity == 3
def test_merge_weight_units():
uc = UnitConverter()
quantity, unit = uc.merge(8, "ounce", 8, "ounce")
assert isinstance(unit, pint.Unit)
assert str(unit) == "ounce"
assert quantity == 16
def test_merge_different_weight_units():
uc = UnitConverter()
quantity, unit = uc.merge(1, "pound", 8, "ounce")
assert isinstance(unit, pint.Unit)
# 1 pound + 8 ounces = 16 ounces + 8 ounces = 24 ounces
assert str(unit) == "pound"
assert quantity == 1.5
def test_merge_zero_quantities():
uc = UnitConverter()
quantity, unit = uc.merge(0, "cup", 1, "cup")
assert isinstance(unit, pint.Unit)
assert str(unit) == "cup"
assert quantity == 1
def test_merge_invalid_unit():
uc = UnitConverter()
with pytest.raises(UnitNotFound):
uc.merge(1, "pound", 1, random_string())
def test_merge_incompatible_units():
uc = UnitConverter()
with pytest.raises(pint.errors.DimensionalityError):
uc.merge(1, "pound", 1, "cup")
def test_merge_negative_quantity():
uc = UnitConverter()
quantity, unit = uc.merge(-1, "cup", 2, "cup")
assert isinstance(unit, pint.Unit)
assert str(unit) == "cup"
assert quantity == 1
def test_merge_volume_ounce():
uc = UnitConverter()
quantity, unit = uc.merge(4, "ounce", 1, "cup")
assert isinstance(unit, pint.Unit)
assert str(unit) == "fluid_ounce" # converted automatically from ounce
assert quantity == 12
def test_merge_quantity_and_unit_simple():
unit_1 = CreateIngredientUnit(name="mealie_cup", standard_quantity=1, standard_unit="cup")
unit_2 = CreateIngredientUnit(name="mealie_cup", standard_quantity=1, standard_unit="cup")
quantity, unit = merge_quantity_and_unit(1, unit_1, 2, unit_2)
assert quantity == 3
assert unit.name == "mealie_cup"
def test_merge_quantity_and_unit_invalid():
unit_1 = CreateIngredientUnit(name="mealie_cup", standard_quantity=1, standard_unit="cup")
unit_2 = CreateIngredientUnit(name="mealie_random", standard_quantity=1, standard_unit=random_string())
with pytest.raises(UnitNotFound):
merge_quantity_and_unit(1, unit_1, 1, unit_2)
def test_merge_quantity_and_unit_compatible():
unit_1 = CreateIngredientUnit(name="mealie_pint", standard_quantity=1, standard_unit="pint")
unit_2 = CreateIngredientUnit(name="mealie_cup", standard_quantity=1, standard_unit="cup")
quantity, unit = merge_quantity_and_unit(1, unit_1, 1, unit_2)
# 1 pint + 1 cup = 2 pints + 1 cup = 3 cups, converted to pint = 1.5 pint
assert quantity == 1.5
assert unit.name == "mealie_pint"
def test_merge_quantity_and_unit_selects_larger_unit():
unit_1 = CreateIngredientUnit(name="mealie_pint", standard_quantity=1, standard_unit="pint")
unit_2 = CreateIngredientUnit(name="mealie_cup", standard_quantity=1, standard_unit="cup")
quantity, unit = merge_quantity_and_unit(2, unit_1, 4, unit_2)
# 2 pint + 4 cup = 4 cups + 4 cups = 8 cups, should be returned as pint (larger unit)
assert quantity == 4
assert unit.name == "mealie_pint"
def test_merge_quantity_and_unit_selects_smaller_unit():
unit_1 = CreateIngredientUnit(name="mealie_pint", standard_quantity=1, standard_unit="pint")
unit_2 = CreateIngredientUnit(name="mealie_cup", standard_quantity=1, standard_unit="cup")
quantity, unit = merge_quantity_and_unit(0.125, unit_1, 0.5, unit_2)
# 0.125 pint + 0.5 cup = 0.25 cup + 0.5 cup = 0.75 cup, should be returned as cup (smaller for < 1)
assert quantity == 0.75
assert unit.name == "mealie_cup"
def test_merge_quantity_and_unit_missing_standard_data():
unit_1 = CreateIngredientUnit(name="mealie_cup", standard_quantity=1, standard_unit="cup")
unit_2 = CreateIngredientUnit(name="mealie_cup_no_std", standard_quantity=None, standard_unit=None)
with pytest.raises(ValueError):
merge_quantity_and_unit(1, unit_1, 1, unit_2)
def test_merge_quantity_and_unit_volume_ounce():
unit_1 = CreateIngredientUnit(name="mealie_oz", standard_quantity=1, standard_unit="ounce")
unit_2 = CreateIngredientUnit(name="mealie_cup", standard_quantity=1, standard_unit="cup")
quantity, unit = merge_quantity_and_unit(8, unit_1, 1, unit_2)
assert quantity == 2
assert unit.name == "mealie_cup"

View File

@@ -1,11 +1,26 @@
from uuid import UUID
import pytest
from sqlalchemy.orm import Session
from mealie.repos.all_repositories import AllRepositories, get_repositories
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient, SaveIngredientUnit
from tests.utils.factories import random_string
from mealie.schema.user.user import GroupBase
from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser
@pytest.fixture()
def unique_local_group_id(unfiltered_database: AllRepositories) -> str:
return str(unfiltered_database.groups.create(GroupBase(name=random_string())).id)
@pytest.fixture()
def unique_db(session: Session, unique_local_group_id: str) -> AllRepositories:
return get_repositories(session, group_id=unique_local_group_id)
def test_unit_merger(unique_user: TestUser):
database = unique_user.repos
recipe: Recipe | None = None
@@ -51,3 +66,79 @@ def test_unit_merger(unique_user: TestUser):
for ingredient in recipe.recipe_ingredient:
assert ingredient.unit.id == unit_1.id # type: ignore
@pytest.mark.parametrize("standard_field", ["name", "plural_name", "abbreviation", "plural_abbreviation"])
@pytest.mark.parametrize("use_bulk", [True, False])
def test_auto_inject_standardization(unique_db: AllRepositories, standard_field: str, use_bulk: bool):
unit_in = SaveIngredientUnit(name=random_string(), group_id=unique_db.group_id).model_dump()
unit_in[standard_field] = "gallon"
if use_bulk:
out_many = unique_db.ingredient_units.create_many([unit_in])
assert len(out_many) == 1
unit_out = out_many[0]
else:
unit_out = unique_db.ingredient_units.create(unit_in)
assert unit_out.standard_unit == "cup"
assert unit_out.standard_quantity == 16
def test_dont_auto_inject_random(unique_db: AllRepositories):
unit_in = SaveIngredientUnit(name=random_string(), group_id=unique_db.group_id)
unit_out = unique_db.ingredient_units.create(unit_in)
assert unit_out.standard_quantity is None
assert unit_out.standard_unit is None
def test_auto_inject_other_language(unique_db: AllRepositories):
# Inject custom unit map
GALLON = random_string()
unique_db.ingredient_units._standardized_unit_map = {GALLON: "gallon"}
# Create unit with translated value
unit_in = SaveIngredientUnit(name=GALLON, group_id=unique_db.group_id)
unit_out = unique_db.ingredient_units.create(unit_in)
assert unit_out.standard_unit == "cup"
assert unit_out.standard_quantity == 16
@pytest.mark.parametrize("name", ["custom-mealie-unit", "gallon"])
def test_user_standardization(unique_db: AllRepositories, name: str):
unit_in = SaveIngredientUnit(
name=name,
group_id=unique_db.group_id,
standard_quantity=random_int(1, 10),
standard_unit=random_string(),
)
unit_out = unique_db.ingredient_units.create(unit_in)
assert unit_out.standard_quantity == unit_in.standard_quantity
assert unit_out.standard_unit == unit_in.standard_unit
def test_ignore_incomplete_standardization(unique_db: AllRepositories):
unit_in = SaveIngredientUnit(
name=random_string(),
group_id=unique_db.group_id,
standard_quantity=random_int(1, 10),
standard_unit=None,
)
unit_out = unique_db.ingredient_units.create(unit_in)
assert unit_out.standard_quantity is None
assert unit_out.standard_unit is None
unit_in = SaveIngredientUnit(
name=random_string(),
group_id=unique_db.group_id,
standard_quantity=None,
standard_unit=random_string(),
)
unit_out = unique_db.ingredient_units.create(unit_in)
assert unit_out.standard_quantity is None
assert unit_out.standard_unit is None

View File

@@ -217,6 +217,22 @@ def _b9e516e2d3b3_add_household_to_recipe_last_made_household_to_foods_and_tools
assert not tool.households_with_tool
def _a39c7f1826e3_add_unit_standardization_fields(session: Session):
groups = session.query(Group).all()
for group in groups:
# test_data.backup_version_1d9a002d7234_1 has a non-anonymized "pint" unit
# and has not yet run the standardization migration.
pint_units = (
session.query(IngredientUnitModel)
.filter(IngredientUnitModel.group_id == group.id, IngredientUnitModel.name == "pint")
.all()
)
for unit in pint_units:
assert unit.standard_quantity == 2
assert unit.standard_unit == "cup"
def test_database_restore_data():
"""
This tests real user backups to make sure the data is restored correctly. The data has been anonymized, but
@@ -227,6 +243,7 @@ def test_database_restore_data():
"""
backup_paths = [
test_data.backup_version_1d9a002d7234_1,
test_data.backup_version_44e8d670719d_1,
test_data.backup_version_44e8d670719d_2,
test_data.backup_version_44e8d670719d_3,
@@ -245,6 +262,7 @@ def test_database_restore_data():
_d7c6efd2de42_migrate_favorites_and_ratings_to_user_ratings,
_86054b40fd06_added_query_filter_string_to_cookbook_and_mealplan,
_b9e516e2d3b3_add_household_to_recipe_last_made_household_to_foods_and_tools,
_a39c7f1826e3_add_unit_standardization_fields,
]
settings = get_app_settings()

View File

@@ -23,7 +23,6 @@ def test_non_default_settings(monkeypatch):
assert app_settings.API_PORT == 8000
assert app_settings.API_DOCS is False
assert app_settings.REDOC_URL is None
assert app_settings.DOCS_URL is None

104
uv.lock generated
View File

@@ -399,7 +399,7 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.129.0"
version = "0.129.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@@ -408,9 +408,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/37/4aa7f6ce92745458b6ce0acd706dde2ac23a3bf341266b5311c904109f67/fastapi-0.129.1.tar.gz", hash = "sha256:6ccf0eca9644e0d6280115b4fc8281bf55ec5878d4d95572f7b2034ab15708ba", size = 369852, upload-time = "2026-02-21T13:10:03.335Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" },
{ url = "https://files.pythonhosted.org/packages/01/f9/f15d92bd6035d4f83be8b82dc527a3e7abc87648fda62cf8d1df344410a7/fastapi-0.129.1-py3-none-any.whl", hash = "sha256:022462403bc385b791df418d8f088eb0e8f1fe7cb8f625d682f5e9da6157cc83", size = 103226, upload-time = "2026-02-21T13:10:05.058Z" },
]
[[package]]
@@ -850,6 +850,7 @@ dependencies = [
{ name = "paho-mqtt" },
{ name = "pillow" },
{ name = "pillow-heif" },
{ name = "pint" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyhumps" },
@@ -910,7 +911,7 @@ requires-dist = [
{ name = "bcrypt", specifier = "==5.0.0" },
{ name = "beautifulsoup4", specifier = "==4.14.3" },
{ name = "extruct", specifier = "==0.18.0" },
{ name = "fastapi", specifier = "==0.129.0" },
{ name = "fastapi", specifier = "==0.129.1" },
{ name = "html2text", specifier = "==2025.4.15" },
{ name = "httpx", specifier = "==0.28.1" },
{ name = "ingredient-parser-nlp", specifier = "==2.5.0" },
@@ -922,10 +923,11 @@ requires-dist = [
{ name = "orjson", specifier = "==3.11.7" },
{ name = "paho-mqtt", specifier = "==1.6.1" },
{ name = "pillow", specifier = "==12.1.1" },
{ name = "pillow-heif", specifier = "==1.2.0" },
{ name = "pillow-heif", specifier = "==1.2.1" },
{ name = "pint", specifier = ">=0.25" },
{ name = "psycopg2-binary", marker = "extra == 'pgsql'", specifier = "==2.9.11" },
{ name = "pydantic", specifier = "==2.12.5" },
{ name = "pydantic-settings", specifier = "==2.13.0" },
{ name = "pydantic-settings", specifier = "==2.13.1" },
{ name = "pyhumps", specifier = "==3.8.0" },
{ name = "pyjwt", specifier = "==2.11.0" },
{ name = "python-dateutil", specifier = "==2.9.0.post0" },
@@ -950,22 +952,22 @@ dev = [
{ name = "coverage", specifier = "==7.13.4" },
{ name = "coveragepy-lcov", specifier = "==0.1.2" },
{ name = "freezegun", specifier = "==1.5.5" },
{ name = "mkdocs-material", specifier = "==9.7.1" },
{ name = "mkdocs-material", specifier = "==9.7.2" },
{ name = "mypy", specifier = "==1.19.1" },
{ name = "pre-commit", specifier = "==4.5.1" },
{ name = "pydantic-to-typescript2", specifier = "==1.0.6" },
{ name = "pylint", specifier = "==4.0.4" },
{ name = "pylint", specifier = "==4.0.5" },
{ name = "pytest", specifier = "==9.0.2" },
{ name = "pytest-asyncio", specifier = "==1.3.0" },
{ name = "rich", specifier = "==14.3.2" },
{ name = "ruff", specifier = "==0.15.1" },
{ name = "rich", specifier = "==14.3.3" },
{ name = "ruff", specifier = "==0.15.2" },
{ name = "types-python-dateutil", specifier = "==2.9.0.20260124" },
{ name = "types-python-slugify", specifier = "==8.0.2.20240310" },
{ name = "types-pyyaml", specifier = "==6.0.12.20250915" },
{ name = "types-requests", specifier = "==2.32.4.20260107" },
{ name = "types-urllib3", specifier = "==1.26.25.14" },
]
docs = [{ name = "mkdocs-material", specifier = "==9.7.1" }]
docs = [{ name = "mkdocs-material", specifier = "==9.7.2" }]
[[package]]
name = "mergedeep"
@@ -1030,7 +1032,7 @@ wheels = [
[[package]]
name = "mkdocs-material"
version = "9.7.1"
version = "9.7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "babel" },
@@ -1045,9 +1047,9 @@ dependencies = [
{ name = "pymdown-extensions" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/27/e2/2ffc356cd72f1473d07c7719d82a8f2cbd261666828614ecb95b12169f41/mkdocs_material-9.7.1.tar.gz", hash = "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8", size = 4094392, upload-time = "2025-12-18T09:49:00.308Z" }
sdist = { url = "https://files.pythonhosted.org/packages/34/57/5d3c8c9e2ff9d66dc8f63aa052eb0bac5041fecff7761d8689fe65c39c13/mkdocs_material-9.7.2.tar.gz", hash = "sha256:6776256552290b9b7a7aa002780e25b1e04bc9c3a8516b6b153e82e16b8384bd", size = 4097818, upload-time = "2026-02-18T15:53:07.763Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/32/ed071cb721aca8c227718cffcf7bd539620e9799bbf2619e90c757bfd030/mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c", size = 9297166, upload-time = "2025-12-18T09:48:56.664Z" },
{ url = "https://files.pythonhosted.org/packages/cd/19/d194e75e82282b1d688f0720e21b5ac250ed64ddea333a228aaf83105f2e/mkdocs_material-9.7.2-py3-none-any.whl", hash = "sha256:9bf6f53452d4a4d527eac3cef3f92b7b6fc4931c55d57766a7d87890d47e1b92", size = 9305052, upload-time = "2026-02-18T15:53:05.221Z" },
]
[[package]]
@@ -1237,20 +1239,20 @@ wheels = [
[[package]]
name = "pillow-heif"
version = "1.2.0"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pillow" },
]
sdist = { url = "https://files.pythonhosted.org/packages/12/96/e4bf7bde1de9908bb509212c2861bff857eb4be845ebf80f6b0b02b8650d/pillow_heif-1.2.0.tar.gz", hash = "sha256:dd5c818dfb4ec39a5093127f8c07bbb32ca81dbbd29c4ebeffd23222ccc76aa9", size = 17128367, upload-time = "2026-01-23T07:35:24.755Z" }
sdist = { url = "https://files.pythonhosted.org/packages/22/f4/68bd0465dc0494c22e23334dde0a9c52dec5afe98cf5a40abb47f75e1b08/pillow_heif-1.2.1.tar.gz", hash = "sha256:29be44d636269e2d779b4aec629bc056ec7260b734a16b4d3bb284c49c200274", size = 17128668, upload-time = "2026-02-18T11:20:48.643Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/3c/76ef3ecb3c7dbfa72033eeded44c0d6dc963aada7f7fc5a6262e3381dc38/pillow_heif-1.2.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:9e2d26deeba5a2f31ee92ae7e21cd07f8001cd4b2075585eac2f74f926527210", size = 4818206, upload-time = "2026-01-23T07:34:37.178Z" },
{ url = "https://files.pythonhosted.org/packages/8a/8c/04fa8db3627c8d8255645eaead8f2900c0e389c20815d19fefbd8a342fde/pillow_heif-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b121733ea0f82c3de04b73958aa338acd98137ed3ed3bf7a16516e0bf8654782", size = 3503861, upload-time = "2026-01-23T07:34:38.916Z" },
{ url = "https://files.pythonhosted.org/packages/b9/b8/500bc420d193314add5b2d3e67e2568215378f025a1fc78374f9fec217f3/pillow_heif-1.2.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdba80502ea990e66e5d1bde97f58746e8002998f6d627c8b483ddff2bc262a7", size = 5843480, upload-time = "2026-01-23T07:34:40.795Z" },
{ url = "https://files.pythonhosted.org/packages/81/60/84d73f681bae63062ac8c03b8d5cecc80ffc8604a2284573d06cd6b2718d/pillow_heif-1.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f5fe73cbf68778a48ebf320f52b73b142e986e2b853b5f83d4b5d102d276d33", size = 5577848, upload-time = "2026-01-23T07:34:42.333Z" },
{ url = "https://files.pythonhosted.org/packages/fd/ca/dc6be91840ce5dc3607793fbeff382813bc5f641ef33abd2fb6f769856ea/pillow_heif-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:36516178cf3c1de0cbefa6461ca0a5c4f67380b7ee8e200106aefaf444f32613", size = 6885225, upload-time = "2026-01-23T07:34:44.622Z" },
{ url = "https://files.pythonhosted.org/packages/72/c5/e86f47bb49214a3c7f7c09c6198db03048ddaf3c548f1fe58b25ff7d17da/pillow_heif-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c132994a444b8a5f2c5e64aa7d07b50405668198d8af5ba53af12732676e882d", size = 6510220, upload-time = "2026-01-23T07:34:46.871Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ca/04a44838085df912fbdf5cef0146ef4026b47995f09c84a25bfc679d10d7/pillow_heif-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:8e06bb5e9ccd4f4a8be9c618e071ed47bcf8246fd0d1f00c25ac98c4ad9fd8ff", size = 5483151, upload-time = "2026-01-23T07:34:49.002Z" },
{ url = "https://files.pythonhosted.org/packages/0e/4a/8d674f384d1d9d2d84193dcb7f6b41eeb240ed127f45f07114770205c8a1/pillow_heif-1.2.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c5a3c8fec8cf63f6d9170f092a210e76d584beef5a5b0f5e8fbfa171eb27520a", size = 4666941, upload-time = "2026-02-18T11:20:03.957Z" },
{ url = "https://files.pythonhosted.org/packages/f9/4b/f08a3c535bb116ed74b7851eb5cc0ae105338f4d5e921d7547137322e68e/pillow_heif-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:add430cf7f5340eaa70c2e57af59655515fd415b2b93dde0baec87be48debd0f", size = 3392297, upload-time = "2026-02-18T11:20:06.167Z" },
{ url = "https://files.pythonhosted.org/packages/92/eb/39f38534894c1e12cd4526683b7aec5a1d403cd8f76efc4efce762826658/pillow_heif-1.2.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9a6daa0f88fe5fa76b72c848615836368d0577a108059e3070615c1e50551dc", size = 5843458, upload-time = "2026-02-18T11:20:07.737Z" },
{ url = "https://files.pythonhosted.org/packages/f9/07/431be5fc0d34d67dedc918a1a0f9f9aa7f3973557698fb7d35b5d136c45c/pillow_heif-1.2.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35a355df6024f09b0e46b56bb5805c275a8ca7dc67e1da2be245aee3a70c82ec", size = 5577826, upload-time = "2026-02-18T11:20:09.407Z" },
{ url = "https://files.pythonhosted.org/packages/e5/1b/ecdedae3225b75e870980bc6f505af2387e38f9ca85a110e59b4328e7263/pillow_heif-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:33d84eb1c40d9c63d2ea869e6290f5b59ebf4421ed16090796be60b8e3b2a061", size = 6885202, upload-time = "2026-02-18T11:20:11.421Z" },
{ url = "https://files.pythonhosted.org/packages/b6/27/a9a2ca1aa874166064ced4c8fbb03f106ef71b9eda0cc45688c3e12376d4/pillow_heif-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2522a54df26f996993189326208513a6c8458ac89de51644a89b19fcda712539", size = 6510193, upload-time = "2026-02-18T11:20:13.435Z" },
{ url = "https://files.pythonhosted.org/packages/41/63/7d1f5358b6a4c1214e5ef25e2166992381e6a30c0da933ab56ce2c278ea9/pillow_heif-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:0c965277fde806c7c628b16f9a45f4a7b10c32c390ce7d70c0572499a5d8426f", size = 5483265, upload-time = "2026-02-18T11:20:15.741Z" },
]
[[package]]
@@ -1397,16 +1399,16 @@ wheels = [
[[package]]
name = "pydantic-settings"
version = "2.13.0"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/a1/ae859ffac5a3338a66b74c5e29e244fd3a3cc483c89feaf9f56c39898d75/pydantic_settings-2.13.0.tar.gz", hash = "sha256:95d875514610e8595672800a5c40b073e99e4aae467fa7c8f9c263061ea2e1fe", size = 222450, upload-time = "2026-02-15T12:11:23.476Z" }
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/1a/dd1b9d7e627486cf8e7523d09b70010e05a4bc41414f4ae6ce184cf0afb6/pydantic_settings-2.13.0-py3-none-any.whl", hash = "sha256:d67b576fff39cd086b595441bf9c75d4193ca9c0ed643b90360694d0f1240246", size = 58429, upload-time = "2026-02-15T12:11:22.133Z" },
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
@@ -1450,7 +1452,7 @@ wheels = [
[[package]]
name = "pylint"
version = "4.0.4"
version = "4.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "astroid" },
@@ -1461,9 +1463,9 @@ dependencies = [
{ name = "platformdirs" },
{ name = "tomlkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/d2/b081da1a8930d00e3fc06352a1d449aaf815d4982319fab5d8cdb2e9ab35/pylint-4.0.4.tar.gz", hash = "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2", size = 1571735, upload-time = "2025-11-30T13:29:04.315Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/92/d40f5d937517cc489ad848fc4414ecccc7592e4686b9071e09e64f5e378e/pylint-4.0.4-py3-none-any.whl", hash = "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0", size = 536425, upload-time = "2025-11-30T13:29:02.53Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
]
[[package]]
@@ -1727,40 +1729,40 @@ wheels = [
[[package]]
name = "rich"
version = "14.3.2"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" },
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "ruff"
version = "0.15.1"
version = "0.15.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
{ url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" },
{ url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" },
{ url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" },
{ url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" },
{ url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" },
{ url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" },
{ url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" },
{ url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" },
{ url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" },
{ url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" },
{ url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" },
{ url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" },
{ url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" },
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
]
[[package]]