File size: 3,722 Bytes
fc93158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
/**
 * Shared fuzzy filtering utilities for select list components.
 */

/**
 * Word boundary characters for matching.
 */
const WORD_BOUNDARY_CHARS = /[\s\-_./:#@]/;

/**
 * Check if position is at a word boundary.
 */
export function isWordBoundary(text: string, index: number): boolean {
  return index === 0 || WORD_BOUNDARY_CHARS.test(text[index - 1] ?? "");
}

/**
 * Find index where query matches at a word boundary in text.
 * Returns null if no match.
 */
export function findWordBoundaryIndex(text: string, query: string): number | null {
  if (!query) {
    return null;
  }
  const textLower = text.toLowerCase();
  const queryLower = query.toLowerCase();
  const maxIndex = textLower.length - queryLower.length;
  if (maxIndex < 0) {
    return null;
  }
  for (let i = 0; i <= maxIndex; i++) {
    if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) {
      return i;
    }
  }
  return null;
}

/**
 * Fuzzy match with pre-lowercased inputs (avoids toLowerCase on every keystroke).
 * Returns score (lower = better) or null if no match.
 */
export function fuzzyMatchLower(queryLower: string, textLower: string): number | null {
  if (queryLower.length === 0) {
    return 0;
  }
  if (queryLower.length > textLower.length) {
    return null;
  }

  let queryIndex = 0;
  let score = 0;
  let lastMatchIndex = -1;
  let consecutiveMatches = 0;

  for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
    if (textLower[i] === queryLower[queryIndex]) {
      const isAtWordBoundary = isWordBoundary(textLower, i);
      if (lastMatchIndex === i - 1) {
        consecutiveMatches++;
        score -= consecutiveMatches * 5; // Reward consecutive matches
      } else {
        consecutiveMatches = 0;
        if (lastMatchIndex >= 0) {
          score += (i - lastMatchIndex - 1) * 2;
        } // Penalize gaps
      }
      if (isAtWordBoundary) {
        score -= 10;
      } // Reward word boundary matches
      score += i * 0.1; // Slight penalty for later matches
      lastMatchIndex = i;
      queryIndex++;
    }
  }
  return queryIndex < queryLower.length ? null : score;
}

/**
 * Filter items using pre-lowercased searchTextLower field.
 * Supports space-separated tokens (all must match).
 */
export function fuzzyFilterLower<T extends { searchTextLower?: string }>(
  items: T[],
  queryLower: string,
): T[] {
  const trimmed = queryLower.trim();
  if (!trimmed) {
    return items;
  }

  const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0);
  if (tokens.length === 0) {
    return items;
  }

  const results: { item: T; score: number }[] = [];
  for (const item of items) {
    const text = item.searchTextLower ?? "";
    let totalScore = 0;
    let allMatch = true;
    for (const token of tokens) {
      const score = fuzzyMatchLower(token, text);
      if (score !== null) {
        totalScore += score;
      } else {
        allMatch = false;
        break;
      }
    }
    if (allMatch) {
      results.push({ item, score: totalScore });
    }
  }
  results.sort((a, b) => a.score - b.score);
  return results.map((r) => r.item);
}

/**
 * Prepare items for fuzzy filtering by pre-computing lowercase search text.
 */
export function prepareSearchItems<
  T extends { label?: string; description?: string; searchText?: string },
>(items: T[]): (T & { searchTextLower: string })[] {
  return items.map((item) => {
    const parts: string[] = [];
    if (item.label) {
      parts.push(item.label);
    }
    if (item.description) {
      parts.push(item.description);
    }
    if (item.searchText) {
      parts.push(item.searchText);
    }
    return { ...item, searchTextLower: parts.join(" ").toLowerCase() };
  });
}