import type { ComputedRef, MaybeRefOrGetter } from 'vue' import { computed, ref, watch, toValue } from 'vue' const htmlReplacers = { '': /<[^>]*>?/g, ' ': / /g, '"': /"/g, '\'': /'/g, '&': /&/g, '<': /</g, '>': />/g, } as const /** * Clears HTML tags from a string * @param html - HTML string to clean * @returns Plain text without HTML tags */ export function clearHtml(value?: string) { if (!value) { return '' } return Object.entries(htmlReplacers) .reduce( ( text, [replacement, regex] ) => ( text.replace(regex, replacement) ), value ) } const normalizeSearchChars = /[\p{N}\p{L}\s]/gu /** * Normalizes text by removing special characters and converting to lowercase * @param text - Text to normalize * @returns Normalized text */ export function normalizeText(text: string): string { return text.match(normalizeSearchChars)?.join('').toLowerCase() ?? '' } /** * Gets a value from an object by path * Supports nested paths like "user.address.street" * @param obj - The object to get the value from * @param path - The path to the value (e.g., "user.address.street") * @returns The value at the path or undefined if not found */ function getValueByPath(obj: any, path: string): any { const keys = path.split('.') let result = obj for (const key of keys) { if (result === null || result === undefined || typeof result !== 'object') { return undefined } result = result[key] } return result } /** * Calculate relevance score based on search terms matching * Higher score = more relevant * @param stringValue - Text to check * @param searchTerms - Array of search terms * @returns Relevance score */ function calculateRelevance(stringValue: string, searchTerms: string[]): number { let score = 0 if (stringValue.length === 0) { return 0 } // Track matched character count for density calculation let totalMatchedChars = 0 // Base score is the number of terms that match for (const term of searchTerms) { if (stringValue.includes(term)) { // Count occurrences for total matched text calculation const regex = new RegExp(term, 'g') const matches = stringValue.match(regex) if (matches) { totalMatchedChars += matches.length * term.length } score += 1 // Give additional weight to exact matches (not just contains) // Example: "jordan" is more relevant for "jordan" than "jordanian history" const words = stringValue.split(/\s+/) if (words.includes(term)) { score += 0.5 } // Give more weight to matches at the beginning of the text if (stringValue.startsWith(term)) { score += 0.5 } } } // Calculate the percentage of text that was matched (0 to 1) // Cap at 1.0 for cases where the same text is matched multiple times const matchDensity = Math.min(1.0, totalMatchedChars / stringValue.length) // Weight by match density (multiply by a factor to make it significant) // This gives higher relevance to fields where the match covers more of the content score *= (1.0 + matchDensity * 2) return score } export interface SearchItemParams { searchTerm?: MaybeRefOrGetter items?: MaybeRefOrGetter keysToSearch?: string[] // Key paths to search within items fieldWeights?: Record // Use simple string keys for weights minChars?: number // Minimum characters required to trigger search serverSearch?: (query: string) => Promise // Function to perform server-side search debounceMs?: number // Debounce time for server requests in milliseconds } export interface SearchResult { results: ComputedRef resultCount: ComputedRef hasResults: ComputedRef isSearching: ComputedRef isLoading: ComputedRef // New property to track server-side loading state searchTerm?: import('vue').Ref // Optional searchTerm ref for simplified usage } /** * Check if a value is a primitive type (string, number, boolean) * @param value - The value to check * @returns True if the value is a primitive */ function isPrimitive(value: any): boolean { return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' } /** * Safely convert any value to a searchable string * @param value - Value to convert * @returns String representation or empty string if value can't be converted */ function toSearchableString(value: any): string { if (value === null || value === undefined) { return '' } if (typeof value === 'string') { return clearHtml(value) } if (typeof value === 'number' || typeof value === 'boolean') { return String(value) } if (typeof value === 'object' && value instanceof Date) { return value.toISOString() } return '' } /** * Generic search function that searches for a term within specified object properties * If keysToSearch is not provided, searches all keys (including nested ones) * @param params - Search parameters including searchTerm, items, keys and weights * @returns Filtered and sorted array of items that match the search terms */ export function searchItems(params: SearchItemParams): T[] { const { searchTerm, items = [], keysToSearch, fieldWeights = {}, minChars = 2 } = params // Handle both string and ref values const searchValue = toValue(searchTerm) const itemsArray = toValue(items) || [] // Return all items if search is empty or doesn't meet minimum character threshold if (!searchValue || searchValue.length < minChars) { return itemsArray } // Split search term into individual words for better matching const searchTerms = normalizeText(searchValue) .split(/\s+/) .filter(term => term.length > 1) if (searchTerms.length === 0) { return itemsArray } // ---- Field weight configuration ---- // Default field weights for common fields const defaultWeights: Record = { // Give higher weight to name/title fields name: 2, first_name: 2, last_name: 2, title: 2, headline: 2, // Lower weight for longer text fields description: 0.7, subtitle: 0.7, bio: 0.7, content: 0.7, body: 0.7, email: 0.7, phone: 0.7, // Even lower for metadata id: 0.3, created_at: 0.3, updated_at: 0.3 } /** * Get the weight for a specific field path * @param path - Path to the field * @returns Appropriate weight for the field */ function getFieldWeight(path: string): number { // First check custom weights if (path in fieldWeights) { const customWeight = fieldWeights[path] if (typeof customWeight === 'number') { return customWeight } } // Try with just the leaf key (last part of the path) const leafKey = path.split('.').pop() || '' if (leafKey in defaultWeights) { return defaultWeights[leafKey] } // Default weight return 1 } /** * Recursively collect all keys from an object, including nested ones * @param obj - Object to collect keys from * @param prefix - Prefix for nested keys * @returns Array of flattened key paths */ function collectAllKeys(obj: any, prefix = ''): string[] { if (!obj || typeof obj !== 'object') { return [] } return Object.keys(obj).flatMap((key) => { const value = obj[key] const newPrefix = prefix ? `${prefix}.${key}` : key if (value && typeof value === 'object' && !Array.isArray(value)) { // Recurse for nested objects return [newPrefix, ...collectAllKeys(value, newPrefix)] } return [newPrefix] }) } /** * Calculate relevance score for a single item against search terms * @param item - Item to score * @returns Relevance score */ function calculateItemRelevance(item: any): number { let totalRelevance = 0 // CASE 1: Handle primitive types directly if (isPrimitive(item)) { const normalizedValue = normalizeText(String(item)) return calculateRelevance(normalizedValue, searchTerms) } // CASE 2: Handle arrays of primitives if (Array.isArray(item)) { for (const element of item) { if (isPrimitive(element)) { const normalizedValue = normalizeText(String(element)) totalRelevance += calculateRelevance(normalizedValue, searchTerms) } } return totalRelevance } // CASE 3: Handle regular objects // Determine which keys to search in const keysToProcess = keysToSearch || collectAllKeys(item) // Process each field for (const keyPath of keysToProcess) { const value = getValueByPath(item, String(keyPath)) // Skip null/undefined values if (value === null || value === undefined) { continue } // CASE 3.1: Handle array field values if (Array.isArray(value)) { for (const element of value) { if (isPrimitive(element)) { const cleanValue = clearHtml(String(element)) const normalizedValue = normalizeText(cleanValue) const relevance = calculateRelevance(normalizedValue, searchTerms) const fieldWeight = getFieldWeight(String(keyPath)) totalRelevance += relevance * fieldWeight } } continue } // CASE 3.2: Handle object fields (skip and process their properties separately) if (typeof value === 'object' && !isPrimitive(value)) { continue } // CASE 3.3: Handle primitive field values const stringValue = toSearchableString(value) const normalizedValue = normalizeText(stringValue) const baseRelevance = calculateRelevance(normalizedValue, searchTerms) const fieldWeight = getFieldWeight(String(keyPath)) totalRelevance += baseRelevance * fieldWeight } return totalRelevance } // Score each item and create [item, score] pairs const scoredItems = itemsArray.map((item) => { const relevance = calculateItemRelevance(item) return [item, relevance] as [T, number] }) // Filter items with non-zero relevance const nonZeroItems = scoredItems.filter(([, score]) => score > 0) // Apply dynamic threshold based on score distribution let filteredItems = nonZeroItems if (nonZeroItems.length > 0) { const maxScore = Math.max(...nonZeroItems.map(([, score]) => score)) // If we have very high relevance items, filter out low relevance noise if (maxScore > 5) { // Items below 25% of max score are considered low relevance const threshold = maxScore * 0.25 filteredItems = nonZeroItems.filter(([, score]) => score >= threshold) } else if (maxScore > 2) { // For medium relevance results, use a lower threshold const threshold = maxScore * 0.15 filteredItems = nonZeroItems.filter(([, score]) => score >= threshold) } } // Sort by relevance (descending) return filteredItems .sort(([, scoreA], [, scoreB]) => scoreB - scoreA) .map(([item]) => item) } /** * Vue composable for searching items with reactive results * Supports both client-side filtering and server-side search * * Usage 1 - Simplified: Pass items directly and get searchTerm ref back * ```ts * const { results, searchTerm } = useSearch(itemList) * ``` * * Usage 2 - Advanced: Pass full params object with custom search term * ```ts * const searchTerm = ref('') * const { results } = useSearch({ searchTerm, items: itemList, keysToSearch: ['name'] }) * ``` * * @param paramsOrItems Search parameters object or array of items for simplified usage * @param options Optional additional search parameters (only used with simplified usage) * @returns Reactive search results and metadata */ export function useSearch( items: MaybeRefOrGetter, options?: Omit, 'items' | 'searchTerm'> ): SearchResult & { searchTerm: import('vue').Ref } export function useSearch( params: SearchItemParams ): SearchResult export function useSearch( paramsOrItems: SearchItemParams | MaybeRefOrGetter, options?: Omit, 'items' | 'searchTerm'> ): SearchResult { // Normalize inputs - detect if first argument is items array or params object let params: SearchItemParams let ownedSearchTerm: import('vue').Ref | undefined // Check if first argument is an array or ref (simplified usage) // If it's a params object, it won't have these characteristics const isArray = Array.isArray(paramsOrItems) const isFunction = typeof paramsOrItems === 'function' const isObject = typeof paramsOrItems === 'object' && paramsOrItems !== null const isRef = isObject && 'value' in paramsOrItems && !('items' in paramsOrItems) const isSimplifiedUsage = isArray || isFunction || isRef if (isSimplifiedUsage) { // Simplified usage: useSearch(items, options) ownedSearchTerm = ref('') params = { items: paramsOrItems as MaybeRefOrGetter, searchTerm: ownedSearchTerm, ...options } } else { // Advanced usage: useSearch(params) params = paramsOrItems as SearchItemParams } const { searchTerm, minChars = 2, serverSearch, debounceMs = 300 } = params // For tracking server-side loading state const isLoading = ref(false) const serverResults = ref([]) let debounceTimeout: number | null = null // Watch for changes in the search term to trigger server-side search if (serverSearch) { watch( () => toValue(searchTerm), async (newTerm) => { // Clear previous timeout if it exists if (debounceTimeout !== null) { clearTimeout(debounceTimeout) } const term = typeof newTerm === 'string' ? newTerm : '' // Allow initial/default fetch when minChars === 0 (including empty term) if (term.length < minChars && minChars > 0) { serverResults.value = [] return } // Set up debounce debounceTimeout = window.setTimeout(async () => { try { isLoading.value = true serverResults.value = await serverSearch(term) } catch (error) { console.error('Server search error:', error) serverResults.value = [] } finally { isLoading.value = false } }, debounceMs) }, { immediate: true } ) } // Create a reactive function to get current search term value const getSearchTermValue = () => toValue(searchTerm) // Function to get filtered results const getFilteredResults = (): T[] => { const term = getSearchTermValue() // If using server-side search if (serverSearch) { if (term && typeof term === 'string' && term.length >= minChars) { return serverResults.value as T[] } // If minChars is 0, allow default server results when empty if (minChars === 0) { return serverResults.value as T[] } } // Otherwise use client-side filtering return searchItems(params) } // Create computed references const results = computed(() => getFilteredResults()) const resultCount = computed(() => results.value.length) const hasResults = computed(() => resultCount.value > 0) const isSearching = computed(() => { const term = getSearchTermValue() return !!term && typeof term === 'string' && term.length >= minChars }) const result: SearchResult = { results, resultCount, hasResults, isSearching, isLoading: computed(() => isLoading.value), } // Include searchTerm in result if we created it (simplified usage) if (ownedSearchTerm) { result.searchTerm = ownedSearchTerm } return result }