1import { BaseAutocompleteExtension } from "controllers/autocomplete_controller"; 2 3export default class extends BaseAutocompleteExtension { 4 static values = { 5 // This controller is specifically for fuzzy search functionality 6 // If you don't need fuzzy search, don't add this controller 7 fuzzyThreshold: { type: Number, default: 0.1 }, // Minimum score for fuzzy matches (0-1) 8 }; 9 10 initialize() { 11 // Check if autocomplete controller is available before proceeding 12 if (!this.autocompleteController) { 13 return; 14 } 15 16 // Override the searchLocalData method for fuzzy search 17 this.overrideMethod("searchLocalData", this.searchLocalDataWithFuzzy); 18 19 // Override the highlightText method for fuzzy highlighting 20 this.overrideMethod("highlightText", this.highlightTextWithFuzzy); 21 } 22 23 searchLocalDataWithFuzzy(query) { 24 const currentData = this.autocompleteController.localDataValue; 25 const suggestions = this.fuzzyFilter(currentData, query); 26 this.autocompleteController.displaySuggestions(suggestions, query); 27 } 28 29 highlightTextWithFuzzy(text, query) { 30 // Fuzzy highlighting - highlight matching characters 31 return this.highlightFuzzyMatch(text, query); 32 } 33 34 // Fuzzy search implementation 35 fuzzyFilter(items, query) { 36 const scored = items 37 .map((item) => { 38 const text = typeof item === "string" ? item : item.title || item.name || item.label; 39 const score = this.fuzzyScore(text.toLowerCase(), query.toLowerCase()); 40 return { item, score, text }; 41 }) 42 .filter((result) => result.score > this.fuzzyThresholdValue) // Filter out items with very low scores 43 .sort((a, b) => b.score - a.score) 44 .slice(0, this.autocompleteController.maxSuggestionsValue); 45 46 return scored.map((result) => result.item); 47 } 48 49 fuzzyScore(text, query) { 50 // Perfect match 51 if (text === query) return 1.0; 52 53 // Contains exact query 54 if (text.includes(query)) return 0.9; 55 56 // Normalize: remove spaces and convert to lowercase for better matching 57 const normalizedText = text.replace(/\s+/g, "").toLowerCase(); 58 const normalizedQuery = query.replace(/\s+/g, "").toLowerCase(); 59 60 // Check if normalized query is contained in normalized text 61 if (normalizedText.includes(normalizedQuery)) return 0.85; 62 63 const textLen = normalizedText.length; 64 const queryLen = normalizedQuery.length; 65 66 if (queryLen === 0) return 1.0; 67 if (queryLen > textLen) return 0.0; 68 69 // Check for subsequence match with improved scoring 70 let textIndex = 0; 71 let queryIndex = 0; 72 let matches = 0; 73 let consecutiveMatches = 0; 74 let maxConsecutive = 0; 75 let firstMatchIndex = -1; 76 let lastMatchIndex = -1; 77 78 while (textIndex < textLen && queryIndex < queryLen) { 79 if (normalizedText[textIndex] === normalizedQuery[queryIndex]) { 80 if (firstMatchIndex === -1) firstMatchIndex = textIndex; 81 lastMatchIndex = textIndex; 82 matches++; 83 consecutiveMatches++; 84 maxConsecutive = Math.max(maxConsecutive, consecutiveMatches); 85 queryIndex++; 86 } else { 87 consecutiveMatches = 0; 88 } 89 textIndex++; 90 } 91 92 // If not all characters were matched, return 0 93 if (queryIndex < queryLen) return 0.0; 94 95 // Calculate score with multiple factors 96 const matchRatio = matches / queryLen; 97 const consecutiveBonus = (maxConsecutive / queryLen) * 0.3; 98 99 // Bonus for matches at the beginning of the text 100 const startBonus = firstMatchIndex === 0 ? 0.2 : 0; 101 102 // Penalty for spread out matches (compactness bonus) 103 const matchSpread = lastMatchIndex - firstMatchIndex + 1; 104 const compactBonus = Math.max(0, (queryLen - matchSpread + queryLen) / (textLen * 2)) * 0.2; 105 106 // Small penalty for length difference 107 const lengthPenalty = Math.abs(queryLen - textLen) / (textLen * 10); 108 109 return Math.max(0, Math.min(1.0, matchRatio + consecutiveBonus + startBonus + compactBonus - lengthPenalty)); 110 } 111 112 highlightFuzzyMatch(text, query) { 113 const lowerText = text.toLowerCase(); 114 // Remove spaces from query for fuzzy matching 115 const normalizedQuery = query.replace(/\s+/g, "").toLowerCase(); 116 const result = []; 117 let textIndex = 0; 118 let queryIndex = 0; 119 120 while (textIndex < text.length) { 121 const currentChar = lowerText[textIndex]; 122 123 // Skip spaces in the original text when matching, but include them in output 124 if (currentChar === " ") { 125 result.push(text[textIndex]); 126 textIndex++; 127 continue; 128 } 129
130 if (queryIndex < normalizedQuery.length && currentChar === normalizedQuery[queryIndex]) { 131 // This character matches the query 132 result.push(`<mark class="bg-yellow-200 dark:bg-yellow-400 font-medium">${text[textIndex]}</mark>`); 133 queryIndex++; 134 } else { 135 // Regular character 136 result.push(text[textIndex]); 137 } 138 textIndex++; 139 } 140 141 return result.join(""); 142 } 143};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.