/** * Composable para búsqueda fuzzy de texto * Normaliza acentos y es tolerante a typos básicos */ export const useFuzzySearch = () => { /** * Normaliza un texto para comparación: * - Convierte a minúsculas * - Elimina acentos/diacríticos * - Elimina espacios extra */ const normalize = (text: string): string => { return text .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .trim() } /** * Verifica si el texto contiene la query (fuzzy) * Retorna true si hay match */ const fuzzyMatch = (text: string, query: string): boolean => { if (!query) return true if (!text) return false const normalizedText = normalize(text) const normalizedQuery = normalize(query) // Match exacto (substring) if (normalizedText.includes(normalizedQuery)) return true // Match por palabras - cada palabra de la query debe coincidir con alguna palabra del texto const textWords = normalizedText.split(/\s+/) const queryWords = normalizedQuery.split(/\s+/) return queryWords.every(queryWord => textWords.some(textWord => textWord.startsWith(queryWord) || textWord.includes(queryWord) ) ) } /** * Calcula un score de relevancia (0-100) * Mayor score = mejor match */ const fuzzyScore = (text: string, query: string): number => { if (!query) return 100 if (!text) return 0 const normalizedText = normalize(text) const normalizedQuery = normalize(query) // Match exacto completo if (normalizedText === normalizedQuery) return 100 // Match al inicio if (normalizedText.startsWith(normalizedQuery)) return 90 // Match exacto (substring) if (normalizedText.includes(normalizedQuery)) { const position = normalizedText.indexOf(normalizedQuery) return 80 - (position / normalizedText.length) * 20 } // Match por palabras const textWords = normalizedText.split(/\s+/) const queryWords = normalizedQuery.split(/\s+/) let matchedWords = 0 let startMatches = 0 queryWords.forEach(queryWord => { const match = textWords.find(textWord => textWord.startsWith(queryWord) || textWord.includes(queryWord) ) if (match) { matchedWords++ if (match.startsWith(queryWord)) startMatches++ } }) if (matchedWords === 0) return 0 const matchRatio = matchedWords / queryWords.length const startBonus = startMatches / queryWords.length * 10 return Math.round(matchRatio * 60 + startBonus) } /** * Filtra y ordena una lista por relevancia de búsqueda */ const filterAndSort = ( items: T[], query: string, getText: (item: T) => string ): T[] => { if (!query) return items return items .map(item => ({ item, score: fuzzyScore(getText(item), query) })) .filter(({ score }) => score > 0) .sort((a, b) => b.score - a.score) .map(({ item }) => item) } return { normalize, fuzzyMatch, fuzzyScore, filterAndSort } }