Rocky_Mountain_Vending/lib/manuals.ts
DMleadgen 46d973904b
Initial commit: Rocky Mountain Vending website
Next.js website for Rocky Mountain Vending company featuring:
- Product catalog with Stripe integration
- Service areas and parts pages
- Admin dashboard with Clerk authentication
- SEO optimized pages with JSON-LD structured data

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 16:22:15 -07:00

907 lines
31 KiB
TypeScript

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'
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<string, string[]> {
const commonNamesMap = new Map<string, string[]>()
try {
// Try to load from project root manuals-data/data/manuals.json
const dataPath = join(process.cwd(), '..', 'manuals-data', 'data', '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<Manual[]> {
const manualsDir = join(process.cwd(), '..', 'manuals-data', 'manuals')
const manuals: Manual[] = []
const commonNamesMap = loadCommonNames()
/**
* Recursively scan a directory for PDF files
*/
async function scanDirectory(dirPath: string, relativePath: string = ''): Promise<void> {
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('<!DOCTYPE html>') ||
content.includes('<html>') ||
content.includes('cannot be found') ||
content.includes('The resource cannot be found') ||
content.includes('404') ||
content.includes('Not Found')) {
if (process.env.NODE_ENV === 'development') {
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 (process.env.NODE_ENV === 'development') {
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 = join(process.cwd(), '..', 'manuals-data', 'thumbnails')
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<string>()
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<modelNumber> }
*/
let mdbMachinesCache: { [manufacturer: string]: Set<string> } | null = null
function loadMDBCapableMachines(): { [manufacturer: string]: Set<string> } {
if (mdbMachinesCache !== null) {
return mdbMachinesCache
}
mdbMachinesCache = {}
try {
const manufacturersPath = join(process.cwd(), '..', 'manuals-data', 'manufacturer-info', '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<string> }): 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<string> | 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<string, number>()
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<string, string[]>,
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<string>()
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'