Compare commits

...

10 Commits

Author SHA1 Message Date
Michael Genson
fdd17182d8 fix: Update OpenAI recipe parse prompt to return the same number of ingredients as given (#7604) 2026-05-10 22:24:47 -05:00
Michael Genson
d340fdd9df fix: Update backend normalization to match search normalization logic (#7603)
Co-authored-by: Copilot <copilot@github.com>
2026-05-10 21:23:57 -05:00
Zdenek Stursa
551a92a031 fix: redirect to login and validate input on password reset flow (#7521)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
Co-authored-by: Michael Genson <genson.michael@gmail.com>
2026-05-10 13:37:46 -05:00
Zdenek Stursa
8c06f49b02 fix: make PWA share target functional on Android Chrome (#7468)
Co-authored-by: Zdenek <tvuj-email@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:21:16 +00:00
Michael Genson
9fd3fbca8b feat: Improve new shopping list UI (#7600)
Co-authored-by: Copilot <copilot@github.com>
2026-05-10 13:15:20 -05:00
Hayden
a242aea9f2 chore(l10n): New Crowdin updates (#7589)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
2026-05-10 17:02:45 +00:00
Michael Genson
6e9ad5fef1 fix: Query Filter Builder "Advanced" bug (#7599)
Co-authored-by: Copilot <copilot@github.com>
2026-05-10 11:51:27 -05:00
mealie-actions[bot]
ee181a598b chore(l10n): Crowdin locale sync (#7595)
Co-authored-by: GitHub Action <action@github.com>
2026-05-10 03:10:29 +00:00
renovate[bot]
3a84b3f262 fix(deps): update dependency openai to v2.34.0 (#7594)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 17:48:09 +00:00
renovate[bot]
a616e14bf9 fix(deps): update dependency authlib to v1.7.1 (#7593)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 13:01:06 +00:00
26 changed files with 323 additions and 92 deletions

View File

@@ -258,6 +258,10 @@
class="text-center"
@update:model-value="setRightParenthesisValue(field, index, $event)"
/>
</v-col>
<!-- field actions -->
<v-col
v-if="!$vuetify.display.smAndDown || index === fields.length - 1"
:cols="config.items.fieldActions.cols(index)"
:sm="config.items.fieldActions.sm(index)"

View File

@@ -11,7 +11,9 @@
>
<div class="d-flex flex-column ga-3">
<v-card-actions class="pa-0">
<div class="position-relative" style="flex: 1;">
<InputLabelType
ref="foodInputRef"
v-model="listItem.food"
v-model:item-id="listItem.foodId!"
:items="foods"
@@ -19,10 +21,18 @@
:icon="$globals.icons.foods"
:style="rail ? 'margin-inline: 3px;' : undefined"
:search="rail"
:menu-props="{ location: menuDirection }"
create
@create="createAssignFood"
@focus="rail = false"
/>
<!-- Intercept clicks when collapsed so the drawer expands before the autocomplete opens -->
<div
v-if="rail"
class="position-absolute"
style="inset: 0; cursor: text;"
@click="expandAndFocus"
/>
</div>
<BaseButtonGroup
v-if="!rail"
:buttons="[
@@ -84,6 +94,20 @@ defineEmits<{
const { createAssignFood } = useShoppingListItemEditor(listItem);
const { smAndDown } = useDisplay();
const menuDirection = computed(() => smAndDown.value ? "top" : "bottom");
const foodInputRef = ref<{ focus: () => void } | null>(null);
const rail = ref(true);
async function expandAndFocus() {
rail.value = false;
await nextTick();
setTimeout(() => {
foodInputRef.value?.focus();
}, 200);
}
watch(
() => listItem.value.quantity,
(newQty) => {
@@ -100,6 +124,4 @@ watch(
listItem.value.labelId = listItem.value.label?.id || null;
},
);
const rail = ref(true);
</script>

View File

@@ -11,11 +11,13 @@
>
<v-row
v-touch="{
move: ({ originalEvent: { touches: [{ screenX }] } }) => {
move: ({ originalEvent: { touches: [{ screenX, screenY }] } }) => {
swipeInfo.touchendX = screenX;
swipeInfo.touchendY = screenY;
},
start: ({ originalEvent: { touches: [{ screenX }] } }) => {
start: ({ originalEvent: { touches: [{ screenX, screenY }] } }) => {
swipeInfo.touchstartX = screenX;
swipeInfo.touchstartY = screenY;
},
end: () => {
if (swiping < SWIPE_THRESHOLD) {
@@ -212,6 +214,7 @@ const emit = defineEmits<{
}>();
const SWIPE_THRESHOLD = 50;
const SCROLL_THRESHOLD = 50;
const { isRtl } = useRtl();
const i18n = useI18n();
@@ -264,14 +267,22 @@ function save() {
edit.value = false;
}
const swipeInfo: Ref<{ touchstartX?: number; touchendX?: number }> = ref({ touchstartX: undefined, touchendX: undefined });
const swipeInfo: Ref<{ touchstartX?: number; touchendX?: number; touchstartY?: number; touchendY?: number }> = ref({});
const swiping = computed(() => {
const { touchstartX, touchendX } = swipeInfo.value ?? {};
const { touchstartX, touchendX, touchstartY, touchendY } = swipeInfo.value ?? {};
if (touchstartX === undefined || touchendX === undefined) {
return 0;
}
const delta = isRtl.value ? touchstartX - touchendX : touchendX - touchstartX;
return Math.min(Math.max(0, delta), 100);
const deltaX = isRtl.value ? touchstartX - touchendX : touchendX - touchstartX;
// If there's significant vertical movement, treat as a scroll gesture and ignore
if (touchstartY !== undefined && touchendY !== undefined) {
const deltaY = Math.abs(touchendY - touchstartY);
if (deltaY > SCROLL_THRESHOLD) {
return 0;
}
}
return Math.min(Math.max(0, deltaX), 100);
});
const recipeList = computed<RecipeSummary[]>(() => {

View File

@@ -16,6 +16,7 @@
:items="units"
:label="$t('recipe.unit')"
:icon="$globals.icons.units"
:menu-props="{ location: menuDirection }"
style="flex: 3"
create
@create="createAssignUnit"
@@ -35,6 +36,7 @@
v-model:item-id="listItem.labelId!"
:items="labels"
:label="$t('shopping-list.label')"
:menu-props="{ location: menuDirection }"
style="flex: 1 0 200px"
/>
<BaseButton
@@ -75,6 +77,9 @@ const emit = defineEmits<{ (e: "save"): void }>();
const { assignLabelToFood, createAssignUnit } = useShoppingListItemEditor(listItem);
const { smAndDown } = useDisplay();
const menuDirection = computed(() => smAndDown.value ? "top" : "bottom");
function handleNoteKeyPress(event: KeyboardEvent) {
// Save on Enter
if (!event.shiftKey && event.key === "Enter") {

View File

@@ -93,4 +93,8 @@ function emitCreate() {
emit("create", searchInput.value);
autocompleteRef.value?.blur();
}
defineExpose({
focus: () => autocompleteRef.value?.focus(),
});
</script>

View File

@@ -10,7 +10,7 @@ export const LOCALES = [
{
name: "简体中文 (Chinese simplified)",
value: "zh-CN",
progress: 55,
progress: 54,
dir: "ltr",
pluralFoodHandling: "never",
},
@@ -143,7 +143,7 @@ export const LOCALES = [
{
name: "Italiano (Italian)",
value: "it-IT",
progress: 72,
progress: 73,
dir: "ltr",
pluralFoodHandling: "always",
},

View File

@@ -169,7 +169,7 @@
"token": "Nøgle",
"tuesday": "Tirsdag",
"type": "Type",
"undo": "Undo",
"undo": "Fortryd",
"update": "Gem",
"updated": "Ændret",
"upload": "Upload",
@@ -943,7 +943,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Er du sikker på, at du vil fjerne markeringen af alle elementer?",
"are-you-sure-you-want-to-delete-checked-items": "Er du sikker på, at du vil sletter de valgte elementer?",
"no-shopping-lists-found": "Ingen Indkøbslister fundet",
"item-checked-off": "{item} was checked off"
"item-checked-off": "{item} blev krydset af"
},
"sidebar": {
"all-recipes": "Alle opskrifter",

View File

@@ -943,7 +943,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Are you sure you want to uncheck all items?",
"are-you-sure-you-want-to-delete-checked-items": "Are you sure you want to delete all checked items?",
"no-shopping-lists-found": "No Shopping Lists Found",
"item-checked-off": "{item} was checked off"
"item-checked-off": "Checked off {item}"
},
"sidebar": {
"all-recipes": "All Recipes",

View File

@@ -169,7 +169,7 @@
"token": "Token",
"tuesday": "Kedd",
"type": "Típus",
"undo": "Undo",
"undo": "Visszavonás",
"update": "Frissítés",
"updated": "Frissítve",
"upload": "Feltöltés",
@@ -943,7 +943,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Biztos, hogy minden elem kijelölését visszavonja?",
"are-you-sure-you-want-to-delete-checked-items": "Biztosan törölni akarja az összes bejelölt elemet?",
"no-shopping-lists-found": "Nem találhatók bevásárlólisták",
"item-checked-off": "{item} was checked off"
"item-checked-off": "{item} lett bejelölve"
},
"sidebar": {
"all-recipes": "Minden recept",

View File

@@ -169,7 +169,7 @@
"token": "Žeton",
"tuesday": "Torek",
"type": "Tip",
"undo": "Undo",
"undo": "Razveljavi",
"update": "Posodobi",
"updated": "Posodobljen",
"upload": "Naloži",
@@ -917,7 +917,7 @@
"quantity": "Količina: {0}",
"shopping-list": "Nakupovalni seznam",
"shopping-lists": "Nakupovalni seznami",
"add-item": "Add item",
"add-item": "Dodaj element",
"food": "Živilo",
"note": "Opomba",
"label": "Oznaka",
@@ -943,7 +943,7 @@
"are-you-sure-you-want-to-uncheck-all-items": "Ali res ne želite izbrati vseh elementov?",
"are-you-sure-you-want-to-delete-checked-items": "Ali ste prepričani, da želite izbrisati vse izbrane elemente?",
"no-shopping-lists-found": "Ni nakupovalnih seznamov",
"item-checked-off": "{item} was checked off"
"item-checked-off": "{item} je bil odkljukan"
},
"sidebar": {
"all-recipes": "Vsi recepti",

View File

@@ -3,8 +3,11 @@ export default defineNuxtRouteMiddleware((to) => {
const { user } = useMealieAuth();
const groupSlug = user.value?.groupSlug;
if (!groupSlug) {
return navigateTo("/login", { redirectCode: 301 });
// Preserve the full path (including recipe_import_url query param) so the
// login page can redirect back here after successful authentication.
const redirect = encodeURIComponent(to.fullPath);
return navigateTo(`/login?redirect=${redirect}`, { redirectCode: 302 });
}
return navigateTo(`/g/${groupSlug}${to.fullPath}`, { redirectCode: 301 });
return navigateTo(`/g/${groupSlug}${to.fullPath}`, { redirectCode: 302 });
}
});

View File

@@ -14,7 +14,7 @@
</v-card-title>
<BaseDivider />
<v-card-text>
<v-form @submit.prevent="requestLink()">
<v-form ref="form" @submit.prevent="requestLink()">
<v-text-field
v-model="state.email"
:prepend-inner-icon="$globals.icons.email"
@@ -24,6 +24,7 @@
name="login"
:label="$t('user.email')"
type="text"
:rules="[validators.email]"
/>
<p class="text-center">
{{ $t('user.forgot-password-text') }}
@@ -63,11 +64,15 @@
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { validators } from "~/composables/use-validators";
import type { VForm } from "~/types/auto-forms";
definePageMeta({
layout: "basic",
});
const form = ref<VForm | null>(null);
const state = reactive({
email: "",
loading: false,
@@ -84,17 +89,27 @@ useSeoMeta({
const api = useUserApi();
async function requestLink() {
if (!form.value) {
return;
};
const { valid } = await form.value.validate();
if (!valid) {
return;
};
state.loading = true;
// TODO: Fix Response to send meaningful error
const { response } = await api.email.sendForgotPassword({ email: state.email });
if (response?.status === 200) {
state.loading = false;
if (response?.status === 200) {
state.error = false;
alert.success(i18n.t("profile.email-sent"));
await navigateTo("/login");
}
else {
state.loading = false;
state.error = true;
alert.error(i18n.t("profile.error-sending-email"));
}

View File

@@ -198,15 +198,32 @@ const recipeUrl = computed({
}
},
get() {
return route.query.recipe_import_url as string | null;
// Prefer the 'url' share field (recipe_import_url, populated by Chrome when
// sharing a page URL). Fall back to the 'text' share field (recipe_import_text)
// for apps that share URLs as plain text, but only when the text value is
// actually a valid http/https URL — shared text can be arbitrary.
const urlFromField = route.query.recipe_import_url as string | null;
if (urlFromField) {
return urlFromField;
}
const textFromField = route.query.recipe_import_text as string | null;
if (textFromField) {
try {
const parsed = new URL(textFromField);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
return textFromField;
}
}
catch { /* not a URL, ignore */ }
}
return null;
},
});
onMounted(() => {
if (recipeUrl.value && recipeUrl.value.includes("https")) {
// Check if we have a query params for using keywords as tags or staying in edit mode.
// We don't use these in the app anymore, but older automations such as Bookmarklet might still use them,
// and they're easy enough to support.
if (recipeUrl.value) {
// Apply legacy query params for older automations such as the Bookmarklet.
// These are no longer used by the app itself but are easy to keep supporting.
const importKeywordsAsTagsParam = route.query.use_keywords;
if (importKeywordsAsTagsParam === "1") {
importKeywordsAsTags.value = true;
@@ -223,8 +240,9 @@ onMounted(() => {
stayInEditMode.value = false;
}
createByUrl(recipeUrl.value, importKeywordsAsTags.value, false);
return;
// The URL is pre-filled via the recipeUrl computed property.
// Do not auto-submit: the user should review the import options and
// confirm by clicking the submit button.
}
});

View File

@@ -211,7 +211,7 @@
</template>
<script setup lang="ts">
import { useDark, whenever } from "@vueuse/core";
import { useDark, useSessionStorage, whenever } from "@vueuse/core";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { usePasswordField } from "~/composables/use-passwords";
import { alert } from "~/composables/use-toast";
@@ -225,6 +225,7 @@ definePageMeta({
const isDark = useDark();
const router = useRouter();
const route = useRoute();
const i18n = useI18n();
const auth = useMealieAuth();
const { $appInfo, $axios } = useNuxtApp();
@@ -235,6 +236,9 @@ const isFirstLogin = ref(false);
const activityPreferences = useUserActivityPreferences();
const { getDefaultActivityRoute } = useDefaultActivity();
// Survives the page reload that happens during OIDC redirect
const pendingShareRedirect = useSessionStorage<string | null>("pwa_share_redirect", null);
useSeoMeta({
title: i18n.t("user.login"),
});
@@ -259,14 +263,29 @@ useAsyncData(useAsyncKey(), async () => {
whenever(
() => loggedIn.value && groupSlug.value,
() => {
// First-login setup always takes priority
if (!isDemo.value && isFirstLogin.value && auth.user.value?.admin) {
router.push("/admin/setup");
return;
}
// After login, honour a pending PWA share redirect.
// The redirect param can arrive via the URL query string (password login)
// or via sessionStorage (OIDC login, where the OIDC provider reload clears
// the query string).
const redirectFromQuery = route.query.redirect as string | undefined;
const redirectTarget = redirectFromQuery ?? pendingShareRedirect.value;
if (redirectTarget && redirectTarget.startsWith("/")) {
pendingShareRedirect.value = null;
router.push(redirectTarget);
return;
}
const defaultActivityRoute = getDefaultActivityRoute(
activityPreferences.value.defaultActivity,
groupSlug.value,
);
if (!isDemo.value && isFirstLogin.value && auth.user.value?.admin) {
router.push("/admin/setup");
}
else if (defaultActivityRoute) {
if (defaultActivityRoute) {
router.push(defaultActivityRoute);
}
else {
@@ -316,6 +335,13 @@ async function oidcAuthenticate(callback = false) {
oidcLoggingIn.value = false;
}
else {
// Save any pending PWA share redirect before leaving for the OIDC provider.
// The OIDC callback reloads the page, which clears the query string, so we
// persist the target in sessionStorage and restore it after login.
const redirectTarget = route.query.redirect as string | undefined;
if (redirectTarget && redirectTarget.startsWith("/")) {
pendingShareRedirect.value = redirectTarget;
}
navigateTo("/api/auth/oauth", { external: true }); // start the redirect process
}
}

View File

@@ -14,7 +14,7 @@
</v-card-title>
<BaseDivider />
<v-card-text>
<v-form @submit.prevent="requestLink()">
<v-form ref="form" @submit.prevent="requestLink()">
<v-text-field
v-model="state.email"
:prepend-inner-icon="$globals.icons.email"
@@ -86,6 +86,7 @@ import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { validators } from "@/composables/use-validators";
import { useRouteQuery } from "~/composables/use-router";
import type { VForm } from "~/types/auto-forms";
definePageMeta({
layout: "basic",
@@ -99,6 +100,8 @@ const state = reactive({
error: false,
});
const form = ref<VForm | null>(null);
const i18n = useI18n();
const passwordMatch = () => state.password === state.passwordConfirm || i18n.t("user.password-must-match");
@@ -115,6 +118,15 @@ const token = useRouteQuery("token", "");
// API
const api = useUserApi();
async function requestLink() {
if (!form.value) {
return;
};
const { valid } = await form.value.validate();
if (!valid) {
return;
};
state.loading = true;
// TODO: Fix Response to send meaningful error
const { response } = await api.users.resetPassword({
@@ -127,12 +139,11 @@ async function requestLink() {
state.loading = false;
if (response?.status === 200) {
state.loading = false;
state.error = false;
alert.success(i18n.t("user.password-updated"));
await navigateTo("/login");
}
else {
state.loading = false;
state.error = true;
alert.error(i18n.t("events.something-went-wrong"));
}

View File

@@ -377,6 +377,7 @@ const { store: allUnits } = useUnitStore();
const { store: allFoods } = useFoodStore();
function itemCheckedToast(item: ShoppingListItemOut) {
setTimeout(() => {
alert.info(
i18n.t("shopping-list.item-checked-off", { item: item.food?.name || item.note || i18n.t("recipe.ingredient") }),
undefined,
@@ -391,6 +392,7 @@ function itemCheckedToast(item: ShoppingListItemOut) {
},
},
);
}, 500);
}
const {

View File

@@ -0,0 +1,76 @@
"""more aggresive normalization
Revision ID: c7427796f7b6
Revises: 4395a04f7784
Create Date: 2026-05-10 18:44:53.159775
"""
from sqlalchemy import orm, text
from alembic import op
from mealie.db.models._model_base import SqlAlchemyBase
# revision identifiers, used by Alembic.
revision = "c7427796f7b6"
down_revision: str | None = "4395a04f7784"
branch_labels: str | tuple[str, ...] | None = None
depends_on: str | tuple[str, ...] | None = None
def _update_table(session: orm.Session, table: str, columns: list[str], source_columns: list[str]) -> None:
"""Re-normalize all rows in `table`, reading raw values from `source_columns` and writing to `columns`."""
rows = session.execute(text(f"SELECT id, {', '.join(source_columns)} FROM {table}")).fetchall()
for row in rows:
id_ = row[0]
updates = {}
for col, src in zip(columns, source_columns, strict=True):
val = row[source_columns.index(src) + 1]
updates[col] = SqlAlchemyBase.normalize(val) if val is not None else None
set_clause = ", ".join(f"{col} = :{col}" for col in columns)
session.execute(text(f"UPDATE {table} SET {set_clause} WHERE id = :id"), {**updates, "id": id_})
session.commit()
def update_normalization() -> None:
bind = op.get_bind()
session = orm.Session(bind=bind)
# recipes: name_normalized, description_normalized
_update_table(session, "recipes", ["name_normalized", "description_normalized"], ["name", "description"])
# recipe ingredients: note_normalized, original_text_normalized
_update_table(
session,
"recipes_ingredients",
["note_normalized", "original_text_normalized"],
["note", "original_text"],
)
# ingredient units: name, plural_name, abbreviation, plural_abbreviation
_update_table(
session,
"ingredient_units",
["name_normalized", "plural_name_normalized", "abbreviation_normalized", "plural_abbreviation_normalized"],
["name", "plural_name", "abbreviation", "plural_abbreviation"],
)
# ingredient foods: name, plural_name
_update_table(session, "ingredient_foods", ["name_normalized", "plural_name_normalized"], ["name", "plural_name"])
# unit aliases
_update_table(session, "ingredient_units_aliases", ["name_normalized"], ["name"])
# food aliases
_update_table(session, "ingredient_foods_aliases", ["name_normalized"], ["name"])
def upgrade():
# no table changes, this is a data migration
update_normalization()
def downgrade():
pass

View File

@@ -1,3 +1,4 @@
import string
from datetime import datetime
from sqlalchemy import Integer
@@ -6,6 +7,12 @@ from text_unidecode import unidecode
from ._model_utils.datetime import NaiveDateTime, get_utc_now
# Punctuation characters replaced with spaces during text normalization.
# Mirrors SearchFilter in query_search.py: string.punctuation minus apostrophe and
# double-quote, which are reserved for quoted literal searches.
NORMALIZE_PUNCTUATION = string.punctuation.replace("'", "").replace('"', "")
_NORMALIZE_PUNCTUATION_TABLE = str.maketrans(NORMALIZE_PUNCTUATION, " " * len(NORMALIZE_PUNCTUATION))
class SqlAlchemyBase(DeclarativeBase):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
@@ -20,7 +27,7 @@ class SqlAlchemyBase(DeclarativeBase):
def normalize(cls, val: str) -> str:
# We cap the length to 255 to prevent indexes from being too long; see:
# https://www.postgresql.org/docs/current/btree.html
return unidecode(val).lower().strip()[:255]
return unidecode(val).translate(_NORMALIZE_PUNCTUATION_TABLE).lower().strip()[:255]
class BaseMixins:

View File

@@ -358,7 +358,7 @@
"aliases": [],
"description": "",
"name": "λείο κεφαλωτό μαρούλι",
"plural_name": "butter lettuce"
"plural_name": "λείο κεφαλωτό μαρούλι"
},
"hash brown": {
"aliases": [],

View File

@@ -31,7 +31,13 @@ def serve_manifest():
"action": "/r/create/url",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {"text": "recipe_import_url"},
"params": {
# 'url' is the field Chrome Android populates when sharing a page URL
"url": "recipe_import_url",
# 'text' is used by apps that share URLs as plain text; mapped to a
# separate param so the page can fall back to it when 'url' is absent
"text": "recipe_import_text",
},
},
"icons": [
{"src": "/icons/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},

View File

@@ -4,7 +4,7 @@ from sqlalchemy import Select
from sqlalchemy.orm import Session
from text_unidecode import unidecode
from ...db.models._model_base import SqlAlchemyBase
from ...db.models._model_base import NORMALIZE_PUNCTUATION, SqlAlchemyBase
from .._mealie import MealieModel, SearchType
@@ -16,7 +16,7 @@ class SearchFilter:
3. remove special characters from each non-literal search string
"""
punctuation = r"!\#$%&()*+,-./:;<=>?@[\\]^_`{|}~" # string.punctuation with ' & " removed
punctuation = NORMALIZE_PUNCTUATION
quoted_regex = re.compile(r"""(["'])(?:(?=(\\?))\2.)*?\1""")
remove_quotes_regex = re.compile(r"""['"](.*)['"]""")

View File

@@ -1,4 +1,4 @@
Parse ingredient strings into components. You will receive a list of one or more ingredients.
Parse ingredient strings into components. You will receive a list of one or more ingredients. It is critical that you return the same number of ingredients as given and in the same order.
When parsing:
- If uncertain about quantity, unit, or food, put the entire string in the note field

View File

@@ -36,13 +36,13 @@ dependencies = [
"isodate==0.7.2",
"text-unidecode==1.3",
"rapidfuzz==3.14.5",
"authlib==1.7.0",
"authlib==1.7.1",
"html2text==2025.4.15",
"paho-mqtt==1.6.1",
"pydantic-settings==2.14.0",
"pillow-heif==1.3.0",
"pyjwt==2.12.1",
"openai==2.33.0",
"openai==2.34.0",
"typing-extensions==4.15.0",
"itsdangerous==2.2.0",
"yt-dlp==2026.3.17",

View File

@@ -3,10 +3,12 @@ from datetime import UTC, datetime
import pytest
from sqlalchemy.orm import Session
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe_ingredient import IngredientUnit, SaveIngredientUnit
from mealie.schema.response.pagination import OrderDirection, PaginationQuery
from mealie.schema.response.query_search import SearchFilter
from mealie.schema.user.user import GroupBase
from tests.utils.factories import random_int, random_string
@@ -137,3 +139,35 @@ def test_random_order_search(
pagination.pagination_seed = str(datetime.now(UTC))
random_ordered.append(repo.page_all(pagination, search="unit").items)
assert not all(i == random_ordered[0] for i in random_ordered)
@pytest.mark.parametrize(
"name, expected",
[
("Gluten-Free Bread", "gluten free bread"),
("Mac & Cheese", "mac cheese"),
("Chicken/Rice Bowl", "chicken rice bowl"),
("Rátàtôuile", "ratatouile"),
("Mom's Pasta", "mom's pasta"),
],
)
def test_normalize_strips_punctuation(name: str, expected: str):
assert SqlAlchemyBase.normalize(name) == expected
@pytest.mark.parametrize(
"name",
[
"Gluten-Free Bread",
"Mac & Cheese",
"Chicken/Rice Bowl",
"Rátàtôuile",
"Mom's Pasta",
],
)
def test_search_normalize_symmetric_with_store_normalize(name: str):
"""SearchFilter._normalize_search and SqlAlchemyBase.normalize must produce the same
output for the same input, otherwise stored values and search queries won't match."""
stored = SqlAlchemyBase.normalize(name)
searched = SearchFilter._normalize_search(name, normalize_characters=True)
assert stored == searched, f"Normalization mismatch for {name!r}: stored={stored!r}, searched={searched!r}"

View File

@@ -1,6 +1,4 @@
import filecmp
import statistics
from pathlib import Path
from typing import Any
from sqlalchemy.orm import Session
@@ -32,17 +30,6 @@ def dict_sorter(d: dict) -> Any:
return next((d[key] for key in possible_keys if d.get(key)), 1)
# For Future Use
def match_file_tree(path_a: Path, path_b: Path):
if path_a.is_dir() and path_b.is_dir():
for a_file in path_a.iterdir():
b_file = path_b.joinpath(a_file.name)
assert b_file.exists()
match_file_tree(a_file, b_file)
else:
assert filecmp.cmp(path_a, path_b)
def test_database_backup():
backup_v2 = BackupV2()
path_to_backup = backup_v2.backup()

16
uv.lock generated
View File

@@ -126,15 +126,15 @@ wheels = [
[[package]]
name = "authlib"
version = "1.7.0"
version = "1.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "joserfc" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/82/4d0603f30c1b4629b1f091bb266b0d7986434891d6940a8c87f8098db24e/authlib-1.7.0.tar.gz", hash = "sha256:b3e326c9aa9cc3ea95fe7d89fd880722d3608da4d00e8a27e061e64b48d801d5", size = 175890, upload-time = "2026-04-18T11:00:28.559Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3c/f2/e05664d5275ce811fd4e9df0a2b3f0086ee19a8a80358d95499fa82fd50c/authlib-1.7.1.tar.gz", hash = "sha256:8c09b0f9d080c823e594b52316af70f79a1fa4eed64d0363a076233c04ef063a", size = 175884, upload-time = "2026-05-04T08:11:25.033Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/48/c954218b2a250e23f178f10167c4173fecb5a75d2c206f0a67ba58006c26/authlib-1.7.0-py2.py3-none-any.whl", hash = "sha256:e36817afb02f6f0b6bf55f150782499ddd6ddf44b402bb055d3263cc65ac9ae0", size = 258779, upload-time = "2026-04-18T11:00:26.64Z" },
{ url = "https://files.pythonhosted.org/packages/e0/82/730650ee5e5b598b7bfdc291b784bc2f6fe02a5671695485403365101088/authlib-1.7.1-py2.py3-none-any.whl", hash = "sha256:8470f4aa6b5590ac41bd81d6e6ee12448ce36a0da0af19bbed69fb53fb4e8ad9", size = 258826, upload-time = "2026-05-04T08:11:23.208Z" },
]
[[package]]
@@ -982,7 +982,7 @@ requires-dist = [
{ name = "aniso8601", specifier = "==10.0.1" },
{ name = "appdirs", specifier = "==1.4.4" },
{ name = "apprise", specifier = "==1.10.0" },
{ name = "authlib", specifier = "==1.7.0" },
{ name = "authlib", specifier = "==1.7.1" },
{ name = "bcrypt", specifier = "==5.0.0" },
{ name = "beautifulsoup4", specifier = "==4.14.3" },
{ name = "extruct", specifier = "==0.18.0" },
@@ -995,7 +995,7 @@ requires-dist = [
{ name = "itsdangerous", specifier = "==2.2.0" },
{ name = "jinja2", specifier = "==3.1.6" },
{ name = "lxml", specifier = "==6.1.0" },
{ name = "openai", specifier = "==2.33.0" },
{ name = "openai", specifier = "==2.34.0" },
{ name = "orjson", specifier = "==3.11.8" },
{ name = "paho-mqtt", specifier = "==1.6.1" },
{ name = "pillow", specifier = "==12.2.0" },
@@ -1224,7 +1224,7 @@ wheels = [
[[package]]
name = "openai"
version = "2.33.0"
version = "2.34.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -1236,9 +1236,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/ee/d056c82f63c05f06baac0cffb4a90952d8274f90c49dfe244f20497b9bbd/openai-2.33.0.tar.gz", hash = "sha256:f850c435e2a4685bba3295bd54912dd26315d9c1b7733068186134d6e0599f9a", size = 693254, upload-time = "2026-04-28T14:04:42.428Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/89/f1e78f5f828f4e97a6ebca8f45c6b35667da12b074ac490dc8362b882279/openai-2.34.0.tar.gz", hash = "sha256:828b4efcbb126352c2b5eb97d33ae890c92a71ab72511aefc1b7fe64aeccb07b", size = 759556, upload-time = "2026-05-04T17:34:08.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/32/37734d769bc8b42e4938785313cc05aade6cb0fa72479d3220a0d61a4e78/openai-2.33.0-py3-none-any.whl", hash = "sha256:03ac37d70e8c9e3a8124214e3afa785e2cbc12e627fbd98177a086ef2fd87ad5", size = 1162695, upload-time = "2026-04-28T14:04:40.482Z" },
{ url = "https://files.pythonhosted.org/packages/f2/40/f090499f10514515081d09cb9da09f25b821eb20497e9423afe4f07b4ecf/openai-2.34.0-py3-none-any.whl", hash = "sha256:c996a71b1a210f3569844572ad4c609307e978515fb76877cf449b72596e549e", size = 1316535, upload-time = "2026-05-04T17:34:06.773Z" },
]
[[package]]