mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-05-11 04:23:50 -04:00
Compare commits
10 Commits
renovate/a
...
mealie-nex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdd17182d8 | ||
|
|
d340fdd9df | ||
|
|
551a92a031 | ||
|
|
8c06f49b02 | ||
|
|
9fd3fbca8b | ||
|
|
a242aea9f2 | ||
|
|
6e9ad5fef1 | ||
|
|
ee181a598b | ||
|
|
3a84b3f262 | ||
|
|
a616e14bf9 |
@@ -258,11 +258,15 @@
|
||||
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)"
|
||||
:class="config.col.class"
|
||||
>
|
||||
>
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
{
|
||||
|
||||
@@ -11,18 +11,28 @@
|
||||
>
|
||||
<div class="d-flex flex-column ga-3">
|
||||
<v-card-actions class="pa-0">
|
||||
<InputLabelType
|
||||
v-model="listItem.food"
|
||||
v-model:item-id="listItem.foodId!"
|
||||
:items="foods"
|
||||
:label="rail ? $t('shopping-list.add-item') : $t('shopping-list.food')"
|
||||
:icon="$globals.icons.foods"
|
||||
:style="rail ? 'margin-inline: 3px;' : undefined"
|
||||
:search="rail"
|
||||
create
|
||||
@create="createAssignFood"
|
||||
@focus="rail = false"
|
||||
/>
|
||||
<div class="position-relative" style="flex: 1;">
|
||||
<InputLabelType
|
||||
ref="foodInputRef"
|
||||
v-model="listItem.food"
|
||||
v-model:item-id="listItem.foodId!"
|
||||
:items="foods"
|
||||
:label="rail ? $t('shopping-list.add-item') : $t('shopping-list.food')"
|
||||
:icon="$globals.icons.foods"
|
||||
:style="rail ? 'margin-inline: 3px;' : undefined"
|
||||
:search="rail"
|
||||
:menu-props="{ location: menuDirection }"
|
||||
create
|
||||
@create="createAssignFood"
|
||||
/>
|
||||
<!-- 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>
|
||||
|
||||
@@ -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[]>(() => {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -93,4 +93,8 @@ function emitCreate() {
|
||||
emit("create", searchInput.value);
|
||||
autocompleteRef.value?.blur();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus: () => autocompleteRef.value?.focus(),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -377,20 +377,22 @@ const { store: allUnits } = useUnitStore();
|
||||
const { store: allFoods } = useFoodStore();
|
||||
|
||||
function itemCheckedToast(item: ShoppingListItemOut) {
|
||||
alert.info(
|
||||
i18n.t("shopping-list.item-checked-off", { item: item.food?.name || item.note || i18n.t("recipe.ingredient") }),
|
||||
undefined,
|
||||
{
|
||||
timeout: 4000,
|
||||
action: {
|
||||
message: i18n.t("general.undo"),
|
||||
onClick: () => {
|
||||
item.checked = false;
|
||||
shoppingListPage.saveListItem(item);
|
||||
setTimeout(() => {
|
||||
alert.info(
|
||||
i18n.t("shopping-list.item-checked-off", { item: item.food?.name || item.note || i18n.t("recipe.ingredient") }),
|
||||
undefined,
|
||||
{
|
||||
timeout: 4000,
|
||||
action: {
|
||||
message: i18n.t("general.undo"),
|
||||
onClick: () => {
|
||||
item.checked = false;
|
||||
shoppingListPage.saveListItem(item);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const {
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
"aliases": [],
|
||||
"description": "",
|
||||
"name": "λείο κεφαλωτό μαρούλι",
|
||||
"plural_name": "butter lettuce"
|
||||
"plural_name": "λείο κεφαλωτό μαρούλι"
|
||||
},
|
||||
"hash brown": {
|
||||
"aliases": [],
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"""['"](.*)['"]""")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -42,7 +42,7 @@ dependencies = [
|
||||
"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",
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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()
|
||||
|
||||
8
uv.lock
generated
8
uv.lock
generated
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user