diff --git a/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue b/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue index ed508974a..e95837b40 100644 --- a/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue +++ b/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue @@ -56,6 +56,7 @@ variant="solo" return-object :items="units || []" + :custom-filter="normalizeFilter" item-title="name" class="mx-1" :placeholder="$t('recipe.choose-unit')" @@ -114,6 +115,7 @@ variant="solo" return-object :items="foods || []" + :custom-filter="normalizeFilter" item-title="name" class="mx-1 py-0" :placeholder="$t('recipe.choose-food')" @@ -171,6 +173,7 @@ variant="solo" return-object :items="search.data.value || []" + :custom-filter="normalizeFilter" item-title="name" class="mx-1 py-0" :placeholder="$t('search.type-to-search')" @@ -225,6 +228,7 @@ import { ref, computed, reactive, toRefs } from "vue"; import { useDisplay } from "vuetify"; import { useI18n } from "vue-i18n"; import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store"; +import { normalizeFilter } from "~/composables/use-utils"; import { useNuxtApp } from "#app"; import type { RecipeIngredient } from "~/lib/api/types/recipe"; import { usePublicExploreApi, useUserApi } from "~/composables/api"; diff --git a/frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue b/frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue index 6706eb582..8b63e1bc1 100644 --- a/frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue +++ b/frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue @@ -4,6 +4,7 @@ v-bind="inputAttrs" v-model:search="searchInput" :items="items" + :custom-filter="normalizeFilter" :label="label" chips closable-chips @@ -52,6 +53,7 @@ import type { RecipeTool } from "~/lib/api/types/admin"; import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated"; import type { HouseholdSummary } from "~/lib/api/types/household"; import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store"; +import { normalizeFilter } from "~/composables/use-utils"; interface Props { selectorType: RecipeOrganizer; diff --git a/frontend/components/global/InputLabelType.vue b/frontend/components/global/InputLabelType.vue index 769a58f02..f82c269bf 100644 --- a/frontend/components/global/InputLabelType.vue +++ b/frontend/components/global/InputLabelType.vue @@ -7,6 +7,7 @@ item-title="name" return-object :items="items" + :custom-filter="normalizeFilter" :prepend-icon="icon || $globals.icons.tags" auto-select-first clearable @@ -52,6 +53,7 @@ import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels"; import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe"; +import { normalizeFilter } from "~/composables/use-utils"; export default defineNuxtComponent({ props: { @@ -122,6 +124,7 @@ export default defineNuxtComponent({ itemIdVal, searchInput, emitCreate, + normalizeFilter, }; }, }); diff --git a/frontend/components/global/LanguageDialog.vue b/frontend/components/global/LanguageDialog.vue index 6e3a51d1b..d8e286cd7 100644 --- a/frontend/components/global/LanguageDialog.vue +++ b/frontend/components/global/LanguageDialog.vue @@ -9,6 +9,7 @@ import { useLocales } from "~/composables/use-locales"; +import { normalizeFilter } from "~/composables/use-utils"; export default defineNuxtComponent({ props: { @@ -83,6 +85,7 @@ export default defineNuxtComponent({ locale, selectedLocale, onLocaleSelect, + normalizeFilter, }; }, }); diff --git a/frontend/composables/use-utils.test.ts b/frontend/composables/use-utils.test.ts new file mode 100644 index 000000000..161ed8678 --- /dev/null +++ b/frontend/composables/use-utils.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vitest"; +import { normalize, normalizeFilter } from "./use-utils"; + +describe("test normalize", () => { + test("base case", () => { + expect(normalize("banana")).not.toEqual(normalize("Potatoes")); + }); + test("diacritics", () => { + expect(normalize("Rátàtôuile")).toEqual("ratatouile"); + }); + test("ligatures", () => { + expect(normalize("IJ")).toEqual("ij"); + expect(normalize("æ")).toEqual("ae"); + expect(normalize("œ")).toEqual("oe"); + expect(normalize("ff")).toEqual("ff"); + expect(normalize("fi")).toEqual("fi"); + expect(normalize("st")).toEqual("st"); + }); +}); + +describe("test normalize filter", () => { + test("base case", () => { + const patternA = "Escargots persillés"; + const patternB = "persillés"; + + expect(normalizeFilter(patternA, patternB)).toBeTruthy(); + expect(normalizeFilter(patternB, patternA)).toBeFalsy(); + }); + test("normalize", () => { + const value = "Cœur de bœuf"; + const query = "coeur"; + expect(normalizeFilter(value, query)).toBeTruthy(); + }); +}); diff --git a/frontend/composables/use-utils.ts b/frontend/composables/use-utils.ts index 8f5f845cf..b54cff0f3 100644 --- a/frontend/composables/use-utils.ts +++ b/frontend/composables/use-utils.ts @@ -1,4 +1,5 @@ import { useDark, useToggle } from "@vueuse/core"; +import type { FilterFunction } from "vuetify"; export const useToggleDarkMode = () => { const isDark = useDark(); @@ -18,6 +19,38 @@ export const titleCase = function (str: string) { .join(" "); }; +const replaceAllBuilder = (map: Map): ((str: string) => string) => { + const re = new RegExp(Array.from(map.keys()).join("|"), "gi"); + return str => str.replace(re, matched => map.get(matched)!); +}; + +const normalizeLigatures = replaceAllBuilder(new Map([ + ["œ", "oe"], + ["æ", "ae"], + ["ij", "ij"], + ["ff", "ff"], + ["fi", "fi"], + ["fl", "fl"], + ["st", "st"], +])); + +export const normalize = (str: string) => { + if (!str) { + return ""; + } + + let normalized = str.normalize("NFKD").toLowerCase(); + normalized = normalized.replace(/\p{Diacritic}/gu, ""); + normalized = normalizeLigatures(normalized); + return normalized; +}; + +export const normalizeFilter: FilterFunction = (value: string, query: string) => { + const normalizedValue = normalize(value); + const normalizeQuery = normalize(query); + return normalizedValue.includes(normalizeQuery); +}; + export function uuid4() { return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c => (parseInt(c) ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (parseInt(c) / 4)))).toString(16), diff --git a/frontend/pages/group/data/foods.vue b/frontend/pages/group/data/foods.vue index 49a60ed24..538223f8c 100644 --- a/frontend/pages/group/data/foods.vue +++ b/frontend/pages/group/data/foods.vue @@ -16,6 +16,7 @@ v-model="fromFood" return-object :items="foods" + :custom-filter="normalizeFilter" item-title="name" :label="$t('data-pages.foods.source-food')" /> @@ -23,6 +24,7 @@ v-model="toFood" return-object :items="foods" + :custom-filter="normalizeFilter" item-title="name" :label="$t('data-pages.foods.target-food')" /> @@ -51,6 +53,7 @@ v-model="locale" :items="locales" item-title="name" + :custom-filter="normalizeFilter" :label="$t('data-pages.select-language')" class="my-3" hide-details @@ -108,6 +111,7 @@ v-model="createTarget.labelId" clearable :items="allLabels" + :custom-filter="normalizeFilter" item-value="id" item-title="name" :label="$t('data-pages.foods.food-label')" @@ -164,6 +168,7 @@ v-model="editTarget.labelId" clearable :items="allLabels" + :custom-filter="normalizeFilter" item-value="id" item-title="name" :label="$t('data-pages.foods.food-label')" @@ -256,6 +261,7 @@ v-model="bulkAssignLabelId" clearable :items="allLabels" + :custom-filter="normalizeFilter" item-value="id" item-title="name" :label="$t('data-pages.foods.food-label')" @@ -346,6 +352,7 @@ import { useUserApi } from "~/composables/api"; import type { CreateIngredientFood, IngredientFood, IngredientFoodAlias } from "~/lib/api/types/recipe"; import MultiPurposeLabel from "~/components/Domain/ShoppingList/MultiPurposeLabel.vue"; import { useLocales } from "~/composables/use-locales"; +import { normalizeFilter } from "~/composables/use-utils"; import { useFoodStore, useLabelStore } from "~/composables/store"; import type { MultiPurposeLabelOut } from "~/lib/api/types/labels"; import type { VForm } from "~/types/auto-forms"; @@ -625,6 +632,7 @@ export default defineNuxtComponent({ foods, allLabels, validators, + normalizeFilter, // Create createDialog, domNewFoodForm, diff --git a/frontend/pages/group/data/labels.vue b/frontend/pages/group/data/labels.vue index 469bfda3e..e4224727b 100644 --- a/frontend/pages/group/data/labels.vue +++ b/frontend/pages/group/data/labels.vue @@ -109,6 +109,7 @@ @@ -26,6 +27,7 @@ v-model="toUnit" return-object :items="store" + :custom-filter="normalizeFilter" item-title="name" :label="$t('data-pages.units.target-unit')" /> @@ -313,6 +315,7 @@ import { validators } from "~/composables/use-validators"; import { useUserApi } from "~/composables/api"; import type { CreateIngredientUnit, IngredientUnit, IngredientUnitAlias } from "~/lib/api/types/recipe"; import { useLocales } from "~/composables/use-locales"; +import { normalizeFilter } from "~/composables/use-utils"; import { useUnitStore } from "~/composables/store"; import type { VForm } from "~/types/auto-forms"; @@ -536,6 +539,7 @@ export default defineNuxtComponent({ tableHeaders, store, validators, + normalizeFilter, // Create createDialog, domNewUnitForm, diff --git a/frontend/pages/household/mealplan/planner/edit.vue b/frontend/pages/household/mealplan/planner/edit.vue index c5fd2e71e..9cf6bab8c 100644 --- a/frontend/pages/household/mealplan/planner/edit.vue +++ b/frontend/pages/household/mealplan/planner/edit.vue @@ -47,6 +47,7 @@ v-model:search="search.query.value" :label="$t('meal-plan.meal-recipe')" :items="search.data.value" + :custom-filter="normalizeFilter" :loading="search.loading.value" cache-items item-title="name" @@ -242,6 +243,7 @@ import RecipeCardImage from "~/components/Domain/Recipe/RecipeCardImage.vue"; import type { PlanEntryType, UpdatePlanEntry } from "~/lib/api/types/meal-plan"; import { useUserApi } from "~/composables/api"; import { useHouseholdSelf } from "~/composables/use-households"; +import { normalizeFilter } from "~/composables/use-utils"; import { useRecipeSearch } from "~/composables/recipes/use-recipe-search"; export default defineNuxtComponent({ @@ -416,6 +418,7 @@ export default defineNuxtComponent({ getEntryTypeText, requiredRule, isCreateDisabled, + normalizeFilter, // Dialog dialog,