Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot]
0883e3ca13 fix(deps): update dependency authlib to v1.7.1 2026-05-09 13:13:18 +00:00
26 changed files with 87 additions and 318 deletions

View File

@@ -258,10 +258,6 @@
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,9 +11,7 @@
>
<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"
@@ -21,18 +19,10 @@
: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="[
@@ -94,20 +84,6 @@ 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) => {
@@ -124,4 +100,6 @@ watch(
listItem.value.labelId = listItem.value.label?.id || null;
},
);
const rail = ref(true);
</script>

View File

@@ -11,13 +11,11 @@
>
<v-row
v-touch="{
move: ({ originalEvent: { touches: [{ screenX, screenY }] } }) => {
move: ({ originalEvent: { touches: [{ screenX }] } }) => {
swipeInfo.touchendX = screenX;
swipeInfo.touchendY = screenY;
},
start: ({ originalEvent: { touches: [{ screenX, screenY }] } }) => {
start: ({ originalEvent: { touches: [{ screenX }] } }) => {
swipeInfo.touchstartX = screenX;
swipeInfo.touchstartY = screenY;
},
end: () => {
if (swiping < SWIPE_THRESHOLD) {
@@ -214,7 +212,6 @@ const emit = defineEmits<{
}>();
const SWIPE_THRESHOLD = 50;
const SCROLL_THRESHOLD = 50;
const { isRtl } = useRtl();
const i18n = useI18n();
@@ -267,22 +264,14 @@ function save() {
edit.value = false;
}
const swipeInfo: Ref<{ touchstartX?: number; touchendX?: number; touchstartY?: number; touchendY?: number }> = ref({});
const swipeInfo: Ref<{ touchstartX?: number; touchendX?: number }> = ref({ touchstartX: undefined, touchendX: undefined });
const swiping = computed(() => {
const { touchstartX, touchendX, touchstartY, touchendY } = swipeInfo.value ?? {};
const { touchstartX, touchendX } = swipeInfo.value ?? {};
if (touchstartX === undefined || touchendX === undefined) {
return 0;
}
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 delta = isRtl.value ? touchstartX - touchendX : touchendX - touchstartX;
return Math.min(Math.max(0, delta), 100);
});
const recipeList = computed<RecipeSummary[]>(() => {

View File

@@ -16,7 +16,6 @@
:items="units"
:label="$t('recipe.unit')"
:icon="$globals.icons.units"
:menu-props="{ location: menuDirection }"
style="flex: 3"
create
@create="createAssignUnit"
@@ -36,7 +35,6 @@
v-model:item-id="listItem.labelId!"
:items="labels"
:label="$t('shopping-list.label')"
:menu-props="{ location: menuDirection }"
style="flex: 1 0 200px"
/>
<BaseButton
@@ -77,9 +75,6 @@ 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,8 +93,4 @@ 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: 54,
progress: 55,
dir: "ltr",
pluralFoodHandling: "never",
},
@@ -143,7 +143,7 @@ export const LOCALES = [
{
name: "Italiano (Italian)",
value: "it-IT",
progress: 73,
progress: 72,
dir: "ltr",
pluralFoodHandling: "always",
},

View File

@@ -169,7 +169,7 @@
"token": "Nøgle",
"tuesday": "Tirsdag",
"type": "Type",
"undo": "Fortryd",
"undo": "Undo",
"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} blev krydset af"
"item-checked-off": "{item} was checked off"
},
"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": "Checked off {item}"
"item-checked-off": "{item} was checked off"
},
"sidebar": {
"all-recipes": "All Recipes",

View File

@@ -169,7 +169,7 @@
"token": "Token",
"tuesday": "Kedd",
"type": "Típus",
"undo": "Visszavonás",
"undo": "Undo",
"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} lett bejelölve"
"item-checked-off": "{item} was checked off"
},
"sidebar": {
"all-recipes": "Minden recept",

View File

@@ -169,7 +169,7 @@
"token": "Žeton",
"tuesday": "Torek",
"type": "Tip",
"undo": "Razveljavi",
"undo": "Undo",
"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": "Dodaj element",
"add-item": "Add item",
"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} je bil odkljukan"
"item-checked-off": "{item} was checked off"
},
"sidebar": {
"all-recipes": "Vsi recepti",

View File

@@ -3,11 +3,8 @@ export default defineNuxtRouteMiddleware((to) => {
const { user } = useMealieAuth();
const groupSlug = user.value?.groupSlug;
if (!groupSlug) {
// 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("/login", { redirectCode: 301 });
}
return navigateTo(`/g/${groupSlug}${to.fullPath}`, { redirectCode: 302 });
return navigateTo(`/g/${groupSlug}${to.fullPath}`, { redirectCode: 301 });
}
});

View File

@@ -14,7 +14,7 @@
</v-card-title>
<BaseDivider />
<v-card-text>
<v-form ref="form" @submit.prevent="requestLink()">
<v-form @submit.prevent="requestLink()">
<v-text-field
v-model="state.email"
:prepend-inner-icon="$globals.icons.email"
@@ -24,7 +24,6 @@
name="login"
:label="$t('user.email')"
type="text"
:rules="[validators.email]"
/>
<p class="text-center">
{{ $t('user.forgot-password-text') }}
@@ -64,15 +63,11 @@
<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,
@@ -89,27 +84,17 @@ 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 });
state.loading = false;
if (response?.status === 200) {
state.loading = false;
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,32 +198,15 @@ const recipeUrl = computed({
}
},
get() {
// 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;
return route.query.recipe_import_url as string | null;
},
});
onMounted(() => {
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.
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.
const importKeywordsAsTagsParam = route.query.use_keywords;
if (importKeywordsAsTagsParam === "1") {
importKeywordsAsTags.value = true;
@@ -240,9 +223,8 @@ onMounted(() => {
stayInEditMode.value = false;
}
// 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.
createByUrl(recipeUrl.value, importKeywordsAsTags.value, false);
return;
}
});

View File

@@ -211,7 +211,7 @@
</template>
<script setup lang="ts">
import { useDark, useSessionStorage, whenever } from "@vueuse/core";
import { useDark, 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,7 +225,6 @@ definePageMeta({
const isDark = useDark();
const router = useRouter();
const route = useRoute();
const i18n = useI18n();
const auth = useMealieAuth();
const { $appInfo, $axios } = useNuxtApp();
@@ -236,9 +235,6 @@ 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"),
});
@@ -263,29 +259,14 @@ 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 (defaultActivityRoute) {
if (!isDemo.value && isFirstLogin.value && auth.user.value?.admin) {
router.push("/admin/setup");
}
else if (defaultActivityRoute) {
router.push(defaultActivityRoute);
}
else {
@@ -335,13 +316,6 @@ 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 ref="form" @submit.prevent="requestLink()">
<v-form @submit.prevent="requestLink()">
<v-text-field
v-model="state.email"
:prepend-inner-icon="$globals.icons.email"
@@ -86,7 +86,6 @@ 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",
@@ -100,8 +99,6 @@ 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");
@@ -118,15 +115,6 @@ 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({
@@ -139,11 +127,12 @@ 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,7 +377,6 @@ 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,
@@ -392,7 +391,6 @@ function itemCheckedToast(item: ShoppingListItemOut) {
},
},
);
}, 500);
}
const {

View File

@@ -1,76 +0,0 @@
"""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,4 +1,3 @@
import string
from datetime import datetime
from sqlalchemy import Integer
@@ -7,12 +6,6 @@ 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)
@@ -27,7 +20,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).translate(_NORMALIZE_PUNCTUATION_TABLE).lower().strip()[:255]
return unidecode(val).lower().strip()[:255]
class BaseMixins:

View File

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

View File

@@ -31,13 +31,7 @@ def serve_manifest():
"action": "/r/create/url",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"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",
},
"params": {"text": "recipe_import_url"},
},
"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 NORMALIZE_PUNCTUATION, SqlAlchemyBase
from ...db.models._model_base import SqlAlchemyBase
from .._mealie import MealieModel, SearchType
@@ -16,7 +16,7 @@ class SearchFilter:
3. remove special characters from each non-literal search string
"""
punctuation = NORMALIZE_PUNCTUATION
punctuation = r"!\#$%&()*+,-./:;<=>?@[\\]^_`{|}~" # string.punctuation with ' & " removed
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. It is critical that you return the same number of ingredients as given and in the same order.
Parse ingredient strings into components. You will receive a list of one or more ingredients.
When parsing:
- If uncertain about quantity, unit, or food, put the entire string in the note field

View File

@@ -42,7 +42,7 @@ dependencies = [
"pydantic-settings==2.14.0",
"pillow-heif==1.3.0",
"pyjwt==2.12.1",
"openai==2.34.0",
"openai==2.33.0",
"typing-extensions==4.15.0",
"itsdangerous==2.2.0",
"yt-dlp==2026.3.17",

View File

@@ -3,12 +3,10 @@ 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
@@ -139,35 +137,3 @@ 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,4 +1,6 @@
import filecmp
import statistics
from pathlib import Path
from typing import Any
from sqlalchemy.orm import Session
@@ -30,6 +32,17 @@ 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()

8
uv.lock generated
View File

@@ -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.34.0" },
{ name = "openai", specifier = "==2.33.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.34.0"
version = "2.33.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/7b/89/f1e78f5f828f4e97a6ebca8f45c6b35667da12b074ac490dc8362b882279/openai-2.34.0.tar.gz", hash = "sha256:828b4efcbb126352c2b5eb97d33ae890c92a71ab72511aefc1b7fe64aeccb07b", size = 759556, upload-time = "2026-05-04T17:34:08.721Z" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]