import { readdir, stat, readFile } from 'fs/promises' import { readFileSync, existsSync } from 'fs' import { join } from 'path' import type { Manual, ManualGroup } from './manuals-types' import { detectMachineType, detectManufacturer, MANUFACTURERS } from './manuals-config' import { getMachineTypeFromModel, extractModelNumberFromFilename } from './model-type-mapping' import { getManualsFilesRoot, getManualsMetadataRoot, getManualsThumbnailsRoot, getManualsManufacturerInfoRoot } from './manuals-paths' export type { Manual, ManualGroup } from './manuals-types' /** * Load common names from a JSON file * The JSON file should be an array of objects with path/filename and commonNames * Format: [{ "path": "path/to/manual.pdf", "commonNames": ["Name 1", "Name 2"] }, ...] * Or: [{ "filename": "manual.pdf", "commonNames": ["Name 1", "Name 2"] }, ...] */ function loadCommonNames(): Map { const commonNamesMap = new Map() try { // Try to load from project root manuals-data/data/manuals.json const dataPath = join(getManualsMetadataRoot(), 'manuals.json') if (existsSync(dataPath)) { const data = JSON.parse(readFileSync(dataPath, 'utf-8')) // Handle array of manual objects if (Array.isArray(data)) { for (const manual of data) { // Support both camelCase (commonNames) and snake_case (common_names) const commonNames = manual.commonNames || manual.common_names if (commonNames && Array.isArray(commonNames)) { // Use path if available, otherwise use filename const key = manual.path || manual.filename || manual.id if (key) { commonNamesMap.set(key, commonNames) } } } } } } catch (error) { // Silently fail if file doesn't exist or is invalid if (process.env.NODE_ENV === 'development') { console.warn('Could not load common names:', error) } } return commonNamesMap } /** * Scan the manuals directory and organize by manufacturer and category * This function is designed to handle hundreds of manuals efficiently * Supports both flat and nested directory structures */ export async function scanManuals(): Promise { const manualsDir = getManualsFilesRoot() const manuals: Manual[] = [] const commonNamesMap = loadCommonNames() const shouldLogScanWarnings = process.env.NODE_ENV === 'development' && process.env.MANUALS_DEBUG === 'true' /** * Recursively scan a directory for PDF files */ async function scanDirectory(dirPath: string, relativePath: string = ''): Promise { try { const entries = await readdir(dirPath, { withFileTypes: true }) for (const entry of entries) { const entryPath = join(dirPath, entry.name) const entryRelativePath = relativePath ? join(relativePath, entry.name) : entry.name if (entry.isDirectory()) { // Recursively scan subdirectories await scanDirectory(entryPath, entryRelativePath) } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.pdf')) { try { const stats = await stat(entryPath) // Skip files that are too small (likely error pages or placeholders) // Real PDF manuals are typically at least 10KB, but we'll use 5KB as a safe threshold const MIN_FILE_SIZE = 5 * 1024 // 5KB if (stats.size < MIN_FILE_SIZE) { // Double-check: read first few bytes to see if it's an HTML error page try { const buffer = await readFile(entryPath) const content = buffer.toString('utf-8', 0, Math.min(200, buffer.length)) // Check if it's an HTML error page if (content.includes('') || content.includes('') || content.includes('cannot be found') || content.includes('The resource cannot be found') || content.includes('404') || content.includes('Not Found')) { if (shouldLogScanWarnings) { console.warn(`Skipping error page: ${entryPath} (${stats.size} bytes)`) } continue } } catch { // If we can't read the file, skip it continue } // If it's small but not HTML, still skip it (likely corrupted or placeholder) if (shouldLogScanWarnings) { console.warn(`Skipping small file: ${entryPath} (${stats.size} bytes)`) } continue } // Try to detect manufacturer from directory path and filename // Check parent directories (e.g., Coin-Mechs/Coinco/ -> Coinco) const pathParts = entryRelativePath.split('/') let detectedManufacturer: string | null = null // Check each directory in the path for manufacturer for (const part of pathParts) { const detected = detectManufacturer(part) if (detected) { detectedManufacturer = detected break } } // Also check filename if (!detectedManufacturer) { detectedManufacturer = detectManufacturer(entry.name) } // Use detected manufacturer or derive from directory structure // For nested structures like Coin-Mechs/Coinco/, use the manufacturer subdirectory let manufacturer: string if (detectedManufacturer) { manufacturer = detectedManufacturer } else if (pathParts.length > 1) { // For nested structures, use the last directory before the file manufacturer = pathParts[pathParts.length - 2] || pathParts[0] } else { // For flat structures, use the directory name manufacturer = pathParts[0] || 'Other' } // Extract category from filename or use manufacturer as category const category = extractCategory(entry.name, manufacturer) // Store relative path for API route (e.g., "Coin-Mechs/Coinco/manual.pdf") const relativeFilePath = entryRelativePath // Generate search terms for easier discovery const searchTerms = generateSearchTerms(entry.name, manufacturer, category) // Load common names from JSON file (try both path and filename as keys) let commonNames: string[] | undefined = undefined if (commonNamesMap.has(relativeFilePath)) { commonNames = commonNamesMap.get(relativeFilePath)! } else if (commonNamesMap.has(entry.name)) { commonNames = commonNamesMap.get(entry.name)! } // Check if thumbnail exists (try multiple filename variations) const thumbnailVariations = getThumbnailPathVariations(entryRelativePath) const thumbnailsDir = getManualsThumbnailsRoot() let thumbnailPath: string | undefined = undefined for (const variation of thumbnailVariations) { const fullThumbnailPath = join(thumbnailsDir, variation) if (existsSync(fullThumbnailPath)) { thumbnailPath = variation break } } manuals.push({ filename: entry.name, path: relativeFilePath, // Relative path for API route manufacturer, category, size: stats.size, lastModified: stats.mtime, searchTerms, commonNames, thumbnailUrl: thumbnailPath, }) } catch (error) { // Skip files that can't be read if (process.env.NODE_ENV === 'development') { console.warn(`Could not read file: ${entryPath}`, error) } } } } } catch (error) { if (process.env.NODE_ENV === 'development') { console.warn(`Could not read directory: ${dirPath}`, error) } } } try { await scanDirectory(manualsDir) } catch (error) { if (process.env.NODE_ENV === 'development') { console.error('Error scanning manuals:', error) } } return manuals.sort((a, b) => a.filename.localeCompare(b.filename)) } /** * Generate search terms for a manual to make it easier to find * Includes model numbers, brand names, manufacturer aliases, and variations */ function generateSearchTerms(filename: string, manufacturer: string, category: string): string[] { const terms = new Set() const nameWithoutExt = filename.replace(/\.pdf$/i, '') // Add filename without extension (normalized) terms.add(nameWithoutExt.toLowerCase()) // Add manufacturer name and aliases const manufacturerInfo = MANUFACTURERS.find(m => m.name === manufacturer) if (manufacturerInfo) { terms.add(manufacturer.toLowerCase()) manufacturerInfo.aliases.forEach(alias => { terms.add(alias.toLowerCase()) }) // Add brand names (e.g., "BevMax" for Crane) if (manufacturerInfo.brands) { manufacturerInfo.brands.forEach(brand => { terms.add(brand.toLowerCase()) // Also add variations like "bevmax" and "bev-max" terms.add(brand.toLowerCase().replace(/\s+/g, '-')) terms.add(brand.toLowerCase().replace(/\s+/g, '')) }) } } // Extract and add model number with variations const modelNumber = extractModelNumberFromFilename(filename) if (modelNumber) { const normalizedModel = modelNumber.toLowerCase() terms.add(normalizedModel) // Add variations: with/without dashes, with/without spaces terms.add(normalizedModel.replace(/[-_]/g, '')) terms.add(normalizedModel.replace(/[-_]/g, ' ')) terms.add(normalizedModel.replace(/\s+/g, '-')) // Add manufacturer + model combinations (e.g., "crane vcb-39", "ams 39") if (manufacturerInfo) { terms.add(`${manufacturer.toLowerCase()} ${normalizedModel}`) terms.add(`${manufacturer.toLowerCase()}-${normalizedModel.replace(/[-_\s]/g, '')}`) } } // Add category terms.add(category.toLowerCase()) // Extract common patterns from filename // Look for common model patterns in filename const filenameLower = nameWithoutExt.toLowerCase() // Add any 3-5 digit numbers found in filename const numberMatches = filenameLower.match(/\d{3,5}/g) if (numberMatches) { numberMatches.forEach(num => terms.add(num)) } // Add common abbreviations (e.g., "vcb", "ams", "bevmax") const abbrevMatches = filenameLower.match(/\b[a-z]{2,5}\b/g) if (abbrevMatches) { abbrevMatches.forEach(abbrev => { if (abbrev.length >= 2 && abbrev.length <= 5) { terms.add(abbrev) } }) } return Array.from(terms).sort() } /** * Get thumbnail path for a manual (mirrors the PDF path structure) * Converts "path/to/manual.pdf" to "path/to/manual.jpg" * Also handles filename normalization (URL decoding, lowercase, space-to-hyphen conversion) * Returns array of possible thumbnail paths to try */ function getThumbnailPathVariations(pdfPath: string): string[] { // Try multiple variations since thumbnail generation may normalize filenames const basePath = pdfPath.replace(/\.pdf$/i, '') const pathParts = basePath.split('/') const filename = pathParts[pathParts.length - 1] const dirPath = pathParts.slice(0, -1).join('/') // Decode URL encoding let decodedFilename = filename try { decodedFilename = decodeURIComponent(filename) } catch { // If decoding fails, use original } // Generate possible thumbnail filename variations const variations: string[] = [] // 1. Original filename (with .jpg) variations.push(pdfPath.replace(/\.pdf$/i, '.jpg')) // 2. Decoded filename if (decodedFilename !== filename) { variations.push(dirPath ? `${dirPath}/${decodedFilename}.jpg` : `${decodedFilename}.jpg`) } // 3. Normalized (lowercase, spaces to hyphens, remove special chars, trim) const normalized = decodedFilename .toLowerCase() .trim() .replace(/\s+/g, '-') .replace(/[^a-z0-9\-_]/g, '') .replace(/-+/g, '-') .replace(/^-|-$/g, '') if (normalized && normalized !== decodedFilename.toLowerCase().trim()) { variations.push(dirPath ? `${dirPath}/${normalized}.jpg` : `${normalized}.jpg`) } return variations } /** * Extract category from filename * Priority order: * 1. Model number lookup (from manufacturers.md) - highest priority * 2. Machine Type detection (Snack, Beverage, Combo, Coffee, Food, etc.) * 3. Model Number (e.g., VCB-39, BevMax-4) * 4. Document type (Service, Parts, etc.) * 5. Manufacturer name (fallback) */ function extractCategory(filename: string, manufacturer: string): string { // Remove file extension const nameWithoutExt = filename.replace(/\.pdf$/i, '') const normalized = nameWithoutExt.toLowerCase() // Priority 1: Try to extract model number and look it up in manufacturers.md const modelNumber = extractModelNumberFromFilename(nameWithoutExt) if (modelNumber) { const machineType = getMachineTypeFromModel(manufacturer, modelNumber) if (machineType) { return machineType } } // Priority 2: Detect machine type from filename keywords const machineType = detectMachineType(nameWithoutExt) if (machineType) { return machineType } // Priority 3: Try to extract model number for display (usually contains numbers and dashes/underscores) // Pattern: letters followed by numbers, optionally with dashes/underscores // Examples: VCB-39, BevMax-4, AMS39, VCB39, etc. const modelPatterns = [ /([A-Z]{2,}[-_]?[0-9]+[A-Z0-9]*)/i, // VCB-39, AMS39, BevMax-4 /([A-Z]+[0-9]+[A-Z0-9]*)/i, // VCB39, AMS39 /(model[-_]?[0-9]+)/i, // model-123 ] for (const pattern of modelPatterns) { const modelMatch = nameWithoutExt.match(pattern) if (modelMatch) { // Clean up the model number const model = modelMatch[1].replace(/[-_]/g, '-').toUpperCase() return model } } // Priority 4: Check for common document types const documentTypes = [ { keywords: ['repair', 'service', 'maintenance'], name: 'Repair' }, { keywords: ['parts', 'part'], name: 'Parts' }, { keywords: ['manual', 'guide', 'instruction'], name: 'Manual' }, { keywords: ['installation', 'install', 'setup'], name: 'Installation' }, { keywords: ['troubleshooting', 'troubleshoot', 'trouble'], name: 'Troubleshooting' }, ] for (const docType of documentTypes) { for (const keyword of docType.keywords) { if (normalized.includes(keyword)) { return docType.name } } } // Priority 5: Default to manufacturer if no category can be determined return manufacturer } /** * Group manuals by manufacturer and category */ export function groupManuals(manuals: Manual[]): ManualGroup[] { const grouped: { [manufacturer: string]: { [category: string]: Manual[] } } = {} for (const manual of manuals) { if (!grouped[manual.manufacturer]) { grouped[manual.manufacturer] = {} } if (!grouped[manual.manufacturer][manual.category]) { grouped[manual.manufacturer][manual.category] = [] } grouped[manual.manufacturer][manual.category].push(manual) } // Convert to array format return Object.entries(grouped) .map(([manufacturer, categories]) => ({ manufacturer, categories: Object.fromEntries( Object.entries(categories).sort(([a], [b]) => a.localeCompare(b)) ), })) .sort((a, b) => a.manufacturer.localeCompare(b.manufacturer)) } /** * Get all unique manufacturers from manuals */ export function getManufacturers(manuals: Manual[]): string[] { const manufacturers = new Set(manuals.map(m => m.manufacturer)) return Array.from(manufacturers).sort() } /** * Get all unique categories from manuals */ export function getCategories(manuals: Manual[]): string[] { const categories = new Set(manuals.map(m => m.category)) return Array.from(categories).sort() } /** * Load MDB-capable machines from manufacturers.md * Returns a map: { manufacturer: Set } */ let mdbMachinesCache: { [manufacturer: string]: Set } | null = null function loadMDBCapableMachines(): { [manufacturer: string]: Set } { if (mdbMachinesCache !== null) { return mdbMachinesCache } mdbMachinesCache = {} try { const manufacturersPath = join(getManualsManufacturerInfoRoot(), 'manufacturers.md') const content = readFileSync(manufacturersPath, 'utf-8') const lines = content.split('\n') // Manufacturer name normalization mapping const MANUFACTURER_NORMALIZATION: { [key: string]: string } = { 'Automated Merchandising Systems (AMS)': 'AMS', 'Automatic Products (APi)': 'AP', 'Crane/National/GPL': 'Crane', 'Dixie-Narco, Inc.': 'Dixie-Narco', 'Sanden Vendo America Inc.': 'Vendo', 'USI/Wittern': 'USI', 'Vendtronics/Seaga': 'Seaga', 'Royal Vendors, Inc.': 'Royal Vendors', 'R-O International': 'R-O', } // Skip header line for (let i = 1; i < lines.length; i++) { const line = lines[i].trim() if (!line) continue // Split by tab (TSV format) const parts = line.split('\t') if (parts.length < 5) continue const manufacturer = parts[0].trim() const modelNumber = parts[2].trim() const interfaces = parts[4].trim() // Check if this machine is MDB-capable if (!interfaces.toLowerCase().includes('mdb')) { continue } // Skip if missing essential data if (!manufacturer || !modelNumber || modelNumber === 'see above models') { continue } // Normalize manufacturer name const normalizedManufacturer = MANUFACTURER_NORMALIZATION[manufacturer] || manufacturer // Initialize manufacturer set if needed if (!mdbMachinesCache[normalizedManufacturer]) { mdbMachinesCache[normalizedManufacturer] = new Set() } // Handle multiple model numbers (e.g., "211, 211E" or "400, 500 Series") const modelNumbers = extractModelNumbers(modelNumber) for (const modelNum of modelNumbers) { const normalizedModel = normalizeModelNumber(modelNum) mdbMachinesCache[normalizedManufacturer].add(normalizedModel) } } } catch (error) { if (process.env.NODE_ENV === 'development') { console.warn('Could not load manufacturers.md for MDB detection:', error) } mdbMachinesCache = {} } return mdbMachinesCache } /** * Extract individual model numbers from a string that may contain multiple models */ function extractModelNumbers(modelNumberStr: string): string[] { const models: string[] = [] const parts = modelNumberStr.split(',').map(p => p.trim()) for (const part of parts) { const cleaned = part.replace(/\s+Series$/i, '').trim() if (cleaned.includes('/')) { const rangeParts = cleaned.split('/').map(p => p.trim()) models.push(...rangeParts) } else { models.push(cleaned) } } return models.filter(m => m.length > 0) } /** * Normalize model number for lookup */ function normalizeModelNumber(modelNumber: string): string { return modelNumber .toUpperCase() .replace(/\s+/g, '') .replace(/[-_]/g, '') } /** * Normalize manufacturer name for matching */ function normalizeManufacturerName(manufacturer: string): string { const normalized = manufacturer .toLowerCase() .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/g, '') // Handle common variations and brand names const variations: { [key: string]: string } = { 'crane': 'crane', 'national': 'crane', 'gpl': 'crane', 'crane-national-gpl': 'crane', 'merchant-series': 'crane', // Merchant Series is Crane 'bevmax': 'crane', // BevMax is Crane 'bev-max': 'crane', 'automatic-products': 'ap', 'api': 'ap', 'ap': 'ap', 'automated-merchandising-systems': 'ams', 'ams': 'ams', 'dixie-narco': 'dixie-narco', 'dixie': 'dixie-narco', 'narco': 'dixie-narco', 'sanden-vendo': 'vendo', 'vendo': 'vendo', 'sandenvendo': 'vendo', 'usi': 'usi', 'wittern': 'usi', 'uselectit': 'usi', 'united-standard-industries': 'usi', 'seaga': 'seaga', 'vendtronics': 'seaga', 'royal-vendors': 'royal-vendors', 'royal': 'royal-vendors', } return variations[normalized] || normalized } /** * Check if a manufacturer has any MDB-capable machines */ function hasMDBCapableMachines(manufacturer: string, mdbMachines: { [key: string]: Set }): boolean { const normalized = normalizeManufacturerName(manufacturer) // Check exact match first if (mdbMachines[manufacturer] && mdbMachines[manufacturer].size > 0) { return true } // Try normalized name matching for (const [mdbManufacturer] of Object.entries(mdbMachines)) { const normalizedMdb = normalizeManufacturerName(mdbManufacturer) if (normalizedMdb === normalized || normalized.includes(normalizedMdb) || normalizedMdb.includes(normalized)) { return true } } return false } /** * Check if a manual is for an MDB-capable machine * More inclusive: if manufacturer has ANY MDB machines, include all their manuals */ function isMDBCapableMachine(manual: Manual): boolean { const mdbMachines = loadMDBCapableMachines() // First check if this manufacturer has any MDB-capable machines if (!hasMDBCapableMachines(manual.manufacturer, mdbMachines)) { return false } // If manufacturer has MDB machines, check if we can match this specific model // But be more lenient - if we can't match, still include it if manufacturer has MDB machines const manufacturer = normalizeManufacturerName(manual.manufacturer) let manufacturerSet: Set | undefined // Find the matching manufacturer set if (mdbMachines[manual.manufacturer]) { manufacturerSet = mdbMachines[manual.manufacturer] } else { for (const [mdbManufacturer, models] of Object.entries(mdbMachines)) { const normalizedMdb = normalizeManufacturerName(mdbManufacturer) if (normalizedMdb === manufacturer || manufacturer.includes(normalizedMdb) || normalizedMdb.includes(manufacturer)) { manufacturerSet = models break } } } if (!manufacturerSet || manufacturerSet.size === 0) { // If manufacturer has MDB machines but we can't find the set, include it anyway // This handles edge cases where manufacturer name doesn't match exactly return true } // Try to extract model number from filename const modelNumber = extractModelNumberFromFilename(manual.filename) if (!modelNumber) { // If we can't extract model number, check if filename contains any MDB model numbers const filenameUpper = manual.filename.toUpperCase() for (const mdbModel of manufacturerSet) { if (filenameUpper.includes(mdbModel) || mdbModel.includes(filenameUpper.replace(/[^A-Z0-9]/g, ''))) { return true } } // If manufacturer has MDB machines and we can't extract model, include it // Most manuals from MDB-capable manufacturers are for MDB machines return true } const normalizedModel = normalizeModelNumber(modelNumber) // Check exact match if (manufacturerSet.has(normalizedModel)) { return true } // Check partial matches (e.g., "AMS35" matches "AMS35/39-XXX", "5000" matches "5000") for (const mdbModel of manufacturerSet) { // Remove slashes and special chars for comparison const cleanMdbModel = mdbModel.replace(/[^A-Z0-9]/g, '') const cleanNormalized = normalizedModel.replace(/[^A-Z0-9]/g, '') if (cleanNormalized.includes(cleanMdbModel) || cleanMdbModel.includes(cleanNormalized)) { return true } // Also check if they share significant digits (e.g., "5000" matches "5000") if (cleanMdbModel.length >= 3 && cleanNormalized.length >= 3) { const mdbDigits = cleanMdbModel.replace(/[^0-9]/g, '') const normDigits = cleanNormalized.replace(/[^0-9]/g, '') if (mdbDigits && normDigits && (mdbDigits.includes(normDigits) || normDigits.includes(mdbDigits))) { return true } } } // If manufacturer has MDB machines but model doesn't match, still include it // This is more inclusive - assumes manuals from MDB manufacturers are likely MDB return true } /** * Check if a manual is MDB (Multi-Drop Bus) compatible * MDB manuals are: * - In Coin-Mechs, Bill-Mechs, or Card-Readers directories * - Have "mdb" in the filename (case-insensitive) * - For MDB-capable vending machines (from manufacturers.md) */ export function isMDBManual(manual: Manual): boolean { const pathLower = manual.path.toLowerCase() const filenameLower = manual.filename.toLowerCase() // Check if manual is in MDB-related directories (payment components) if ( pathLower.includes('coin-mechs') || pathLower.includes('bill-mechs') || pathLower.includes('card-readers') ) { return true } // Check if filename contains "mdb" if (filenameLower.includes('mdb')) { return true } // Check if this manual is for an MDB-capable vending machine if (isMDBCapableMachine(manual)) { return true } return false } /** * Filter manuals to only include MDB ones */ export function filterMDBManuals(manuals: Manual[]): Manual[] { return manuals.filter(isMDBManual) } /** * Filter manufacturers by minimum manual count * Removes manufacturers that have fewer than the specified minimum count */ export function filterManufacturersByMinCount( manuals: Manual[], minCount: number ): Manual[] { if (minCount <= 0) { return manuals } // Count manuals per manufacturer const manufacturerCounts = new Map() for (const manual of manuals) { const count = manufacturerCounts.get(manual.manufacturer) || 0 manufacturerCounts.set(manual.manufacturer, count + 1) } // Filter out manufacturers below minimum count return manuals.filter((manual) => { const count = manufacturerCounts.get(manual.manufacturer) || 0 return count >= minCount }) } /** * Filter manuals by site configuration * Only includes manuals from allowed manufacturers for the site */ export function filterManualsBySite( manuals: Manual[], allowedManufacturers: string[] | 'all', aliases: Record, includePaymentComponents: boolean = true ): Manual[] { // If all manufacturers are allowed, return all manuals if (allowedManufacturers === 'all') { return manuals } // Create a comprehensive set of allowed manufacturer names and aliases for fast lookup const allowedSet = new Set() for (const manufacturer of allowedManufacturers) { const normalized = manufacturer.toLowerCase().trim() allowedSet.add(normalized) // Add all aliases for this manufacturer if (aliases[manufacturer]) { for (const alias of aliases[manufacturer]) { allowedSet.add(alias.toLowerCase().trim()) // Also add normalized versions (replace spaces with hyphens, etc.) allowedSet.add(alias.toLowerCase().trim().replace(/\s+/g, '-')) allowedSet.add(alias.toLowerCase().trim().replace(/-/g, ' ')) } } // Also add normalized versions of the canonical name allowedSet.add(normalized.replace(/\s+/g, '-')) allowedSet.add(normalized.replace(/-/g, ' ')) } // Payment component directories that should always be included const paymentComponentDirs = ['coin-mechs', 'bill-mechs', 'card-readers'] return manuals.filter((manual) => { const pathLower = manual.path.toLowerCase() // Always include payment components if configured if (includePaymentComponents) { for (const dir of paymentComponentDirs) { if (pathLower.includes(dir)) { return true } } } // Normalize manufacturer name for matching (handle variations) let manufacturerNormalized = manual.manufacturer.toLowerCase().trim() // Check direct match if (allowedSet.has(manufacturerNormalized)) { return true } // Try normalized variations const variations = [ manufacturerNormalized, manufacturerNormalized.replace(/\s+/g, '-'), manufacturerNormalized.replace(/-/g, ' '), manufacturerNormalized.replace(/[^a-z0-9]/g, ''), ] for (const variation of variations) { if (allowedSet.has(variation)) { return true } } // Check if any alias matches for (const [canonicalName, aliasList] of Object.entries(aliases)) { // Check if canonical name is in allowed list const canonicalLower = canonicalName.toLowerCase().trim() if (allowedSet.has(canonicalLower) || allowedSet.has(canonicalLower.replace(/\s+/g, '-')) || allowedSet.has(canonicalLower.replace(/-/g, ' '))) { // Check if this manual's manufacturer matches any alias for (const alias of aliasList) { const aliasLower = alias.toLowerCase().trim() if (manufacturerNormalized === aliasLower || manufacturerNormalized === aliasLower.replace(/\s+/g, '-') || manufacturerNormalized === aliasLower.replace(/-/g, ' ') || manufacturerNormalized.includes(aliasLower) || aliasLower.includes(manufacturerNormalized)) { return true } } } } // Final fuzzy matching - check if manufacturer name contains or is contained by any allowed manufacturer for (const allowed of allowedManufacturers) { const allowedLower = allowed.toLowerCase().trim() const allowedVariations = [ allowedLower, allowedLower.replace(/\s+/g, '-'), allowedLower.replace(/-/g, ' '), ] for (const allowedVar of allowedVariations) { if (manufacturerNormalized === allowedVar || manufacturerNormalized.includes(allowedVar) || allowedVar.includes(manufacturerNormalized)) { return true } } } return false }) } /** * Filter manuals by manufacturer and/or category */ export function filterManuals( manuals: Manual[], manufacturer?: string, category?: string, searchTerm?: string ): Manual[] { let filtered = manuals if (manufacturer) { filtered = filtered.filter(m => m.manufacturer === manufacturer) } if (category) { filtered = filtered.filter(m => m.category === category) } if (searchTerm) { const search = searchTerm.toLowerCase() filtered = filtered.filter(m => m.filename.toLowerCase().includes(search) || m.manufacturer.toLowerCase().includes(search) || m.category.toLowerCase().includes(search) ) } return filtered } // getManualUrl is exported from manuals-types.ts for client components export { getManualUrl } from './manuals-types'