mirror of
https://github.com/avinal/sciezka.git
synced 2026-07-03 23:30:09 +05:30
4f69f3b3f4
Custom fzy algorithm with fulltext and prefix modes, boxy dark-aware UI with keyboard navigation, mode switching, match highlighting, and auto-resizing iframe overlay. Assisted-by: Claude Code Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
144 lines
4.3 KiB
TypeScript
144 lines
4.3 KiB
TypeScript
import type { SearchItem, SearchMethod, SearchResult } from "./types";
|
|
|
|
const SCORE_GAP_LEADING = -0.005;
|
|
const SCORE_GAP_TRAILING = -0.005;
|
|
const SCORE_GAP_INNER = -0.01;
|
|
const SCORE_MATCH_CONSECUTIVE = 1.0;
|
|
const SCORE_MATCH_SLASH = 0.9;
|
|
const SCORE_MATCH_WORD = 0.8;
|
|
const SCORE_MATCH_CAPITAL = 0.7;
|
|
const SCORE_MATCH_DOT = 0.6;
|
|
const BONUS_FIRST_CHAR = 0.6;
|
|
|
|
function isSlash(c: string): boolean {
|
|
return c === "/" || c === "\\";
|
|
}
|
|
|
|
function computeBonus(prev: string, curr: string): number {
|
|
if (isSlash(prev)) return SCORE_MATCH_SLASH;
|
|
if (prev === "_" || prev === "-" || prev === " ") return SCORE_MATCH_WORD;
|
|
if (prev === ".") return SCORE_MATCH_DOT;
|
|
if (prev === prev.toLowerCase() && curr === curr.toUpperCase()) return SCORE_MATCH_CAPITAL;
|
|
return 0;
|
|
}
|
|
|
|
function fuzzyMatch(needle: string, haystack: string): { score: number; positions: number[] } | null {
|
|
const n = needle.length;
|
|
const m = haystack.length;
|
|
if (n === 0) return { score: 0, positions: [] };
|
|
if (n > m) return null;
|
|
|
|
const needleLower = needle.toLowerCase();
|
|
const haystackLower = haystack.toLowerCase();
|
|
|
|
let ni = 0;
|
|
for (let hi = 0; hi < m; hi++) {
|
|
if (needleLower[ni] === haystackLower[hi]) ni++;
|
|
if (ni === n) break;
|
|
}
|
|
if (ni < n) return null;
|
|
|
|
const D: number[][] = Array.from({ length: n }, () => new Array(m).fill(0));
|
|
const M: number[][] = Array.from({ length: n }, () => new Array(m).fill(0));
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
let prevScore = -Infinity;
|
|
let gapScore = i === n - 1 ? SCORE_GAP_TRAILING : SCORE_GAP_INNER;
|
|
|
|
for (let j = 0; j < m; j++) {
|
|
if (needleLower[i] === haystackLower[j]) {
|
|
let score = 0;
|
|
if (i === 0) {
|
|
score = j * SCORE_GAP_LEADING;
|
|
if (j === 0) score += BONUS_FIRST_CHAR;
|
|
else score += computeBonus(haystack[j - 1], haystack[j]);
|
|
} else if (j > 0) {
|
|
const bonus = computeBonus(haystack[j - 1], haystack[j]);
|
|
score = Math.max(
|
|
M[i - 1][j - 1] + bonus,
|
|
D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE
|
|
);
|
|
}
|
|
D[i][j] = score;
|
|
M[i][j] = Math.max(score, prevScore + gapScore);
|
|
} else {
|
|
D[i][j] = -Infinity;
|
|
M[i][j] = prevScore + gapScore;
|
|
}
|
|
prevScore = M[i][j];
|
|
}
|
|
}
|
|
|
|
let bestJ = 0;
|
|
for (let j = 1; j < m; j++) {
|
|
if (M[n - 1][j] > M[n - 1][bestJ]) bestJ = j;
|
|
}
|
|
|
|
const positions: number[] = new Array(n);
|
|
let i = n - 1;
|
|
let j = bestJ;
|
|
while (i >= 0 && j >= 0) {
|
|
if (D[i][j] !== -Infinity && (i === 0 || j === 0 || D[i][j] >= M[i][j])) {
|
|
positions[i] = j;
|
|
i--;
|
|
j--;
|
|
} else {
|
|
j--;
|
|
}
|
|
}
|
|
|
|
return { score: M[n - 1][bestJ], positions };
|
|
}
|
|
|
|
function fulltextMatch(needle: string, haystack: string): { score: number; positions: number[] } | null {
|
|
const idx = haystack.toLowerCase().indexOf(needle.toLowerCase());
|
|
if (idx === -1) return null;
|
|
|
|
const positions = Array.from({ length: needle.length }, (_, i) => idx + i);
|
|
let score = 1.0;
|
|
if (idx === 0) score += 0.5;
|
|
score -= idx * 0.01;
|
|
return { score, positions };
|
|
}
|
|
|
|
function prefixMatch(needle: string, haystack: string): { score: number; positions: number[] } | null {
|
|
const words = haystack.split(/[\s\-_/.:]+/);
|
|
const needleLower = needle.toLowerCase();
|
|
let offset = 0;
|
|
|
|
for (const word of words) {
|
|
if (word.toLowerCase().startsWith(needleLower)) {
|
|
const start = haystack.toLowerCase().indexOf(word.toLowerCase(), offset);
|
|
const positions = Array.from({ length: needle.length }, (_, i) => start + i);
|
|
let score = 1.0;
|
|
if (start === 0) score += 0.5;
|
|
return { score, positions };
|
|
}
|
|
offset += word.length + 1;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function search(items: SearchItem[], query: string, method: SearchMethod): SearchResult[] {
|
|
if (!query) {
|
|
return items.map((item) => ({ item, score: 0, positions: [] }));
|
|
}
|
|
|
|
const matchFn = method === "fuzzy" ? fuzzyMatch
|
|
: method === "fulltext" ? fulltextMatch
|
|
: prefixMatch;
|
|
|
|
const results: SearchResult[] = [];
|
|
|
|
for (const item of items) {
|
|
const haystack = `${item.title} ${item.url}`;
|
|
const match = matchFn(query, haystack);
|
|
if (match) {
|
|
results.push({ item, score: match.score, positions: match.positions });
|
|
}
|
|
}
|
|
|
|
results.sort((a, b) => b.score - a.score);
|
|
return results;
|
|
}
|