Skip to content

Commit

Permalink
feat(search): switches to fuzzy search
Browse files Browse the repository at this point in the history
  • Loading branch information
dvcol committed Oct 31, 2024
1 parent ec94617 commit d4b2d91
Show file tree
Hide file tree
Showing 8 changed files with 123 additions and 21 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"@dvcol/tmdb-http-client": "^1.3.10",
"@dvcol/trakt-http-client": "^1.4.16",
"@dvcol/web-extension-utils": "^3.4.5",
"@leeoniya/ufuzzy": "^1.0.14",
"@vue/devtools": "^7.4.6",
"naive-ui": "^2.40.1",
"pinia": "^2.2.4",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/components/common/panel/PanelAlias.vue
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ const i18n = useI18n('panel', 'alias');
:disabled="!popOptions?.length"
:width="popWidth"
placement="bottom-end"
style="--custom-bg-color: var(--bg-color-70)"
:style="{ '--custom-bg-color': 'var(--bg-color-70)' }"
scrollable
>
<!-- Alias Input -->
Expand Down
46 changes: 46 additions & 0 deletions src/components/common/typography/TextHtml.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<script setup lang="ts">
import type { PropType } from 'vue';
defineProps({
html: {
type: String,
required: true,
},
color: {
type: String,
required: false,
},
background: {
type: String,
required: false,
},
style: {
type: Object as PropType<Record<string, string>>,
required: false,
},
});
</script>

<template>
<span
class="html-text"
:style="{
'--mark-color-text': color,
'--mark-color-background': background,
...style,
}"
v-html="html"
>
</span>
</template>

