diff --git a/.gitignore b/.gitignore index 37df23f58..b339d7ff6 100644 --- a/.gitignore +++ b/.gitignore @@ -203,3 +203,10 @@ id_ed25519.pub # node_modules node_modules/ + +# Translation temp files +*_compact.json +*compact*.json +test_batch.json +*.backup.*.json +frontend/public/locales/*/translation.backup*.json diff --git a/frontend/index.html b/frontend/index.html index 31f1b3008..b563bdcd8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,6 +2,7 @@
+Opening Swagger UI in a new tab...
If it didn't open automatically,{" "} - + click here
diff --git a/frontend/src/utils/fuzzySearch.ts b/frontend/src/utils/fuzzySearch.ts new file mode 100644 index 000000000..e8e8bdf01 --- /dev/null +++ b/frontend/src/utils/fuzzySearch.ts @@ -0,0 +1,121 @@ +// Lightweight fuzzy search helpers without external deps +// Provides diacritics-insensitive normalization and Levenshtein distance scoring + +function normalizeText(text: string): string { + return text + .toLowerCase() + .normalize('NFD') + .replace(/\p{Diacritic}+/gu, '') + .trim(); +} + +// Basic Levenshtein distance (iterative with two rows) +function levenshtein(a: string, b: string): number { + if (a === b) return 0; + const aLen = a.length; + const bLen = b.length; + if (aLen === 0) return bLen; + if (bLen === 0) return aLen; + + const prev = new Array(bLen + 1); + const curr = new Array(bLen + 1); + + for (let j = 0; j <= bLen; j++) prev[j] = j; + + for (let i = 1; i <= aLen; i++) { + curr[0] = i; + const aChar = a.charCodeAt(i - 1); + for (let j = 1; j <= bLen; j++) { + const cost = aChar === b.charCodeAt(j - 1) ? 0 : 1; + curr[j] = Math.min( + prev[j] + 1, // deletion + curr[j - 1] + 1, // insertion + prev[j - 1] + cost // substitution + ); + } + for (let j = 0; j <= bLen; j++) prev[j] = curr[j]; + } + return curr[bLen]; +} + +// Compute a heuristic match score (higher is better) +// 1) Exact/substring hits get high base; 2) otherwise use normalized Levenshtein distance +export function scoreMatch(queryRaw: string, targetRaw: string): number { + const query = normalizeText(queryRaw); + const target = normalizeText(targetRaw); + if (!query) return 0; + if (target.includes(query)) { + // Reward earlier/shorter substring matches + const pos = target.indexOf(query); + return 100 - pos - Math.max(0, target.length - query.length); + } + + // Token-aware: check each word token too, but require better similarity + const tokens = target.split(/[^a-z0-9]+/g).filter(Boolean); + for (const token of tokens) { + if (token.includes(query)) { + // Only give high score if the match is substantial (not just "and" matching) + const similarity = query.length / Math.max(query.length, token.length); + if (similarity >= 0.6) { // Require at least 60% similarity + return 80 - Math.abs(token.length - query.length); + } + } + } + + const distance = levenshtein(query, target.length > 64 ? target.slice(0, 64) : target); + const maxLen = Math.max(query.length, target.length, 1); + const similarity = 1 - distance / maxLen; // 0..1 + return Math.floor(similarity * 60); // scale below substring scores +} + +export function minScoreForQuery(query: string): number { + const len = normalizeText(query).length; + if (len <= 3) return 40; + if (len <= 6) return 30; + return 25; +} + +// Decide if a target matches a query based on a threshold +export function isFuzzyMatch(query: string, target: string, minScore?: number): boolean { + const threshold = typeof minScore === 'number' ? minScore : minScoreForQuery(query); + return scoreMatch(query, target) >= threshold; +} + +// Convenience: rank a list of items by best score across provided getters +export function rankByFuzzy