988 lines
31 KiB
TypeScript
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"
|