diff --git a/modules/.gitignore b/modules/.gitignore index 848b0b9d1..86aeefbd1 100644 --- a/modules/.gitignore +++ b/modules/.gitignore @@ -11,3 +11,5 @@ nano.* worker.* *.mts *.cts +*.rs +*.md diff --git a/modules/leeoniya-ufuzzy/.gitignore b/modules/leeoniya-ufuzzy/.gitignore deleted file mode 100644 index c8ccef65f..000000000 --- a/modules/leeoniya-ufuzzy/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -LICENSE -*.json -webcomponent* -README.md -cli* -extras/ diff --git a/modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.d.ts b/modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.d.ts deleted file mode 100644 index 2dbef82e0..000000000 --- a/modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.d.ts +++ /dev/null @@ -1,215 +0,0 @@ -declare class uFuzzy { - constructor(opts?: uFuzzy.Options); - - /** search API composed of filter/info/sort, with a info/ranking threshold (1e3) and fast outOfOrder impl */ - search( - haystack: string[], - needle: string, - /** limit how many terms will be permuted, default = 0; 5 will result in up to 5! (120) search iterations. be careful with this! */ - outOfOrder?: number, - /** default = 1e3 */ - infoThresh?: number, - preFiltered?: uFuzzy.HaystackIdxs | null, - ): uFuzzy.SearchResult; - - /** initial haystack filter, can accept idxs from previous prefix/typeahead match as optimization */ - filter( - haystack: string[], - needle: string, - idxs?: uFuzzy.HaystackIdxs, - ): uFuzzy.HaystackIdxs | null; - - /** collects stats about pre-filtered matches, does additional filtering based on term boundary settings, finds highlight ranges */ - info( - idxs: uFuzzy.HaystackIdxs, - haystack: string[], - needle: string, - ): uFuzzy.Info; - - /** performs final result sorting via Array.sort(), relying on Info */ - sort( - info: uFuzzy.Info, - haystack: string[], - needle: string, - ): uFuzzy.InfoIdxOrder; - - /** utility for splitting needle into terms following defined interSplit/intraSplit opts. useful for out-of-order permutes */ - split(needle: string, keepCase?: boolean): uFuzzy.Terms; - - /** util for creating out-of-order permutations of a needle terms array */ - static permute(arr: unknown[]): unknown[][]; - - /** util for replacing common diacritics/accents */ - static latinize(strings: T): T; - - /** util for highlighting matched substr parts of a result */ - static highlight( - match: string, - ranges: number[], - - mark?: (part: string, matched: boolean) => TMarkedPart, - accum?: TAccum, - append?: (accum: TAccum, part: TMarkedPart) => TAccum | undefined, - ): TAccum; -} - -export = uFuzzy; - -declare namespace uFuzzy { - /** needle's terms */ - export type Terms = string[]; - - /** subset of idxs of a haystack array */ - export type HaystackIdxs = number[]; - - /** sorted order in which info facets should be iterated */ - export type InfoIdxOrder = number[]; - - export type AbortedResult = [null, null, null]; - - export type FilteredResult = [uFuzzy.HaystackIdxs, null, null]; - - export type RankedResult = [ - uFuzzy.HaystackIdxs, - uFuzzy.Info, - uFuzzy.InfoIdxOrder, - ]; - - export type SearchResult = FilteredResult | RankedResult | AbortedResult; - - /** partial RegExp */ - type PartialRegExp = string; - - /** what should be considered acceptable term bounds */ - export const enum BoundMode { - /** will match 'man' substr anywhere. e.g. tasmania */ - Any = 0, - /** will match 'man' at whitespace, punct, case-change, and alpha-num boundaries. e.g. mantis, SuperMan, fooManBar, 0007man */ - Loose = 1, - /** will match 'man' at whitespace, punct boundaries only. e.g. mega man, walk_man, man-made, foo.man.bar */ - Strict = 2, - } - - export const enum IntraMode { - /** allows any number of extra char insertions within a term, but all term chars must be present for a match */ - MultiInsert = 0, - /** allows for a single-char substitution, transposition, insertion, or deletion within terms (excluding first and last chars) */ - SingleError = 1, - } - - export type IntraSliceIdxs = [from: number, to: number]; - - type CompareFn = (a: string, b: string) => number; - - export interface Options { - // whether regexps use a /u unicode flag - unicode?: boolean; // false - - /** @deprecated renamed to opts.alpha */ - letters?: PartialRegExp | null; // a-z - - // regexp character class [] of chars which should be treated as letters (case insensitive) - alpha?: PartialRegExp | null; // a-z - - /** term segmentation & punct/whitespace merging */ - interSplit?: PartialRegExp; // '[^A-Za-z\\d']+' - intraSplit?: PartialRegExp | null; // '[a-z][A-Z]' - - /** inter bounds that will be used to increase lft2/rgt2 info counters */ - interBound?: PartialRegExp | null; // '[^A-Za-z\\d]' - /** intra bounds that will be used to increase lft1/rgt1 info counters */ - intraBound?: PartialRegExp | null; // '[A-Za-z][0-9]|[0-9][A-Za-z]|[a-z][A-Z]' - - /** inter-term modes, during .info() can discard matches when bounds conditions are not met */ - interLft?: BoundMode; // 0 - interRgt?: BoundMode; // 0 - - /** allowance between terms */ - interChars?: PartialRegExp; // '.' - interIns?: number; // Infinity - - /** allowance between chars within terms */ - intraChars?: PartialRegExp; // '[a-z\\d]' - intraIns?: number; // 0 - - /** contractions detection */ - intraContr?: PartialRegExp; // "'[a-z]{1,2}\\b" - - /** error tolerance mode within terms. will clamp intraIns to 1 when set to SingleError */ - intraMode?: IntraMode; // 0 - - /** which part of each term should tolerate errors (when intraMode: 1) */ - intraSlice?: IntraSliceIdxs; // [1, Infinity] - - /** max substitutions (when intraMode: 1) */ - intraSub?: 0 | 1; // 0 - /** max transpositions (when intraMode: 1) */ - intraTrn?: 0 | 1; // 0 - /** max omissions/deletions (when intraMode: 1) */ - intraDel?: 0 | 1; // 0 - - /** can dynamically adjust error tolerance rules per term in needle (when intraMode: 1) */ - intraRules?: (term: string) => { - intraSlice?: IntraSliceIdxs; - intraIns: 0 | 1; - intraSub: 0 | 1; - intraTrn: 0 | 1; - intraDel: 0 | 1; - }; - - /** post-filters matches during .info() based on cmp of term in needle vs partial match */ - intraFilt?: (term: string, match: string, index: number) => boolean; // should this also accept WIP info? - - /** default: toLocaleUpperCase() */ - toUpper?: (str: string) => string; - - /** default: toLocaleLowerCase() */ - toLower?: (str: string) => string; - - /** final sorting cmp when all other match metrics are equal */ - compare?: CompareFn; - - sort?: ( - info: Info, - haystack: string[], - needle: string, - compare?: CompareFn, - ) => InfoIdxOrder; - } - - export interface Info { - /** matched idxs from haystack */ - idx: HaystackIdxs; - - /** match offsets */ - start: number[]; - - /** number of left BoundMode.Strict term boundaries found */ - interLft2: number[]; - /** number of right BoundMode.Strict term boundaries found */ - interRgt2: number[]; - /** number of left BoundMode.Loose term boundaries found */ - interLft1: number[]; - /** number of right BoundMode.Loose term boundaries found */ - interRgt1: number[]; - - /** total number of extra chars matched within all terms. higher = matched terms have more fuzz in them */ - intraIns: number[]; - /** total number of chars found in between matched terms. higher = terms are more sparse, have more fuzz in between them */ - interIns: number[]; - - /** total number of matched contiguous chars (substrs but not necessarily full terms) */ - chars: number[]; - - /** number of exactly-matched terms (intra = 0) where both lft and rgt landed on a BoundMode.Loose or BoundMode.Strict boundary */ - terms: number[]; - - /** number of needle terms with case-sensitive partial matches */ - cases: number[]; - - /** offset ranges within match for highlighting: [startIdx0, endIdx0, startIdx1, endIdx1,...] */ - ranges: number[][]; - } -} - -export as namespace uFuzzy; diff --git a/modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.mjs b/modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.mjs deleted file mode 100644 index b594ef501..000000000 --- a/modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.mjs +++ /dev/null @@ -1,1047 +0,0 @@ -// @ts-nocheck -/** -* Copyright (c) 2025, Leon Sorokin -* All rights reserved. (MIT Licensed) -* -* uFuzzy.js (μFuzzy) -* A tiny, efficient fuzzy matcher that doesn't suck -* https://github.com/leeoniya/uFuzzy (v1.0.19) -*/ - -const cmp = (a, b) => a > b ? 1 : a < b ? -1 : 0; - -const inf = Infinity; - -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping -const escapeRegExp = str => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - -// meh, magic tmp placeholder, must be tolerant to toLocaleLowerCase(), interSplit, and intraSplit -const EXACT_HERE = 'eexxaacctt'; - -const PUNCT_RE = /\p{P}/gu; - -const LATIN_UPPER = 'A-Z'; -const LATIN_LOWER = 'a-z'; - -const COLLATE_ARGS = ['en', { numeric: true, sensitivity: 'base' }]; - -const swapAlpha = (str, upper, lower) => str.replace(LATIN_UPPER, upper).replace(LATIN_LOWER, lower); - -const OPTS = { - // whether regexps use a /u unicode flag - unicode: false, - - alpha: null, - - // term segmentation & punct/whitespace merging - interSplit: "[^A-Za-z\\d']+", - intraSplit: "[a-z][A-Z]", - - // inter bounds that will be used to increase lft2/rgt2 info counters - interBound: "[^A-Za-z\\d]", - // intra bounds that will be used to increase lft1/rgt1 info counters - intraBound: "[A-Za-z]\\d|\\d[A-Za-z]|[a-z][A-Z]", - - // inter-bounds mode - // 2 = strict (will only match 'man' on whitepace and punct boundaries: Mega Man, Mega_Man, mega.man) - // 1 = loose (plus allowance for alpha-num and case-change boundaries: MegaMan, 0007man) - // 0 = any (will match 'man' as any substring: megamaniac) - interLft: 0, - interRgt: 0, - - // allowance between terms - interChars: '.', - interIns: inf, - - // allowance between chars in terms - intraChars: "[a-z\\d']", // internally case-insensitive - intraIns: null, - - intraContr: "'[a-z]{1,2}\\b", - - // multi-insert or single-error mode - intraMode: 0, - - // single-error bounds for errors within terms, default requires exact first char - intraSlice: [1, inf], - - // single-error tolerance toggles - intraSub: null, - intraTrn: null, - intraDel: null, - - // can post-filter matches that are too far apart in distance or length - // (since intraIns is between each char, it can accum to nonsense matches) - intraFilt: (term, match, index) => true, // should this also accept WIP info? - - toUpper: str => str.toLocaleUpperCase(), - toLower: str => str.toLocaleLowerCase(), - compare: null, - - // final sorting fn - sort: (info, haystack, needle, compare = cmp) => { - let { - idx, - chars, - terms, - interLft2, - interLft1, - // interRgt2, - // interRgt1, - start, - intraIns, - interIns, - cases, - } = info; - - return idx.map((v, i) => i).sort((ia, ib) => ( - // most contig chars matched - chars[ib] - chars[ia] || - // least char intra-fuzz (most contiguous) - intraIns[ia] - intraIns[ib] || - // most prefix bounds, boosted by full term matches - ( - (terms[ib] + interLft2[ib] + 0.5 * interLft1[ib]) - - (terms[ia] + interLft2[ia] + 0.5 * interLft1[ia]) - ) || - // highest density of match (least span) - // span[ia] - span[ib] || - // highest density of match (least term inter-fuzz) - interIns[ia] - interIns[ib] || - // earliest start of match - start[ia] - start[ib] || - // case match - cases[ib] - cases[ia] || - // alphabetic - compare(haystack[idx[ia]], haystack[idx[ib]]) - )); - }, -}; - -const lazyRepeat = (chars, limit) => ( - limit == 0 ? '' : - limit == 1 ? chars + '??' : - limit == inf ? chars + '*?' : - chars + `{0,${limit}}?` -); - -const mode2Tpl = '(?:\\b|_)'; - -function uFuzzy(opts) { - opts = Object.assign({}, OPTS, opts); - - let { - unicode, - interLft, - interRgt, - intraMode, - intraSlice, - intraIns, - intraSub, - intraTrn, - intraDel, - intraContr, - intraSplit: _intraSplit, - interSplit: _interSplit, - intraBound: _intraBound, - interBound: _interBound, - intraChars, - toUpper, - toLower, - compare, - } = opts; - - intraIns ??= intraMode; - intraSub ??= intraMode; - intraTrn ??= intraMode; - intraDel ??= intraMode; - - compare ??= typeof Intl == "undefined" ? cmp : new Intl.Collator(...COLLATE_ARGS).compare; - - let alpha = opts.letters ?? opts.alpha; - - if (alpha != null) { - let upper = toUpper(alpha); - let lower = toLower(alpha); - - _interSplit = swapAlpha(_interSplit, upper, lower); - _intraSplit = swapAlpha(_intraSplit, upper, lower); - _interBound = swapAlpha(_interBound, upper, lower); - _intraBound = swapAlpha(_intraBound, upper, lower); - intraChars = swapAlpha(intraChars, upper, lower); - intraContr = swapAlpha(intraContr, upper, lower); - } - - let uFlag = unicode ? 'u' : ''; - - const quotedAny = '".+?"'; - const EXACTS_RE = new RegExp(quotedAny, 'gi' + uFlag); - const NEGS_RE = new RegExp(`(?:\\s+|^)-(?:${intraChars}+|${quotedAny})`, 'gi' + uFlag); - - let { intraRules } = opts; - - if (intraRules == null) { - intraRules = p => { - // default is exact term matches only - let _intraSlice = OPTS.intraSlice, // requires first char - _intraIns = 0, - _intraSub = 0, - _intraTrn = 0, - _intraDel = 0; - - // only-digits strings should match exactly, else special rules for short strings - if (/[^\d]/.test(p)) { - let plen = p.length; - - // prevent junk matches by requiring stricter rules for short terms - if (plen <= 4) { - if (plen >= 3) { - // one swap in non-first char when 3-4 chars - _intraTrn = Math.min(intraTrn, 1); - - // or one insertion when 4 chars - if (plen == 4) - _intraIns = Math.min(intraIns, 1); - } - // else exact match when 1-2 chars - } - // use supplied opts - else { - _intraSlice = intraSlice; - _intraIns = intraIns, - _intraSub = intraSub, - _intraTrn = intraTrn, - _intraDel = intraDel; - } - } - - return { - intraSlice: _intraSlice, - intraIns: _intraIns, - intraSub: _intraSub, - intraTrn: _intraTrn, - intraDel: _intraDel, - }; - }; - } - - let withIntraSplit = !!_intraSplit; - - let intraSplit = new RegExp(_intraSplit, 'g' + uFlag); - let interSplit = new RegExp(_interSplit, 'g' + uFlag); - - let trimRe = new RegExp('^' + _interSplit + '|' + _interSplit + '$', 'g' + uFlag); - let contrsRe = new RegExp(intraContr, 'gi' + uFlag); - - const split = (needle, keepCase = false) => { - let exacts = []; - - needle = needle.replace(EXACTS_RE, m => { - exacts.push(m); - return EXACT_HERE; - }); - - needle = needle.replace(trimRe, ''); - - if (!keepCase) - needle = toLower(needle); - - if (withIntraSplit) - needle = needle.replace(intraSplit, m => m[0] + ' ' + m[1]); - - let j = 0; - return needle.split(interSplit).filter(t => t != '').map(v => v === EXACT_HERE ? exacts[j++] : v); - }; - - const NUM_OR_ALPHA_RE = /[^\d]+|\d+/g; - - const prepQuery = (needle, capt = 0, interOR = false) => { - // split on punct, whitespace, num-alpha, and upper-lower boundaries - let parts = split(needle); - - if (parts.length == 0) - return []; - - // split out any detected contractions for each term that become required suffixes - let contrs = Array(parts.length).fill(''); - parts = parts.map((p, pi) => p.replace(contrsRe, m => { - contrs[pi] = m; - return ''; - })); - - // array of regexp tpls for each term - let reTpl; - - // allows single mutations within each term - if (intraMode == 1) { - reTpl = parts.map((p, pi) => { - if (p[0] === '"') - return escapeRegExp(p.slice(1, -1)); - - let reTpl = ''; - - // split into numeric and alpha parts, so numbers are only matched as following punct or alpha boundaries, without swaps or insertions - for (let m of p.matchAll(NUM_OR_ALPHA_RE)) { - let p = m[0]; - - let { - intraSlice, - intraIns, - intraSub, - intraTrn, - intraDel, - } = intraRules(p); - - if (intraIns + intraSub + intraTrn + intraDel == 0) - reTpl += p + contrs[pi]; - else { - let [lftIdx, rgtIdx] = intraSlice; - let lftChar = p.slice(0, lftIdx); // prefix - let rgtChar = p.slice(rgtIdx); // suffix - - let chars = p.slice(lftIdx, rgtIdx); - - // neg lookahead to prefer matching 'Test' instead of 'tTest' in ManifestTest or fittest - // but skip when search term contains leading repetition (aardvark, aaa) - if (intraIns == 1 && lftChar.length == 1 && lftChar != chars[0]) - lftChar += '(?!' + lftChar + ')'; - - let numChars = chars.length; - - let variants = [p]; - - // variants with single char substitutions - if (intraSub) { - for (let i = 0; i < numChars; i++) - variants.push(lftChar + chars.slice(0, i) + intraChars + chars.slice(i + 1) + rgtChar); - } - - // variants with single transpositions - if (intraTrn) { - for (let i = 0; i < numChars - 1; i++) { - if (chars[i] != chars[i+1]) - variants.push(lftChar + chars.slice(0, i) + chars[i+1] + chars[i] + chars.slice(i + 2) + rgtChar); - } - } - - // variants with single char omissions - if (intraDel) { - for (let i = 0; i < numChars; i++) - variants.push(lftChar + chars.slice(0, i + 1) + '?' + chars.slice(i + 1) + rgtChar); - } - - // variants with single char insertions - if (intraIns) { - let intraInsTpl = lazyRepeat(intraChars, 1); - - for (let i = 0; i < numChars; i++) - variants.push(lftChar + chars.slice(0, i) + intraInsTpl + chars.slice(i) + rgtChar); - } - - reTpl += '(?:' + variants.join('|') + ')' + contrs[pi]; - } - } - - // console.log(reTpl); - - return reTpl; - }); - } - else { - let intraInsTpl = lazyRepeat(intraChars, intraIns); - - // capture at char level - if (capt == 2 && intraIns > 0) { - // sadly, we also have to capture the inter-term junk via parenth-wrapping .*? - // to accum other capture groups' indices for \b boosting during scoring - intraInsTpl = ')(' + intraInsTpl + ')('; - } - - reTpl = parts.map((p, pi) => p[0] === '"' ? escapeRegExp(p.slice(1, -1)) : p.split('').map((c, i, chars) => { - // neg lookahead to prefer matching 'Test' instead of 'tTest' in ManifestTest or fittest - // but skip when search term contains leading repetition (aardvark, aaa) - if (intraIns == 1 && i == 0 && chars.length > 1 && c != chars[i+1]) - c += '(?!' + c + ')'; - - return c; - }).join(intraInsTpl) + contrs[pi]); - } - - // console.log(reTpl); - - // this only helps to reduce initial matches early when they can be detected - // TODO: might want a mode 3 that excludes _ - let preTpl = interLft == 2 ? mode2Tpl : ''; - let sufTpl = interRgt == 2 ? mode2Tpl : ''; - - let interCharsTpl = sufTpl + lazyRepeat(opts.interChars, opts.interIns) + preTpl; - - // capture at word level - if (capt > 0) { - if (interOR) { - // this is basically for doing .matchAll() occurence counting and highlighting without needing permuted ooo needles - reTpl = preTpl + '(' + reTpl.join(')' + sufTpl + '|' + preTpl + '(') + ')' + sufTpl; - } - else { - // sadly, we also have to capture the inter-term junk via parenth-wrapping .*? - // to accum other capture groups' indices for \b boosting during scoring - reTpl = '(' + reTpl.join(')(' + interCharsTpl + ')(') + ')'; - reTpl = '(.??' + preTpl + ')' + reTpl + '(' + sufTpl + '.*)'; // nit: trailing capture here assumes interIns = Inf - } - } - else { - reTpl = reTpl.join(interCharsTpl); - reTpl = preTpl + reTpl + sufTpl; - } - - // console.log(reTpl); - - return [new RegExp(reTpl, 'i' + uFlag), parts, contrs]; - }; - - const filter = (haystack, needle, idxs) => { - - let [query] = prepQuery(needle); - - if (query == null) - return null; - - let out = []; - - if (idxs != null) { - for (let i = 0; i < idxs.length; i++) { - let idx = idxs[i]; - query.test(haystack[idx]) && out.push(idx); - } - } - else { - for (let i = 0; i < haystack.length; i++) - query.test(haystack[i]) && out.push(i); - } - - return out; - }; - - let withIntraBound = !!_intraBound; - - let interBound = new RegExp(_interBound, uFlag); - let intraBound = new RegExp(_intraBound, uFlag); - - const info = (idxs, haystack, needle) => { - - let [query, parts, contrs] = prepQuery(needle, 1); - let partsCased = split(needle, true); - let [queryR] = prepQuery(needle, 2); - let partsLen = parts.length; - - let _terms = Array(partsLen); - let _termsCased = Array(partsLen); - - for (let j = 0; j < partsLen; j++) { - let part = parts[j]; - let partCased = partsCased[j]; - - let term = part[0] == '"' ? part.slice(1, -1) : part + contrs[j]; - let termCased = partCased[0] == '"' ? partCased.slice(1, -1) : partCased + contrs[j]; - - _terms[j] = term; - _termsCased[j] = termCased; - } - - let len = idxs.length; - - let field = Array(len).fill(0); - - let info = { - // idx in haystack - idx: Array(len), - - // start of match - start: field.slice(), - // length of match - // span: field.slice(), - - // contiguous chars matched - chars: field.slice(), - - // case matched in term (via term.includes(match)) - cases: field.slice(), - - // contiguous (no fuzz) and bounded terms (intra=0, lft2/1, rgt2/1) - // excludes terms that are contiguous but have < 2 bounds (substrings) - terms: field.slice(), - - // cumulative length of unmatched chars (fuzz) within span - interIns: field.slice(), // between terms - intraIns: field.slice(), // within terms - - // interLft/interRgt counters - interLft2: field.slice(), - interRgt2: field.slice(), - interLft1: field.slice(), - interRgt1: field.slice(), - - ranges: Array(len), - }; - - // might discard idxs based on bounds checks - let mayDiscard = interLft == 1 || interRgt == 1; - - let ii = 0; - - for (let i = 0; i < idxs.length; i++) { - let mhstr = haystack[idxs[i]]; - - // the matched parts are [full, junk, term, junk, term, junk] - let m = mhstr.match(query); - - // leading junk - let start = m.index + m[1].length; - - let idxAcc = start; - // let span = m[0].length; - - let disc = false; - let lft2 = 0; - let lft1 = 0; - let rgt2 = 0; - let rgt1 = 0; - let chars = 0; - let terms = 0; - let cases = 0; - let inter = 0; - let intra = 0; - - let refine = []; - - for (let j = 0, k = 2; j < partsLen; j++, k+=2) { - let group = toLower(m[k]); - let term = _terms[j]; - let termCased = _termsCased[j]; - let termLen = term.length; - let groupLen = group.length; - let fullMatch = group == term; - - if (m[k] == termCased) - cases++; - - // this won't handle the case when an exact match exists across the boundary of the current group and the next junk - // e.g. blob,ob when searching for 'bob' but finding the earlier `blob` (with extra insertion) - if (!fullMatch && m[k+1].length >= termLen) { - // probe for exact match in inter junk (TODO: maybe even in this matched part?) - let idxOf = toLower(m[k+1]).indexOf(term); - - if (idxOf > -1) { - refine.push(idxAcc, groupLen, idxOf, termLen); - idxAcc += refineMatch(m, k, idxOf, termLen); - group = term; - groupLen = termLen; - fullMatch = true; - - if (j == 0) - start = idxAcc; - } - } - - if (mayDiscard || fullMatch) { - // does group's left and/or right land on \b - let lftCharIdx = idxAcc - 1; - let rgtCharIdx = idxAcc + groupLen; - - let isPre = false; - let isSuf = false; - - // prefix info - if (lftCharIdx == -1 || interBound.test(mhstr[lftCharIdx])) { - fullMatch && lft2++; - isPre = true; - } - else { - if (interLft == 2) { - disc = true; - break; - } - - if (withIntraBound && intraBound.test(mhstr[lftCharIdx] + mhstr[lftCharIdx + 1])) { - fullMatch && lft1++; - isPre = true; - } - else { - if (interLft == 1) { - // regexps are eager, so try to improve the match by probing forward inter junk for exact match at a boundary - let junk = m[k+1]; - let junkIdx = idxAcc + groupLen; - - if (junk.length >= termLen) { - let idxOf = 0; - let found = false; - let re = new RegExp(term, 'ig' + uFlag); - - let m2; - while (m2 = re.exec(junk)) { - idxOf = m2.index; - - let charIdx = junkIdx + idxOf; - let lftCharIdx = charIdx - 1; - - if (lftCharIdx == -1 || interBound.test(mhstr[lftCharIdx])) { - lft2++; - found = true; - break; - } - else if (intraBound.test(mhstr[lftCharIdx] + mhstr[charIdx])) { - lft1++; - found = true; - break; - } - } - - if (found) { - isPre = true; - - // identical to exact term refinement pass above - refine.push(idxAcc, groupLen, idxOf, termLen); - idxAcc += refineMatch(m, k, idxOf, termLen); - group = term; - groupLen = termLen; - fullMatch = true; - - if (j == 0) - start = idxAcc; - } - } - - if (!isPre) { - disc = true; - break; - } - } - } - } - - // suffix info - if (rgtCharIdx == mhstr.length || interBound.test(mhstr[rgtCharIdx])) { - fullMatch && rgt2++; - isSuf = true; - } - else { - if (interRgt == 2) { - disc = true; - break; - } - - if (withIntraBound && intraBound.test(mhstr[rgtCharIdx - 1] + mhstr[rgtCharIdx])) { - fullMatch && rgt1++; - isSuf = true; - } - else { - if (interRgt == 1) { - disc = true; - break; - } - } - } - - if (fullMatch) { - chars += termLen; - - if (isPre && isSuf) - terms++; - } - } - - if (groupLen > termLen) - intra += groupLen - termLen; // intraFuzz - - if (j > 0) - inter += m[k-1].length; // interFuzz - - // TODO: group here is lowercased, which is okay for length cmp, but not more case-sensitive filts - if (!opts.intraFilt(term, group, idxAcc)) { - disc = true; - break; - } - - if (j < partsLen - 1) - idxAcc += groupLen + m[k+1].length; - } - - if (!disc) { - info.idx[ii] = idxs[i]; - info.interLft2[ii] = lft2; - info.interLft1[ii] = lft1; - info.interRgt2[ii] = rgt2; - info.interRgt1[ii] = rgt1; - info.chars[ii] = chars; - info.terms[ii] = terms; - info.cases[ii] = cases; - info.interIns[ii] = inter; - info.intraIns[ii] = intra; - - info.start[ii] = start; - // info.span[ii] = span; - - // ranges - let m = mhstr.match(queryR); - - let idxAcc = m.index + m[1].length; - - let refLen = refine.length; - let ri = refLen > 0 ? 0 : Infinity; - let lastRi = refLen - 4; - - for (let i = 2; i < m.length;) { - let len = m[i].length; - - if (ri <= lastRi && refine[ri] == idxAcc) { - let groupLen = refine[ri+1]; - let idxOf = refine[ri+2]; - let termLen = refine[ri+3]; - - // advance to end of original (full) group match that includes intra-junk - let j = i; - let v = ''; - for (let _len = 0; _len < groupLen; j++) { - v += m[j]; - _len += m[j].length; - } - - m.splice(i, j - i, v); - - idxAcc += refineMatch(m, i, idxOf, termLen); - - ri += 4; - } - else { - idxAcc += len; - i++; - } - } - - idxAcc = m.index + m[1].length; - - let ranges = info.ranges[ii] = []; - let from = idxAcc; - let to = idxAcc; - - for (let i = 2; i < m.length; i++) { - let len = m[i].length; - - idxAcc += len; - - if (i % 2 == 0) - to = idxAcc; - else if (len > 0) { - ranges.push(from, to); - from = to = idxAcc; - } - } - - if (to > from) - ranges.push(from, to); - - ii++; - } - } - - // trim arrays - if (ii < idxs.length) { - for (let k in info) - info[k] = info[k].slice(0, ii); - } - - return info; - }; - - const refineMatch = (m, k, idxInNext, termLen) => { - // shift the current group into the prior junk - let prepend = m[k] + m[k+1].slice(0, idxInNext); - m[k-1] += prepend; - m[k] = m[k+1].slice(idxInNext, idxInNext + termLen); - m[k+1] = m[k+1].slice(idxInNext + termLen); - return prepend.length; - }; - - const OOO_TERMS_LIMIT = 5; - - // returns [idxs, info, order] - const _search = (haystack, needle, outOfOrder, infoThresh = 1e3, preFiltered) => { - outOfOrder = !outOfOrder ? 0 : outOfOrder === true ? OOO_TERMS_LIMIT : outOfOrder; - - let needles = null; - let matches = null; - - let negs = []; - - needle = needle.replace(NEGS_RE, m => { - let neg = m.trim().slice(1); - - neg = neg[0] === '"' ? escapeRegExp(neg.slice(1,-1)) : neg.replace(PUNCT_RE, ''); - - if (neg != '') - negs.push(neg); - - return ''; - }); - - let terms = split(needle); - - let negsRe; - - if (negs.length > 0) { - negsRe = new RegExp(negs.join('|'), 'i' + uFlag); - - if (terms.length == 0) { - let idxs = []; - - for (let i = 0; i < haystack.length; i++) { - if (!negsRe.test(haystack[i])) - idxs.push(i); - } - - return [idxs, null, null]; - } - } - else { - // abort search (needle is empty after pre-processing, e.g. no alpha-numeric chars) - if (terms.length == 0) - return [null, null, null]; - } - - // console.log(negs); - // console.log(needle); - - if (outOfOrder > 0) { - // since uFuzzy is an AND-based search, we can iteratively pre-reduce the haystack by searching - // for each term in isolation before running permutations on what's left. - // this is a major perf win. e.g. searching "test man ger pp a" goes from 570ms -> 14ms - let terms = split(needle); - - if (terms.length > 1) { - // longest -> shortest - let terms2 = terms.slice().sort((a, b) => b.length - a.length); - - for (let ti = 0; ti < terms2.length; ti++) { - // no haystack item contained all terms - if (preFiltered?.length == 0) - return [[], null, null]; - - preFiltered = filter(haystack, terms2[ti], preFiltered); - } - - // avoid combinatorial explosion by limiting outOfOrder to 5 terms (120 max searches) - // fall back to just filter() otherwise - if (terms.length > outOfOrder) - return [preFiltered, null, null]; - - needles = permute(terms).map(perm => perm.join(' ')); - - // filtered matches for each needle excluding same matches for prior needles - matches = []; - - // keeps track of already-matched idxs to skip in follow-up permutations - let matchedIdxs = new Set(); - - for (let ni = 0; ni < needles.length; ni++) { - if (matchedIdxs.size < preFiltered.length) { - // filter further for this needle, exclude already-matched - let preFiltered2 = preFiltered.filter(idx => !matchedIdxs.has(idx)); - - let matched = filter(haystack, needles[ni], preFiltered2); - - for (let j = 0; j < matched.length; j++) - matchedIdxs.add(matched[j]); - - matches.push(matched); - } - else - matches.push([]); - } - } - } - - // interOR - // console.log(prepQuery(needle, 1, null, true)); - - // non-ooo or ooo w/single term - if (needles == null) { - needles = [needle]; - matches = [preFiltered?.length > 0 ? preFiltered : filter(haystack, needle)]; - } - - let retInfo = null; - let retOrder = null; - - if (negs.length > 0) - matches = matches.map(idxs => idxs.filter(idx => !negsRe.test(haystack[idx]))); - - let matchCount = matches.reduce((acc, idxs) => acc + idxs.length, 0); - - // rank, sort, concat - if (matchCount <= infoThresh) { - retInfo = {}; - retOrder = []; - - for (let ni = 0; ni < matches.length; ni++) { - let idxs = matches[ni]; - - if (idxs == null || idxs.length == 0) - continue; - - let needle = needles[ni]; - let _info = info(idxs, haystack, needle); - let order = opts.sort(_info, haystack, needle, compare); - - // offset idxs for concat'ing infos - if (ni > 0) { - for (let i = 0; i < order.length; i++) - order[i] += retOrder.length; - } - - for (let k in _info) - retInfo[k] = (retInfo[k] ?? []).concat(_info[k]); - - retOrder = retOrder.concat(order); - } - } - - return [ - [].concat(...matches), - retInfo, - retOrder, - ]; - }; - - return { - search: (...args) => { - let out = _search(...args); - return out; - }, - split, - filter, - info, - sort: opts.sort, - }; -} - -const latinize = (() => { - let accents = { - A: 'ÁÀÃÂÄĄĂÅ', - a: 'áàãâäąăå', - E: 'ÉÈÊËĖĚ', - e: 'éèêëęě', - I: 'ÍÌÎÏĮİ', - i: 'íìîïįı', - O: 'ÓÒÔÕÖ', - o: 'óòôõö', - U: 'ÚÙÛÜŪŲŮŰ', - u: 'úùûüūųůű', - C: 'ÇČĆ', - c: 'çčć', - D: 'Ď', - d: 'ď', - G: 'Ğ', - g: 'ğ', - L: 'Ł', - l: 'ł', - N: 'ÑŃŇ', - n: 'ñńň', - S: 'ŠŚȘŞ', - s: 'šśșş', - T: 'ŢȚŤ', - t: 'ţțť', - Y: 'Ý', - y: 'ý', - Z: 'ŻŹŽ', - z: 'żźž' - }; - - // str.normalize("NFD").replace(/\p{Diacritic}/gu, "") - - let accentsMap = {}; - let accentsTpl = ''; - - for (let r in accents) { - accents[r].split('').forEach(a => { - accentsTpl += a; - accentsMap[a] = r; - }); - } - - let accentsRe = new RegExp(`[${accentsTpl}]`, 'g'); - let replacer = m => accentsMap[m]; - - return strings => { - if (typeof strings == 'string') - return strings.replace(accentsRe, replacer); - - let out = Array(strings.length); - for (let i = 0; i < strings.length; i++) - out[i] = strings[i].replace(accentsRe, replacer); - return out; - }; -})(); - -// https://stackoverflow.com/questions/9960908/permutations-in-javascript/37580979#37580979 -function permute(arr) { - arr = arr.slice(); - - let length = arr.length, - result = [arr.slice()], - c = new Array(length).fill(0), - i = 1, k, p; - - while (i < length) { - if (c[i] < i) { - k = i % 2 && c[i]; - p = arr[i]; - arr[i] = arr[k]; - arr[k] = p; - ++c[i]; - i = 1; - result.push(arr.slice()); - } else { - c[i] = 0; - ++i; - } - } - - return result; -} - -const _mark = (part, matched) => matched ? `${part}` : part; -const _append = (acc, part) => acc + part; - -function highlight(str, ranges, mark = _mark, accum = '', append = _append) { - accum = append(accum, mark(str.substring(0, ranges[0]), false)) ?? accum; - - for (let i = 0; i < ranges.length; i+=2) { - let fr = ranges[i]; - let to = ranges[i+1]; - - accum = append(accum, mark(str.substring(fr, to), true)) ?? accum; - - if (i < ranges.length - 3) - accum = append(accum, mark(str.substring(ranges[i+1], ranges[i+2]), false)) ?? accum; - } - - accum = append(accum, mark(str.substring(ranges[ranges.length - 1]), false)) ?? accum; - - return accum; -} - -uFuzzy.latinize = latinize; -uFuzzy.permute = arr => { - let idxs = permute([...Array(arr.length).keys()]).sort((a,b) => { - for (let i = 0; i < a.length; i++) { - if (a[i] != b[i]) - return a[i] - b[i]; - } - return 0; - }); - - return idxs.map(pi => pi.map(i => arr[i])); -}; -uFuzzy.highlight = highlight; - -export { uFuzzy as default }; diff --git a/modules/quickmatch-js/0.3.1/src/index.js b/modules/quickmatch-js/0.3.1/src/index.js new file mode 100644 index 000000000..df49e7d54 --- /dev/null +++ b/modules/quickmatch-js/0.3.1/src/index.js @@ -0,0 +1,427 @@ +const DEFAULT_SEPARATORS = "_- "; +const DEFAULT_TRIGRAM_BUDGET = 6; +const DEFAULT_LIMIT = 100; + +/** + * Configuration for QuickMatch. + */ +export class QuickMatchConfig { + /** @type {string} Characters used to split items into words */ + separators = DEFAULT_SEPARATORS; + + /** @type {number} Maximum number of results to return */ + limit = DEFAULT_LIMIT; + + /** @type {number} Number of trigram lookups for fuzzy matching (0-20) */ + trigramBudget = DEFAULT_TRIGRAM_BUDGET; + + /** + * Set maximum number of results. + * @param {number} n + */ + withLimit(n) { + this.limit = Math.max(1, n); + return this; + } + + /** + * Set trigram budget for fuzzy matching. + * Higher values find more typos but cost more. + * @param {number} n - Budget (0-20, default: 6) + */ + withTrigramBudget(n) { + this.trigramBudget = Math.max(0, Math.min(20, n)); + return this; + } + + /** + * Set word separator characters. + * @param {string} s - Separator characters (default: '_- ') + */ + withSeparators(s) { + this.separators = s; + return this; + } +} + +/** + * Fast fuzzy string matcher using word and trigram indexing. + */ +export class QuickMatch { + /** + * Create a new matcher. + * @param {string[]} items - Items to index (should be lowercase) + * @param {QuickMatchConfig} [config] - Optional configuration + */ + constructor(items, config = new QuickMatchConfig()) { + this.config = config; + this.items = items; + /** @type {Map} */ + this.wordIndex = new Map(); + /** @type {Map} */ + this.trigramIndex = new Map(); + + let maxWordLength = 0; + let maxQueryLength = 0; + let maxWordCount = 0; + + const { separators } = config; + + for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + const item = items[itemIndex]; + + if (item.length > maxQueryLength) { + maxQueryLength = item.length; + } + + let wordCount = 0; + let wordStart = 0; + + for (let i = 0; i <= item.length; i++) { + const isEndOfWord = i === item.length || separators.includes(item[i]); + + if (isEndOfWord && i > wordStart) { + wordCount++; + const word = item.slice(wordStart, i); + + if (word.length > maxWordLength) { + maxWordLength = word.length; + } + + addToIndex(this.wordIndex, word, itemIndex); + addTrigramsToIndex(this.trigramIndex, word, itemIndex); + + wordStart = i + 1; + } else if (isEndOfWord) { + wordStart = i + 1; + } + } + + if (wordCount > maxWordCount) { + maxWordCount = wordCount; + } + } + + this.maxWordLength = maxWordLength + 4; + this.maxQueryLength = maxQueryLength + 6; + this.maxWordCount = maxWordCount + 2; + } + + /** + * Find matching items. Returns items sorted by relevance. + * @param {string} query - Search query + */ + matches(query) { + return this.matchesWith(query, this.config); + } + + /** + * Find matching items with custom config. Returns items sorted by relevance. + * @param {string} query - Search query + * @param {QuickMatchConfig} config - Configuration to use + */ + matchesWith(query, config) { + const { limit, trigramBudget, separators } = config; + + const normalizedQuery = normalizeQuery(query); + + if (!normalizedQuery || normalizedQuery.length > this.maxQueryLength) { + return []; + } + + const queryWords = parseWords( + normalizedQuery, + separators, + this.maxWordLength, + ); + + if (!queryWords.length || queryWords.length > this.maxWordCount) { + return []; + } + + const knownWords = []; + const unknownWords = []; + + for (const word of queryWords) { + const matchingItems = this.wordIndex.get(word); + + if (matchingItems) { + knownWords.push(matchingItems); + } else if (word.length >= 3 && unknownWords.length < trigramBudget) { + unknownWords.push(word); + } + } + + const exactMatches = intersectAll(knownWords); + const hasExactMatches = exactMatches.length > 0; + const needsFuzzyMatching = unknownWords.length > 0 && trigramBudget > 0; + + if (!needsFuzzyMatching) { + if (!hasExactMatches) return []; + return this.sortedByLength(exactMatches, limit); + } + + const scores = new Map(); + + if (hasExactMatches) { + for (const index of exactMatches) { + scores.set(index, 1); + } + } + + const minItemLength = Math.max(0, normalizedQuery.length - 3); + + const hitCount = this.scoreByTrigrams({ + unknownWords, + budget: trigramBudget, + scores, + hasExactMatches, + minItemLength, + }); + + const minScoreToInclude = Math.max(1, Math.ceil(hitCount / 2)); + + return this.rankedResults(scores, minScoreToInclude, limit); + } + + /** + * @private + * @param {{unknownWords: string[], budget: number, scores: Map, hasExactMatches: boolean, minItemLength: number}} args + */ + scoreByTrigrams({ + unknownWords, + budget, + scores, + hasExactMatches, + minItemLength, + }) { + const visitedTrigrams = new Set(); + let budgetRemaining = budget; + let hitCount = 0; + + outer: for (let round = 0; round < budget; round++) { + for (const word of unknownWords) { + if (budgetRemaining <= 0) break outer; + + const position = pickTrigramPosition(word.length, round); + if (position < 0) continue; + + const trigram = + word[position] + word[position + 1] + word[position + 2]; + + if (visitedTrigrams.has(trigram)) continue; + visitedTrigrams.add(trigram); + + budgetRemaining--; + + const matchingItems = this.trigramIndex.get(trigram); + if (!matchingItems) continue; + + hitCount++; + + for (const itemIndex of matchingItems) { + if (hasExactMatches) { + const currentScore = scores.get(itemIndex); + if (currentScore !== undefined) { + scores.set(itemIndex, currentScore + 1); + } + } else if (this.items[itemIndex].length >= minItemLength) { + scores.set(itemIndex, (scores.get(itemIndex) || 0) + 1); + } + } + } + } + + return hitCount; + } + + /** + * @private + * @param {number[]} indices + * @param {number} limit + */ + sortedByLength(indices, limit) { + const { items } = this; + indices.sort((a, b) => items[a].length - items[b].length); + if (indices.length > limit) indices.length = limit; + return indices.map((i) => items[i]); + } + + /** + * @private + * @param {Map} scores + * @param {number} minScore + * @param {number} limit + */ + rankedResults(scores, minScore, limit) { + const { items } = this; + const results = []; + + for (const [index, score] of scores) { + if (score >= minScore) { + results.push({ index, score }); + } + } + + results.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return items[a.index].length - items[b.index].length; + }); + + if (results.length > limit) results.length = limit; + + return results.map((r) => items[r.index]); + } +} + +/** @param {string} query */ +function normalizeQuery(query) { + let result = ""; + let start = 0; + let end = query.length; + + while (start < end && query.charCodeAt(start) <= 32) start++; + while (end > start && query.charCodeAt(end - 1) <= 32) end--; + + for (let i = start; i < end; i++) { + const code = query.charCodeAt(i); + if (code >= 128) continue; + result += + code >= 65 && code <= 90 ? String.fromCharCode(code + 32) : query[i]; + } + + return result; +} + +/** + * @param {string} text + * @param {string} separators + * @param {number} maxLength + */ +function parseWords(text, separators, maxLength) { + /** @type {string[]} */ + const words = []; + let start = 0; + + for (let i = 0; i <= text.length; i++) { + const isEnd = i === text.length || separators.includes(text[i]); + + if (isEnd && i > start) { + const word = text.slice(start, i); + if (word.length <= maxLength && !words.includes(word)) { + words.push(word); + } + start = i + 1; + } else if (isEnd) { + start = i + 1; + } + } + + return words; +} + +/** + * @param {Map} index + * @param {string} key + * @param {number} value + */ +function addToIndex(index, key, value) { + const existing = index.get(key); + if (existing) { + existing.push(value); + } else { + index.set(key, [value]); + } +} + +/** + * @param {Map} index + * @param {string} word + * @param {number} itemIndex + */ +function addTrigramsToIndex(index, word, itemIndex) { + if (word.length < 3) return; + + for (let i = 0; i <= word.length - 3; i++) { + const trigram = word[i] + word[i + 1] + word[i + 2]; + const existing = index.get(trigram); + + if (!existing) { + index.set(trigram, [itemIndex]); + } else if (existing[existing.length - 1] !== itemIndex) { + existing.push(itemIndex); + } + } +} + +/** @param {number[][]} arrays */ +function intersectAll(arrays) { + if (!arrays.length) return []; + + let smallestIndex = 0; + for (let i = 1; i < arrays.length; i++) { + if (arrays[i].length < arrays[smallestIndex].length) { + smallestIndex = i; + } + } + + const result = arrays[smallestIndex].slice(); + + for (let i = 0; i < arrays.length && result.length > 0; i++) { + if (i === smallestIndex) continue; + + let writeIndex = 0; + for (let j = 0; j < result.length; j++) { + if (binarySearch(arrays[i], result[j])) { + result[writeIndex++] = result[j]; + } + } + result.length = writeIndex; + } + + return result; +} + +/** + * @param {number[]} sortedArray + * @param {number} value + */ +function binarySearch(sortedArray, value) { + let low = 0; + let high = sortedArray.length - 1; + + while (low <= high) { + const mid = (low + high) >> 1; + const midValue = sortedArray[mid]; + + if (midValue === value) return true; + if (midValue < value) low = mid + 1; + else high = mid - 1; + } + + return false; +} + +/** + * @param {number} wordLength + * @param {number} round + */ +function pickTrigramPosition(wordLength, round) { + const maxPosition = wordLength - 3; + if (maxPosition < 0) return -1; + + if (round === 0) return 0; + if (round === 1 && maxPosition > 0) return maxPosition; + if (round === 2 && maxPosition > 1) return maxPosition >> 1; + if (maxPosition <= 2) return -1; + + const middle = maxPosition >> 1; + const offset = (round - 2) >> 1; + const position = round & 1 ? Math.max(0, middle - offset) : middle + offset; + + if (position === 0 || position >= maxPosition || position === middle) { + return -1; + } + + return position; +} diff --git a/modules/unpkg.sh b/modules/unpkg.sh index 081fced87..7e55596dd 100755 --- a/modules/unpkg.sh +++ b/modules/unpkg.sh @@ -405,6 +405,6 @@ main() { # Run the main function with all arguments # main "$@" -main "@leeoniya/ufuzzy" +main "quickmatch-js" main "lean-qr" main "lightweight-charts" diff --git a/website/scripts/entry.js b/website/scripts/entry.js index 8420ae894..4fb68d8b4 100644 --- a/website/scripts/entry.js +++ b/website/scripts/entry.js @@ -1,6 +1,4 @@ /** - * @import * as _ from "./modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.d.ts" - * * @import { IChartApi, ISeriesApi as _ISeriesApi, SeriesDefinition, SingleValueData as _SingleValueData, CandlestickData as _CandlestickData, BaselineData as _BaselineData, HistogramData as _HistogramData, SeriesType as LCSeriesType, IPaneApi, LineSeriesPartialOptions as _LineSeriesPartialOptions, HistogramSeriesPartialOptions as _HistogramSeriesPartialOptions, BaselineSeriesPartialOptions as _BaselineSeriesPartialOptions, CandlestickSeriesPartialOptions as _CandlestickSeriesPartialOptions, WhitespaceData, DeepPartial, ChartOptions, Time, LineData as _LineData, createChart as CreateLCChart, LineStyle, createSeriesMarkers as CreateSeriesMarkers, SeriesMarker, ISeriesMarkersPluginApi } from './modules/lightweight-charts/5.1.0/dist/typings.js' * * @import * as Brk from "./modules/brk-client/index.js" diff --git a/website/scripts/options/full.js b/website/scripts/options/full.js index 456db07ed..75a2b3f43 100644 --- a/website/scripts/options/full.js +++ b/website/scripts/options/full.js @@ -109,6 +109,7 @@ export function initOptions(brk) { * @param {Option} option */ function selectOption(option) { + if (selected.value === option) return; pushHistory(option.path); resetParams(option); writeToStorage(LS_SELECTED_KEY, JSON.stringify(option.path)); diff --git a/website/scripts/panes/search.js b/website/scripts/panes/search.js index f5b8e850d..e5f504a56 100644 --- a/website/scripts/panes/search.js +++ b/website/scripts/panes/search.js @@ -3,7 +3,7 @@ import { searchLabelElement, searchResultsElement, } from "../utils/elements.js"; -import ufuzzy from "../modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.mjs"; +import { QuickMatch } from "../modules/quickmatch-js/0.3.1/src/index.js"; /** * @param {Options} options @@ -11,148 +11,47 @@ import ufuzzy from "../modules/leeoniya-ufuzzy/1.0.19/dist/uFuzzy.mjs"; export function initSearch(options) { console.log("search: init"); - const haystack = options.list.map((option) => option.title); - - const RESULTS_PER_PAGE = 100; - - /** - * @param {uFuzzy.SearchResult} searchResult - * @param {number} pageIndex - */ - function computeResultPage(searchResult, pageIndex) { - /** @type {{ option: Option, title: string }[]} */ - let list = []; - - let [indexes, _info, order] = searchResult || [null, null, null]; - - const minIndex = pageIndex * RESULTS_PER_PAGE; - - if (indexes?.length) { - const maxIndex = Math.min( - (order || indexes).length - 1, - minIndex + RESULTS_PER_PAGE - 1, - ); - - list = Array(maxIndex - minIndex + 1); - - for (let i = minIndex; i <= maxIndex; i++) { - let index = indexes[i]; - - const title = haystack[index]; - - list[i % 100] = { - option: options.list[index], - title, - }; - } - } - - return list; - } - - /** @type {uFuzzy.Options} */ - const config = { - intraIns: Infinity, - intraChars: `[a-z\d' ]`, - }; - - const fuzzyMultiInsert = /** @type {uFuzzy} */ ( - ufuzzy({ - intraIns: 1, - }) - ); - const fuzzyMultiInsertFuzzier = /** @type {uFuzzy} */ (ufuzzy(config)); - const fuzzySingleError = /** @type {uFuzzy} */ ( - ufuzzy({ - intraMode: 1, - ...config, - }) - ); - const fuzzySingleErrorFuzzier = /** @type {uFuzzy} */ ( - ufuzzy({ - intraMode: 1, - ...config, - }) + const haystack = options.list.map((option) => option.title.toLowerCase()); + const titleToOption = new Map( + options.list.map((option) => [option.title.toLowerCase(), option]), ); + const matcher = new QuickMatch(haystack); + function inputEvent() { - const needle = /** @type {string} */ (searchInput.value); + const needle = /** @type {string} */ (searchInput.value).trim(); - searchResultsElement.scrollTo({ - top: 0, - }); + searchResultsElement.scrollTo({ top: 0 }); + searchResultsElement.innerHTML = ""; - if (!needle) { - searchResultsElement.innerHTML = ""; + if (needle.length < 3) { + const li = window.document.createElement("li"); + li.textContent = 'e.g. "BTC"'; + li.style.color = "var(--off-color)"; + searchResultsElement.appendChild(li); return; } - const outOfOrder = 5; - const infoThresh = 5_000; + const matches = matcher.matches(needle); - let result = fuzzyMultiInsert?.search( - haystack, - needle, - undefined, - infoThresh, - ); - - if (!result?.[0]?.length || !result?.[1]) { - result = fuzzyMultiInsert?.search( - haystack, - needle, - outOfOrder, - infoThresh, - ); + if (!matches.length) { + const li = window.document.createElement("li"); + li.textContent = "No results"; + li.style.color = "var(--off-color)"; + searchResultsElement.appendChild(li); + return; } - if (!result?.[0]?.length || !result?.[1]) { - result = fuzzySingleError?.search( - haystack, - needle, - outOfOrder, - infoThresh, - ); - } + matches.forEach((title) => { + const option = titleToOption.get(title); + if (!option) return; - if (!result?.[0]?.length || !result?.[1]) { - result = fuzzySingleErrorFuzzier?.search( - haystack, - needle, - outOfOrder, - infoThresh, - ); - } - - if (!result?.[0]?.length || !result?.[1]) { - result = fuzzyMultiInsertFuzzier?.search( - haystack, - needle, - undefined, - infoThresh, - ); - } - - if (!result?.[0]?.length || !result?.[1]) { - result = fuzzyMultiInsertFuzzier?.search( - haystack, - needle, - outOfOrder, - infoThresh, - ); - } - - searchResultsElement.innerHTML = ""; - - const list = computeResultPage(result, 0); - - list.forEach(({ option, title }) => { const li = window.document.createElement("li"); searchResultsElement.appendChild(li); const element = options.createOptionElement({ option, - name: title, + name: option.title, }); if (element) { @@ -161,11 +60,11 @@ export function initSearch(options) { }); } - if (searchInput.value) { - inputEvent(); - } + inputEvent(); searchInput.addEventListener("input", inputEvent); + const len = searchInput.value.length; + searchInput.setSelectionRange(len, len); } document.addEventListener("keydown", (e) => { diff --git a/website/scripts/utils/dom.js b/website/scripts/utils/dom.js index 603f4bf3f..8b7d96e9e 100644 --- a/website/scripts/utils/dom.js +++ b/website/scripts/utils/dom.js @@ -165,52 +165,6 @@ export function createLabeledInput({ }; } -/** - * @param {HTMLElement} parent - * @param {HTMLElement} child - * @param {number} index - */ -export function insertElementAtIndex(parent, child, index) { - if (!index) index = 0; - if (index >= parent.children.length) { - parent.appendChild(child); - } else { - parent.insertBefore(child, parent.children[index]); - } -} - -/** - * @param {string} url - * @param {boolean} [targetBlank] - */ -export function open(url, targetBlank) { - console.log(`open: ${url}`); - const a = window.document.createElement("a"); - window.document.body.append(a); - a.href = url; - - if (targetBlank) { - a.target = "_blank"; - a.rel = "noopener noreferrer"; - } - - a.click(); - a.remove(); -} - -/** - * @param {string} href - */ -export function importStyle(href) { - const link = document.createElement("link"); - link.href = href; - link.type = "text/css"; - link.rel = "stylesheet"; - link.media = "screen,print"; - const head = window.document.getElementsByTagName("head")[0]; - head.appendChild(link); - return link; -} /** * @template T @@ -233,9 +187,6 @@ export function createRadios({ const field = window.document.createElement("div"); field.classList.add("field"); - const div = window.document.createElement("div"); - field.append(div); - const initialKey = toKey(initialValue); /** @param {string} key */ @@ -245,7 +196,7 @@ export function createRadios({ if (choices.length === 1) { const span = window.document.createElement("span"); span.textContent = toLabel(choices[0]); - div.append(span); + field.append(span); } else { const fieldId = id ?? ""; choices.forEach((choice) => { @@ -261,7 +212,7 @@ export function createRadios({ const text = window.document.createTextNode(choiceLabel); label.append(text); - div.append(label); + field.append(label); }); field.addEventListener("change", (event) => { @@ -297,9 +248,8 @@ export function createSelect({ ? unsortedChoices.toSorted((a, b) => toLabel(a).localeCompare(toLabel(b))) : unsortedChoices; - const select = window.document.createElement("select"); - select.id = id ?? ""; - select.name = id ?? ""; + const field = window.document.createElement("div"); + field.classList.add("field"); const initialKey = toKey(initialValue); @@ -307,21 +257,39 @@ export function createSelect({ const fromKey = (key) => choices.find((c) => toKey(c) === key) ?? initialValue; - choices.forEach((choice) => { - const option = window.document.createElement("option"); - option.value = toKey(choice); - option.textContent = toLabel(choice); - if (toKey(choice) === initialKey) { - option.selected = true; + if (choices.length === 1) { + const span = window.document.createElement("span"); + span.textContent = toLabel(choices[0]); + field.append(span); + } else { + const select = window.document.createElement("select"); + select.id = id ?? ""; + select.name = id ?? ""; + field.append(select); + + choices.forEach((choice) => { + const option = window.document.createElement("option"); + option.value = toKey(choice); + option.textContent = toLabel(choice); + if (toKey(choice) === initialKey) { + option.selected = true; + } + select.append(option); + }); + + select.addEventListener("change", () => { + onChange?.(fromKey(select.value)); + }); + + const remaining = choices.length - 1; + if (remaining > 0) { + const small = window.document.createElement("small"); + small.textContent = `+${remaining}`; + field.append(small); } - select.append(option); - }); + } - select.addEventListener("change", () => { - onChange?.(fromKey(select.value)); - }); - - return select; + return field; } /** @@ -361,39 +329,6 @@ export function createOption(arg) { } -/** - * @param {Object} args - * @param {string} args.title - * @param {string} args.description - * @param {HTMLElement} args.input - */ -export function createFieldElement({ title, description, input }) { - const div = window.document.createElement("div"); - - const label = window.document.createElement("label"); - div.append(label); - - const titleElement = window.document.createElement("span"); - titleElement.innerHTML = title; - label.append(titleElement); - - const descriptionElement = window.document.createElement("small"); - descriptionElement.innerHTML = description; - label.append(descriptionElement); - - div.append(input); - - const forId = input.id || input.firstElementChild?.id; - - if (!forId) { - console.log(input); - throw `Input should've an ID`; - } - - label.htmlFor = forId; - - return div; -} /** * @param {'left' | 'bottom' | 'top' | 'right'} position