mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-06 10:25:18 -05:00
feat: frontend autocomplete is diacritics/ligatures insensitive (#6169)
Co-authored-by: Pierre <pierre@debian.zabi.ovh> Co-authored-by: Michael Genson <genson.michael@gmail.com> Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
This commit is contained in:
@@ -56,6 +56,7 @@
|
|||||||
variant="solo"
|
variant="solo"
|
||||||
return-object
|
return-object
|
||||||
:items="units || []"
|
:items="units || []"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
class="mx-1"
|
class="mx-1"
|
||||||
:placeholder="$t('recipe.choose-unit')"
|
:placeholder="$t('recipe.choose-unit')"
|
||||||
@@ -114,6 +115,7 @@
|
|||||||
variant="solo"
|
variant="solo"
|
||||||
return-object
|
return-object
|
||||||
:items="foods || []"
|
:items="foods || []"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
class="mx-1 py-0"
|
class="mx-1 py-0"
|
||||||
:placeholder="$t('recipe.choose-food')"
|
:placeholder="$t('recipe.choose-food')"
|
||||||
@@ -171,6 +173,7 @@
|
|||||||
variant="solo"
|
variant="solo"
|
||||||
return-object
|
return-object
|
||||||
:items="search.data.value || []"
|
:items="search.data.value || []"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
class="mx-1 py-0"
|
class="mx-1 py-0"
|
||||||
:placeholder="$t('search.type-to-search')"
|
:placeholder="$t('search.type-to-search')"
|
||||||
@@ -225,6 +228,7 @@ import { ref, computed, reactive, toRefs } from "vue";
|
|||||||
import { useDisplay } from "vuetify";
|
import { useDisplay } from "vuetify";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
||||||
|
import { normalizeFilter } from "~/composables/use-utils";
|
||||||
import { useNuxtApp } from "#app";
|
import { useNuxtApp } from "#app";
|
||||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||||
import { usePublicExploreApi, useUserApi } from "~/composables/api";
|
import { usePublicExploreApi, useUserApi } from "~/composables/api";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
v-bind="inputAttrs"
|
v-bind="inputAttrs"
|
||||||
v-model:search="searchInput"
|
v-model:search="searchInput"
|
||||||
:items="items"
|
:items="items"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
:label="label"
|
:label="label"
|
||||||
chips
|
chips
|
||||||
closable-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 { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||||
|
import { normalizeFilter } from "~/composables/use-utils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectorType: RecipeOrganizer;
|
selectorType: RecipeOrganizer;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
item-title="name"
|
item-title="name"
|
||||||
return-object
|
return-object
|
||||||
:items="items"
|
:items="items"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
:prepend-icon="icon || $globals.icons.tags"
|
:prepend-icon="icon || $globals.icons.tags"
|
||||||
auto-select-first
|
auto-select-first
|
||||||
clearable
|
clearable
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
|
|
||||||
import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
||||||
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||||
|
import { normalizeFilter } from "~/composables/use-utils";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
@@ -122,6 +124,7 @@ export default defineNuxtComponent({
|
|||||||
itemIdVal,
|
itemIdVal,
|
||||||
searchInput,
|
searchInput,
|
||||||
emitCreate,
|
emitCreate,
|
||||||
|
normalizeFilter,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
v-model="selectedLocale"
|
v-model="selectedLocale"
|
||||||
:items="locales"
|
:items="locales"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
item-value="value"
|
item-value="value"
|
||||||
class="my-3"
|
class="my-3"
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useLocales } from "~/composables/use-locales";
|
import { useLocales } from "~/composables/use-locales";
|
||||||
|
import { normalizeFilter } from "~/composables/use-utils";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
@@ -83,6 +85,7 @@ export default defineNuxtComponent({
|
|||||||
locale,
|
locale,
|
||||||
selectedLocale,
|
selectedLocale,
|
||||||
onLocaleSelect,
|
onLocaleSelect,
|
||||||
|
normalizeFilter,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
34
frontend/composables/use-utils.test.ts
Normal file
34
frontend/composables/use-utils.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useDark, useToggle } from "@vueuse/core";
|
import { useDark, useToggle } from "@vueuse/core";
|
||||||
|
import type { FilterFunction } from "vuetify";
|
||||||
|
|
||||||
export const useToggleDarkMode = () => {
|
export const useToggleDarkMode = () => {
|
||||||
const isDark = useDark();
|
const isDark = useDark();
|
||||||
@@ -18,6 +19,38 @@ export const titleCase = function (str: string) {
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const replaceAllBuilder = (map: Map<string, string>): ((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() {
|
export function uuid4() {
|
||||||
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
|
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
|
||||||
(parseInt(c) ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (parseInt(c) / 4)))).toString(16),
|
(parseInt(c) ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (parseInt(c) / 4)))).toString(16),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
v-model="fromFood"
|
v-model="fromFood"
|
||||||
return-object
|
return-object
|
||||||
:items="foods"
|
:items="foods"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
:label="$t('data-pages.foods.source-food')"
|
:label="$t('data-pages.foods.source-food')"
|
||||||
/>
|
/>
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
v-model="toFood"
|
v-model="toFood"
|
||||||
return-object
|
return-object
|
||||||
:items="foods"
|
:items="foods"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
:label="$t('data-pages.foods.target-food')"
|
:label="$t('data-pages.foods.target-food')"
|
||||||
/>
|
/>
|
||||||
@@ -51,6 +53,7 @@
|
|||||||
v-model="locale"
|
v-model="locale"
|
||||||
:items="locales"
|
:items="locales"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
:label="$t('data-pages.select-language')"
|
:label="$t('data-pages.select-language')"
|
||||||
class="my-3"
|
class="my-3"
|
||||||
hide-details
|
hide-details
|
||||||
@@ -108,6 +111,7 @@
|
|||||||
v-model="createTarget.labelId"
|
v-model="createTarget.labelId"
|
||||||
clearable
|
clearable
|
||||||
:items="allLabels"
|
:items="allLabels"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
item-value="id"
|
item-value="id"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
:label="$t('data-pages.foods.food-label')"
|
:label="$t('data-pages.foods.food-label')"
|
||||||
@@ -164,6 +168,7 @@
|
|||||||
v-model="editTarget.labelId"
|
v-model="editTarget.labelId"
|
||||||
clearable
|
clearable
|
||||||
:items="allLabels"
|
:items="allLabels"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
item-value="id"
|
item-value="id"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
:label="$t('data-pages.foods.food-label')"
|
:label="$t('data-pages.foods.food-label')"
|
||||||
@@ -256,6 +261,7 @@
|
|||||||
v-model="bulkAssignLabelId"
|
v-model="bulkAssignLabelId"
|
||||||
clearable
|
clearable
|
||||||
:items="allLabels"
|
:items="allLabels"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
item-value="id"
|
item-value="id"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
:label="$t('data-pages.foods.food-label')"
|
: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 type { CreateIngredientFood, IngredientFood, IngredientFoodAlias } from "~/lib/api/types/recipe";
|
||||||
import MultiPurposeLabel from "~/components/Domain/ShoppingList/MultiPurposeLabel.vue";
|
import MultiPurposeLabel from "~/components/Domain/ShoppingList/MultiPurposeLabel.vue";
|
||||||
import { useLocales } from "~/composables/use-locales";
|
import { useLocales } from "~/composables/use-locales";
|
||||||
|
import { normalizeFilter } from "~/composables/use-utils";
|
||||||
import { useFoodStore, useLabelStore } from "~/composables/store";
|
import { useFoodStore, useLabelStore } from "~/composables/store";
|
||||||
import type { MultiPurposeLabelOut } from "~/lib/api/types/labels";
|
import type { MultiPurposeLabelOut } from "~/lib/api/types/labels";
|
||||||
import type { VForm } from "~/types/auto-forms";
|
import type { VForm } from "~/types/auto-forms";
|
||||||
@@ -625,6 +632,7 @@ export default defineNuxtComponent({
|
|||||||
foods,
|
foods,
|
||||||
allLabels,
|
allLabels,
|
||||||
validators,
|
validators,
|
||||||
|
normalizeFilter,
|
||||||
// Create
|
// Create
|
||||||
createDialog,
|
createDialog,
|
||||||
domNewFoodForm,
|
domNewFoodForm,
|
||||||
|
|||||||
@@ -109,6 +109,7 @@
|
|||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
v-model="locale"
|
v-model="locale"
|
||||||
:items="locales"
|
:items="locales"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
:label="$t('data-pages.select-language')"
|
:label="$t('data-pages.select-language')"
|
||||||
class="my-3"
|
class="my-3"
|
||||||
@@ -186,6 +187,7 @@ import { useUserApi } from "~/composables/api";
|
|||||||
import MultiPurposeLabel from "~/components/Domain/ShoppingList/MultiPurposeLabel.vue";
|
import MultiPurposeLabel from "~/components/Domain/ShoppingList/MultiPurposeLabel.vue";
|
||||||
import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
||||||
import { useLocales } from "~/composables/use-locales";
|
import { useLocales } from "~/composables/use-locales";
|
||||||
|
import { normalizeFilter } from "~/composables/use-utils";
|
||||||
import { useLabelData, useLabelStore } from "~/composables/store";
|
import { useLabelData, useLabelStore } from "~/composables/store";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
export default defineNuxtComponent({
|
||||||
@@ -316,6 +318,7 @@ export default defineNuxtComponent({
|
|||||||
tableHeaders,
|
tableHeaders,
|
||||||
labels: labelStore.store,
|
labels: labelStore.store,
|
||||||
validators,
|
validators,
|
||||||
|
normalizeFilter,
|
||||||
|
|
||||||
// create
|
// create
|
||||||
createLabel,
|
createLabel,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
v-model="fromUnit"
|
v-model="fromUnit"
|
||||||
return-object
|
return-object
|
||||||
:items="store"
|
:items="store"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
:label="$t('data-pages.units.source-unit')"
|
:label="$t('data-pages.units.source-unit')"
|
||||||
/>
|
/>
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
v-model="toUnit"
|
v-model="toUnit"
|
||||||
return-object
|
return-object
|
||||||
:items="store"
|
:items="store"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
item-title="name"
|
item-title="name"
|
||||||
:label="$t('data-pages.units.target-unit')"
|
:label="$t('data-pages.units.target-unit')"
|
||||||
/>
|
/>
|
||||||
@@ -313,6 +315,7 @@ import { validators } from "~/composables/use-validators";
|
|||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import type { CreateIngredientUnit, IngredientUnit, IngredientUnitAlias } from "~/lib/api/types/recipe";
|
import type { CreateIngredientUnit, IngredientUnit, IngredientUnitAlias } from "~/lib/api/types/recipe";
|
||||||
import { useLocales } from "~/composables/use-locales";
|
import { useLocales } from "~/composables/use-locales";
|
||||||
|
import { normalizeFilter } from "~/composables/use-utils";
|
||||||
import { useUnitStore } from "~/composables/store";
|
import { useUnitStore } from "~/composables/store";
|
||||||
import type { VForm } from "~/types/auto-forms";
|
import type { VForm } from "~/types/auto-forms";
|
||||||
|
|
||||||
@@ -536,6 +539,7 @@ export default defineNuxtComponent({
|
|||||||
tableHeaders,
|
tableHeaders,
|
||||||
store,
|
store,
|
||||||
validators,
|
validators,
|
||||||
|
normalizeFilter,
|
||||||
// Create
|
// Create
|
||||||
createDialog,
|
createDialog,
|
||||||
domNewUnitForm,
|
domNewUnitForm,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
v-model:search="search.query.value"
|
v-model:search="search.query.value"
|
||||||
:label="$t('meal-plan.meal-recipe')"
|
:label="$t('meal-plan.meal-recipe')"
|
||||||
:items="search.data.value"
|
:items="search.data.value"
|
||||||
|
:custom-filter="normalizeFilter"
|
||||||
:loading="search.loading.value"
|
:loading="search.loading.value"
|
||||||
cache-items
|
cache-items
|
||||||
item-title="name"
|
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 type { PlanEntryType, UpdatePlanEntry } from "~/lib/api/types/meal-plan";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { useHouseholdSelf } from "~/composables/use-households";
|
import { useHouseholdSelf } from "~/composables/use-households";
|
||||||
|
import { normalizeFilter } from "~/composables/use-utils";
|
||||||
import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
|
import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
export default defineNuxtComponent({
|
||||||
@@ -416,6 +418,7 @@ export default defineNuxtComponent({
|
|||||||
getEntryTypeText,
|
getEntryTypeText,
|
||||||
requiredRule,
|
requiredRule,
|
||||||
isCreateDisabled,
|
isCreateDisabled,
|
||||||
|
normalizeFilter,
|
||||||
|
|
||||||
// Dialog
|
// Dialog
|
||||||
dialog,
|
dialog,
|
||||||
|
|||||||
Reference in New Issue
Block a user