Rocky_Mountain_Vending/lib/manuals.ts

988 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"
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<string, string[]> {
const commonNamesMap = new Map<string, string[]>()
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<Manual[]> {
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<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 (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<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(
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<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"