// SEARCH FUNCTIONALITY
// =============================================================================
/**
* Filters inventory based on the current search query and active column filters.
* Handles advanced multi-term, phrase, and series-specific logic for coins and metals.
*
* @returns {Array<Object>} Filtered inventory items matching the search query and filters
*
* @example
* filterInventory();
*/
const filterInventory = () => {
// Use the advanced filtering system if available, otherwise fall back to legacy
if (typeof filterInventoryAdvanced === 'function') {
return filterInventoryAdvanced();
}
// Legacy filtering for compatibility
let result = inventory;
Object.entries(activeFilters).forEach(([field, criteria]) => {
if (criteria && typeof criteria === 'object' && Array.isArray(criteria.values)) {
const lowerVals = criteria.values.map(v => String(v).toLowerCase());
result = result.filter((item) => {
const rawVal = item[field] ?? (field === 'metal' ? item.metal : '');
const fieldVal = String(rawVal ?? '').toLowerCase();
const match = lowerVals.includes(fieldVal);
return criteria.exclude ? !match : match;
});
}
});
if (!searchQuery.trim()) return result;
let query = searchQuery.toLowerCase().trim();
// Support comma-separated terms for multi-value search
const terms = query.split(',').map(t => t.trim()).filter(t => t);
return result.filter(item => {
if (!terms.length) return true;
const formattedDate = formatDisplayDate(item.date).toLowerCase();
// Handle comma-separated terms (OR logic between comma terms)
return terms.some(q => {
// Split each comma term into individual words for AND logic
const words = q.split(/\s+/).filter(w => w.length > 0);
// Special handling for multi-word searches to prevent partial matches
// If searching for "American Eagle", it should only match items that have both words
// but NOT match "American Gold Eagle" (which has an extra word in between)
if (words.length >= 2) {
// Expand abbreviations in the query words for multi-word searches
const abbrevs = typeof METAL_ABBREVIATIONS !== 'undefined' ? METAL_ABBREVIATIONS : {};
const expandedWords = words.map(w => abbrevs[w.toLowerCase()] || w);
const expandedPhrase = expandedWords.join(' ').toLowerCase();
// For multi-word searches, check if the exact phrase exists or
// if all words exist as separate word boundaries without conflicting words
const exactPhrase = q.toLowerCase();
// STAK-126: include tags in searchable text
const _searchTags = typeof getItemTags === 'function' ? getItemTags(item.uuid).join(' ') : '';
const itemText = [
item.metal,
item.composition || '',
item.name,
item.type,
item.purchaseLocation,
item.storageLocation || '',
item.notes || '',
String(item.year || ''),
item.grade || '',
item.gradingAuthority || '',
String(item.certNumber || ''),
String(item.numistaId || ''),
item.serialNumber || '',
String(item.pcgsNumber || ''),
String(item.purity || ''),
_searchTags
].join(' ').toLowerCase();
// Check for exact phrase match first
if (itemText.includes(exactPhrase)) {
return true;
}
// Check expanded abbreviation phrase (e.g. "ase 2024" → "american silver eagle 2024")
if (expandedPhrase !== exactPhrase && itemText.includes(expandedPhrase)) {
return true;
}
// STACK-23: Check custom chip group label matching for multi-word searches
if (typeof window.itemMatchesCustomGroupLabel === 'function' &&
window.itemMatchesCustomGroupLabel(item, q)) {
return true;
}
// For phrase searches like "American Eagle", be more restrictive
// Check that all words are present as word boundaries
const allWordsPresent = words.every(word => {
const wordRegex = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
return wordRegex.test(itemText);
});
if (!allWordsPresent) {
return false;
}
// Additional check: prevent cross-metal matching for common coin series
// "Silver Eagle" should not match "American Gold Eagle"
// "Gold Maple" should not match "Silver Maple Leaf"
// etc.
if (words.length === 2) {
const searchMetal = words[0];
const coinType = words[1];
// Handle Eagle series
if (coinType === 'eagle') {
if (searchMetal === 'american') {
// "American Eagle" should not match "American [Metal] Eagle"
const metalWords = ['gold', 'silver', 'platinum', 'palladium'];
const hasMetalBetween = metalWords.some(metal =>
itemText.includes(`american ${metal} eagle`) && !exactPhrase.includes(metal)
);
return !hasMetalBetween;
} else if (['silver', 'gold', 'platinum', 'palladium'].includes(searchMetal)) {
// Metal-specific eagle searches must match exact phrase
return itemText.includes(exactPhrase);
}
}
// Handle Maple series (Canadian Maple Leaf)
else if (coinType === 'maple') {
if (['silver', 'gold', 'platinum', 'palladium'].includes(searchMetal)) {
// "Silver Maple" should only match items with "silver maple"
return itemText.includes(exactPhrase) || itemText.includes(`${searchMetal} maple leaf`);
} else if (searchMetal === 'canadian') {
// "Canadian Maple" should not match specific metal maples unless no metal specified
const metalWords = ['gold', 'silver', 'platinum', 'palladium'];
const hasMetalBetween = metalWords.some(metal =>
itemText.includes(`canadian ${metal} maple`) && !exactPhrase.includes(metal)
);
return !hasMetalBetween;
}
}
// Handle Britannia series (British Britannia)
else if (coinType === 'britannia') {
if (['silver', 'gold', 'platinum', 'palladium'].includes(searchMetal)) {
// "Silver Britannia" should only match items with "silver britannia"
return itemText.includes(exactPhrase);
} else if (searchMetal === 'british') {
// "British Britannia" should not match specific metal britannias
const metalWords = ['gold', 'silver', 'platinum', 'palladium'];
const hasMetalBetween = metalWords.some(metal =>
itemText.includes(`british ${metal} britannia`) && !exactPhrase.includes(metal)
);
return !hasMetalBetween;
}
}
// Handle Krugerrand series
else if (coinType === 'krugerrand') {
if (['silver', 'gold', 'platinum', 'palladium'].includes(searchMetal)) {
// "Gold Krugerrand" should only match gold krugerrands
return itemText.includes(exactPhrase);
} else if (searchMetal === 'south' || searchMetal === 'african') {
// Handle "South African Krugerrand" - don't match if metal specified
const metalWords = ['gold', 'silver', 'platinum', 'palladium'];
const hasMetalBetween = metalWords.some(metal =>
(itemText.includes(`south african ${metal} krugerrand`) ||
itemText.includes(`${metal} krugerrand`)) && !exactPhrase.includes(metal)
);
return !hasMetalBetween;
}
}
// Handle Buffalo series
else if (coinType === 'buffalo') {
if (['silver', 'gold', 'platinum', 'palladium'].includes(searchMetal)) {
// "Gold Buffalo" should only match gold buffalos
return itemText.includes(exactPhrase);
} else if (searchMetal === 'american') {
// "American Buffalo" should not match if metal specified
const metalWords = ['gold', 'silver', 'platinum', 'palladium'];
const hasMetalBetween = metalWords.some(metal =>
itemText.includes(`american ${metal} buffalo`) && !exactPhrase.includes(metal)
);
return !hasMetalBetween;
}
}
// Handle Panda series
else if (coinType === 'panda') {
if (['silver', 'gold', 'platinum', 'palladium'].includes(searchMetal)) {
// "Silver Panda" should only match silver pandas
return itemText.includes(exactPhrase);
} else if (searchMetal === 'chinese') {
// "Chinese Panda" should not match if metal specified
const metalWords = ['gold', 'silver', 'platinum', 'palladium'];
const hasMetalBetween = metalWords.some(metal =>
itemText.includes(`chinese ${metal} panda`) && !exactPhrase.includes(metal)
);
return !hasMetalBetween;
}
}
// Handle Kangaroo series
else if (coinType === 'kangaroo') {
if (['silver', 'gold', 'platinum', 'palladium'].includes(searchMetal)) {
return itemText.includes(exactPhrase);
} else if (searchMetal === 'australian') {
const metalWords = ['gold', 'silver', 'platinum', 'palladium'];
const hasMetalBetween = metalWords.some(metal =>
itemText.includes(`australian ${metal} kangaroo`) && !exactPhrase.includes(metal)
);
return !hasMetalBetween;
}
}
}
// Handle three-word searches with special patterns
if (words.length === 3) {
// Handle "American Gold Eagle" type searches - these should be exact
const firstWord = words[0];
const middleWord = words[1];
const lastWord = words[2];
if (['american', 'canadian', 'british', 'chinese', 'australian', 'south'].includes(firstWord) &&
['gold', 'silver', 'platinum', 'palladium'].includes(middleWord) &&
['eagle', 'maple', 'britannia', 'krugerrand', 'buffalo', 'panda', 'kangaroo'].includes(lastWord)) {
// For "American Gold Eagle" type searches, require exact phrase or very close match
return itemText.includes(exactPhrase) ||
(lastWord === 'maple' && itemText.includes(`${firstWord} ${middleWord} maple leaf`));
}
}
// Handle fractional weight searches to be more specific
// "1/4 oz" should be distinct from "1/2 oz" and "1 oz"
if (words.length >= 2) {
const hasFraction = words.some(word => word.includes('/'));
const hasOz = words.some(word => word === 'oz' || word === 'ounce');
if (hasFraction && hasOz) {
// For fractional searches, require exact phrase match
return itemText.includes(exactPhrase);
}
}
// Prevent overly broad country/origin searches
const broadTerms = ['american', 'canadian', 'australian', 'british', 'chinese', 'south', 'mexican'];
if (words.length === 1 && broadTerms.includes(words[0])) {
// Single broad geographic terms should require additional context
// Return false to prevent matching everything from that country
return false;
}
return true;
}
// For single words, use word boundary matching with abbreviation expansion
const fieldMatch = words.every(word => {
// Build regex patterns: original word + any abbreviation expansion
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const patterns = [escaped];
// Expand abbreviation if one exists (e.g. 'ase' → 'American Silver Eagle')
const abbrevs = typeof METAL_ABBREVIATIONS !== 'undefined' ? METAL_ABBREVIATIONS : {};
const expansion = abbrevs[word.toLowerCase()];
if (expansion) {
patterns.push(expansion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
}
// OR together: match original word OR expanded term
const combined = patterns.join('|');
const wordRegex = new RegExp(`\\b(?:${combined})`, 'i');
return (
wordRegex.test(item.metal) ||
(item.composition && wordRegex.test(item.composition)) ||
wordRegex.test(item.name) ||
wordRegex.test(item.type) ||
wordRegex.test(item.purchaseLocation) ||
(item.storageLocation && wordRegex.test(item.storageLocation)) ||
(item.notes && wordRegex.test(item.notes)) ||
item.date.includes(word) ||
formattedDate.includes(word) ||
String(Number.isFinite(Number(item.qty)) ? Number(item.qty) : '').includes(word) ||
String(Number.isFinite(Number(item.weight)) ? Number(item.weight) : '').includes(word) ||
String(Number.isFinite(Number(item.price)) ? Number(item.price) : '').includes(word) ||
(item.year && wordRegex.test(String(item.year))) ||
(item.grade && wordRegex.test(item.grade)) ||
(item.gradingAuthority && wordRegex.test(item.gradingAuthority)) ||
(item.certNumber && wordRegex.test(String(item.certNumber))) ||
(item.numistaId && wordRegex.test(String(item.numistaId))) ||
(item.serialNumber && wordRegex.test(item.serialNumber)) ||
(item.pcgsNumber && wordRegex.test(String(item.pcgsNumber))) ||
(typeof getItemTags === 'function' && getItemTags(item.uuid).some(t => wordRegex.test(t)))
);
});
if (fieldMatch) return true;
// STACK-23: Fall back to custom chip group label matching
if (typeof window.itemMatchesCustomGroupLabel === 'function') {
return window.itemMatchesCustomGroupLabel(item, q);
}
// STACK-62: Fuzzy fallback — score item fields when exact matching fails
if (typeof window.featureFlags !== 'undefined' &&
window.featureFlags.isEnabled('FUZZY_AUTOCOMPLETE') &&
typeof fuzzyMatch === 'function') {
const fuzzyThreshold = typeof AUTOCOMPLETE_CONFIG !== 'undefined'
? AUTOCOMPLETE_CONFIG.threshold : 0.3;
const fieldsToCheck = [item.name, item.purchaseLocation, item.storageLocation || '', item.notes || ''];
for (const field of fieldsToCheck) {
if (field && fuzzyMatch(q, field, { threshold: fuzzyThreshold }) > 0) {
// Mark that fuzzy matching was used (for indicator)
if (!window._fuzzyMatchUsed) window._fuzzyMatchUsed = true;
return true;
}
}
}
return false;
});
});
};
// Note: applyColumnFilter function is now in filters.js for advanced filtering
/**
* Safely retrieves a DOM element by ID, using safeGetElement if available.
*
* @param {string} id - The ID of the element to retrieve
* @returns {HTMLElement|null} The DOM element or null
*/
const resolveElement = (id) => {
if (typeof safeGetElement === 'function') {
return safeGetElement(id);
}
return document.getElementById(id);
};
/**
* Normalizes a list of search patterns for comparison.
* Trims, lowercases, and sorts the patterns.
*
* @param {string[]} list - The list of patterns to normalize
* @returns {string[]} The normalized list
*/
const _normalizePatterns = (list) => list
.map(p => p.trim().toLowerCase())
.filter(p => p.length > 0)
.sort();
/**
* Parses a comma-separated search query into individual patterns.
*
* @param {string} query - The search query string
* @returns {string[]} Array of individual search patterns
*/
const parseSearchPatterns = (query) => {
if (!query || typeof query !== 'string') return [];
return query.split(',').map(part => part.trim()).filter(Boolean);
};
/**
* Checks if a set of search patterns already exists as a custom group.
*
* @param {string[]} patterns - The patterns to check
* @returns {boolean} True if a matching custom group exists
*/
const searchPatternExistsInCustomGroups = (patterns) => {
if (!patterns.length) return false;
if (typeof loadCustomGroups !== 'function') return false;
const targetKey = _normalizePatterns(patterns).join('|');
return loadCustomGroups().some(group => {
if (!group || !Array.isArray(group.patterns)) return false;
const groupKey = _normalizePatterns(group.patterns).join('|');
return groupKey === targetKey;
});
};
/**
* Checks if a set of search patterns matches any auto-generated chips.
*
* @param {string[]} patterns - The patterns to check
* @returns {boolean} True if any pattern matches an auto-chip
*/
const searchPatternMatchesAutoChip = (patterns) => {
if (!patterns.length) return false;
if (typeof generateCategorySummary !== 'function') return false;
if (typeof inventory === 'undefined') return false;
const summary = generateCategorySummary(inventory);
if (!summary || typeof summary !== 'object') return false;
const summaryKeys = [
'metals', 'types', 'names',
'purchaseLocations', 'storageLocations',
'years', 'grades', 'numistaIds',
'purities', 'dynamicNames', 'tags',
];
const autoValues = new Set();
summaryKeys.forEach(key => {
const bucket = summary[key];
if (bucket && typeof bucket === 'object') {
Object.keys(bucket).forEach(val => {
autoValues.add(String(val).toLowerCase());
});
}
});
return patterns.some(p => autoValues.has(p.toLowerCase()));
};
/**
* Determines whether the "Save Search" button should be displayed.
*
* @param {string} query - The current search query
* @param {boolean} fuzzyUsed - Whether fuzzy matching was used for this query
* @returns {boolean} True if the save button should be shown
*/
const shouldShowSearchSaveButton = (query, fuzzyUsed) => {
const patterns = parseSearchPatterns(query);
if (patterns.length < 2) return false;
if (fuzzyUsed) return false;
if (searchPatternExistsInCustomGroups(patterns)) return false;
if (searchPatternMatchesAutoChip(patterns)) return false;
return true;
};
/**
* Updates the visibility of the "Save Search" button based on the query.
*
* @param {string} query - The search query string
* @param {boolean} [fuzzyUsed=false] - Whether fuzzy matching was active
*/
const updateSaveSearchButton = (query, fuzzyUsed = false) => {
const group = resolveElement('saveSearchPatternGroup');
if (!group || !group.id) return;
const canSave = shouldShowSearchSaveButton(query, fuzzyUsed);
group.style.display = canSave ? '' : 'none';
};
/**
* Derives a default display label for a set of search patterns.
*
* @param {string[]} patterns - The patterns to derive a label from
* @returns {string} The derived display label
*/
const deriveSearchLabel = (patterns) => {
if (!patterns.length) return '';
const pretty = patterns.map(p => p.charAt(0).toUpperCase() + p.slice(1));
return pretty.join(' / ');
};
/**
* Handles the "Save Search" button click event.
* Prompts the user for a label and creates a new custom group.
*/
const handleSaveSearchPattern = async () => {
const input = resolveElement('searchInput');
if (!input || !input.id) return;
const query = input.value || '';
const fuzzyUsed = !!(query.trim() && window._fuzzyMatchUsed);
if (!shouldShowSearchSaveButton(query, fuzzyUsed)) {
updateSaveSearchButton(query, fuzzyUsed);
return;
}
const patterns = parseSearchPatterns(query);
const defaultLabel = deriveSearchLabel(patterns);
const label = typeof showAppPrompt === 'function'
? await showAppPrompt('Label for saved filter chip:', defaultLabel, 'Save Search Pattern')
: window.prompt('Label for saved filter chip:', defaultLabel);
if (!label || !label.trim()) {
updateSaveSearchButton(query, fuzzyUsed);
return;
}
if (typeof addCustomGroup === 'function') {
const group = addCustomGroup(label.trim(), patterns.join(', '));
if (group && typeof renderActiveFilters === 'function') {
renderActiveFilters();
}
}
updateSaveSearchButton(query, fuzzyUsed);
};
window.updateSaveSearchButton = updateSaveSearchButton;
/**
* Show or hide the fuzzy search indicator banner.
* Displayed when exact search returns 0 results but fuzzy returns > 0.
*
* @param {string} query - The search query that triggered fuzzy matching
* @param {boolean} show - Whether to show or hide the indicator
*/
const updateFuzzyIndicator = (query, show) => {
let indicator = safeGetElement('fuzzySearchIndicator');
// safeGetElement returns a dummy if not found; check for real DOM node
if (!indicator.id) indicator = null;
if (!show) {
if (indicator) indicator.style.display = 'none';
return;
}
if (!indicator) {
// Create indicator next to searchResultsInfo
const parent = safeGetElement('searchResultsInfo');
if (!parent.id || !parent.parentElement) return;
indicator = document.createElement('div');
indicator.id = 'fuzzySearchIndicator';
indicator.className = 'fuzzy-indicator';
parent.parentElement.insertBefore(indicator, parent.nextSibling);
}
indicator.textContent = `Showing approximate matches for \u2018${query}\u2019`;
indicator.style.display = '';
};
// Expose for global access
window.filterInventory = filterInventory;
window.updateFuzzyIndicator = updateFuzzyIndicator;
// Apply debounce to search input
const searchInput = resolveElement('searchInput');
const saveSearchPatternBtn = resolveElement('saveSearchPatternBtn');
if (searchInput && searchInput.id) {
const debouncedSearch = debounce((query) => {
window._fuzzyMatchUsed = false;
searchQuery = query;
renderTable();
renderActiveFilters();
// Show fuzzy indicator if fuzzy matching was used
const fuzzyActive = !!(query.trim() && window._fuzzyMatchUsed);
updateFuzzyIndicator(query, fuzzyActive);
updateSaveSearchButton(query, fuzzyActive);
}, 300);
searchInput.addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
// Dismiss mobile keyboard on Enter (STAK-126)
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
searchInput.blur();
}
});
updateSaveSearchButton(searchInput.value || '', false);
}
if (saveSearchPatternBtn && saveSearchPatternBtn.id) {
saveSearchPatternBtn.addEventListener('click', handleSaveSearchPattern);
}
// =============================================================================