<style scoped lang="scss">
.html-text {
white-space: pre-wrap;
:deep(mark) {
color: var(--mark-color-text, var(--color-primary-lighter));
background-color: var(--mark-color-background, none);
}
}
</style>
50 changes: 34 additions & 16 deletions src/components/views/search/SearchNavbar.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script setup lang="ts">
import UFuzzy from '@leeoniya/ufuzzy';
import {
NAutoComplete,
NFlex,
Expand All @@ -17,6 +18,7 @@ import type { TraktSearchType } from '@dvcol/trakt-http-client/models';
import ButtonLinkExternal from '~/components/common/buttons/ButtonLinkExternal.vue';
import NavbarPageSizeSelect from '~/components/common/navbar/NavbarPageSizeSelect.vue';
import TextHtml from '~/components/common/typography/TextHtml.vue';
import IconAccount from '~/components/icons/IconAccount.vue';
import IconChevronDown from '~/components/icons/IconChevronDownSmall.vue';
import IconChevronUp from '~/components/icons/IconChevronUpSmall.vue';
Expand All @@ -31,6 +33,7 @@ import { SupportedSearchType, useSearchStoreRefs } from '~/stores/data/search.st
import { debounce } from '~/utils/debounce.utils';
import { useI18n } from '~/utils/i18n.utils';
import { useDebouncedSearch } from '~/utils/store.utils';
import { fuzzyMatch } from '~/utils/string.utils';
const i18n = useI18n('navbar', 'search');
Expand All @@ -53,41 +56,52 @@ const typeOptions = ref<TraktSearchType[]>(SupportedSearchType);
const debouncedSearch = useDebouncedSearch(search, 1000);
const external = computed(() => ResolveExternalLinks.trakt.query(debouncedSearch.value));
const fuzzy = new UFuzzy();
const filteredHistory = computed(() => {
const _search = debouncedSearch.value?.toLowerCase().trim();
const _values = Array.from(history.value);
if (!_search || !history.value) {
return { filter: [], recent: Array.from(history.value) };
}
const result = { match: [], highlight: [], recent: _values };
const filter: string[] = [];
if (!_search || !_values.length) return result;
const { match, highlight } = fuzzyMatch(_values, _search);
const recent: string[] = [];
history.value.forEach(value => {
const _val = value.toLowerCase().trim();
if (_val === _search) return;
if (_val.includes(_search) || _search.includes(_val)) {
filter.push(value);
} else if (recent.length < 5) {
recent.push(value);
}
if (match.includes(value)) return;
if (recent.length >= 5) return;
recent.push(value);
});
return { filter, recent };
let i = 0;
let value: string;
while (recent.length < 5) {
value = _values[i];
if (value !== _search && !match.includes(value)) recent.push(value);
i += 1;
}
return { match, highlight, recent };
});
const historyOptions = computed(() => {
const results = [];
const filter = {
const match = {
type: 'group',
label: i18n('option_group_filter'),
key: 'filter',
children: filteredHistory.value.filter.map(value => ({
label: i18n('option_group_match'),
key: 'match',
children: filteredHistory.value.match.map((value, i) => ({
value,
label: value,
key: `filter-${value}`,
highlight: filteredHistory.value.highlight[i],
key: `match-${value}`,
})),
};
if (filter.children.length) results.push(filter);
if (match.children.length) results.push(match);
const recent = {
type: 'group',
label: i18n('option_group_recent'),
Expand All @@ -103,6 +117,9 @@ const historyOptions = computed(() => {
return results;
});
const renderMatch = ({ highlight, label }: SelectOption & { highlight?: string }) =>
highlight ? h(TextHtml, { html: highlight }) : label?.toString();
const selectedValues = computed({
get: () => types.value,
set: selected => {
Expand Down Expand Up @@ -253,6 +270,7 @@ onActivated(() => {
class="search-input"
:loading="loading"
:placeholder="i18n('search', 'navbar')"
:render-label="renderMatch"
autosize
clearable
:options="historyOptions"
Expand Down
6 changes: 3 additions & 3 deletions src/i18n/en/navbar/navbar-search.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@
"message": "Used for searching using regular expressions.",
"description": "Search query tooltip for the regex keywords (/)"
},
"navbar__search__option_group_filter": {
"message": "Filter",
"description": "Label for the search option group filter"
"navbar__search__option_group_match": {
"message": "Match",
"description": "Label for the search option group match"
},
"navbar__search__option_group_recent": {
"message": "Recent",
Expand Down
3 changes: 2 additions & 1 deletion src/stores/data/search.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ export const useSearchStore = defineStore(SearchStoreConstants.Store, () => {

const addToHistory = debounce((value: string = search.value) => {
if (!value || value.trim().length < minSearchLength) return;
const newArray = [value, ...history.value].map(v => v.trim()).filter(v => v && v.length > 3);
const newArray = [...history.value].filter(v => v !== value);
newArray.unshift(value);
// Keep only the last 100 elements
if (newArray.length > 100) {
history.value = new Set(newArray.slice(0, 100));
Expand Down
28 changes: 28 additions & 0 deletions src/utils/string.utils.ts
Original file line number Diff line number Diff line change
@@ -1 +1,29 @@
import UFuzzy from '@leeoniya/ufuzzy';

export const isTrailer = (title: string) => title && /(trailer|teaser)/.test(title.toLowerCase());

let fuzzy: UFuzzy;
export const fuzzyMatch = (
values: string[],
search: string,
): {
match: string[];
highlight: string[];
ids: { exact?: number; matches: number[]; order: number[]; info: { idx: number[]; ranges: number[][] } };
} => {
if (!fuzzy) fuzzy = new UFuzzy();
const matches = fuzzy.filter(values, search);
if (matches === null || !matches.length) return { match: [], highlight: [], ids: { matches: [], order: [], info: { idx: [], ranges: [] } } };

const info = fuzzy.info(matches, values, search);
const order = fuzzy.sort(info, values, search);
const match: string[] = order.map(idx => values[matches[idx]]);
const highlight: string[] = order.map(idx => UFuzzy.highlight(values[info.idx[idx]], info.ranges[idx]));

const exact = match.findIndex(m => m === search);
if (exact !== -1) {
match.splice(exact, 1);
highlight.splice(exact, 1);
}
return { match, highlight, ids: { exact, matches, order, info } };
};

0 comments on commit d4b2d91

Please sign in to comment.