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"