/* Recallroll core: pure functions (no DOM, no network) shared by the page and the Node tests. © 2026 CyberMax */ (function (root) { 'use strict'; var TRANS = { A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8, J: 1, K: 2, L: 3, M: 4, N: 5, P: 7, R: 9, S: 2, T: 3, U: 4, V: 5, W: 6, X: 7, Y: 8, Z: 9 }; var WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]; function normVin(s) { return String(s || '').toUpperCase().replace(/[\s-]/g, ''); } // {vin, ok, error, warning}: 17 chars, no I/O/Q; check digit (position 9) is required for North American VINs, // so a mismatch is a warning (typo likely), not a hard error. function checkVin(input) { var vin = normVin(input); if (!vin) return { vin: vin, ok: false, error: 'Enter a VIN.' }; if (vin.length !== 17) return { vin: vin, ok: false, error: 'A VIN has 17 characters (this one has ' + vin.length + ').' }; if (/[IOQ]/.test(vin)) return { vin: vin, ok: false, error: 'VINs never contain the letters I, O or Q (check for 1 and 0).' }; if (!/^[A-HJ-NPR-Z0-9]{17}$/.test(vin)) return { vin: vin, ok: false, error: 'Only letters and digits are allowed.' }; var sum = 0; for (var i = 0; i < 17; i++) { var c = vin[i]; var v = /[0-9]/.test(c) ? +c : TRANS[c]; sum += v * WEIGHTS[i]; } var r = sum % 11, want = r === 10 ? 'X' : String(r); return { vin: vin, ok: true, checkDigit: want, warning: vin[8] === want ? null : 'The check digit (9th character) should be ' + want + ' for this VIN. North American VINs always pass this check, so look for a typo.' }; } // Pull VIN-looking tokens out of pasted text (one per line, commas, spreadsheet columns...). Keeps order, dedupes. function extractVins(text, max) { var seen = {}, out = []; String(text || '').toUpperCase().split(/[^A-Z0-9]+/).forEach(function (t) { if (t.length === 17 && !seen[t]) { seen[t] = 1; out.push(t); } }); return max ? out.slice(0, max) : out; } function pick(r, k) { var v = (r[k] || '').toString().trim(); return v && v !== 'Not Applicable' ? v : ''; } // vPIC DecodeVinValues(Batch) result row -> compact vehicle function vehicleFromVpic(r) { var code = pick(r, 'ErrorCode'); return { vin: pick(r, 'VIN'), year: pick(r, 'ModelYear'), make: pick(r, 'Make'), model: pick(r, 'Model'), trim: pick(r, 'Trim'), series: pick(r, 'Series'), body: pick(r, 'BodyClass'), type: pick(r, 'VehicleType'), engine: [pick(r, 'DisplacementL') && (Number(pick(r, 'DisplacementL')).toFixed(1) + ' L'), pick(r, 'EngineCylinders') && (pick(r, 'EngineCylinders') + ' cyl'), pick(r, 'FuelTypePrimary'), pick(r, 'ElectrificationLevel')].filter(Boolean).join(' · '), drive: pick(r, 'DriveType'), plant: [pick(r, 'PlantCity'), pick(r, 'PlantState'), pick(r, 'PlantCountry')].filter(Boolean).join(', '), manufacturer: pick(r, 'Manufacturer'), gvwr: pick(r, 'GVWR'), errorCode: code, errorText: pick(r, 'ErrorText'), decoded: !!(pick(r, 'Make') && pick(r, 'ModelYear')), }; } function key(s) { return String(s || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); } // Recall API model names don't always equal vPIC's ("F-150" vs "F-150 SUPERCREW"). Given the models NHTSA lists // for recalls in that make/year, return the names to query: exact match first, else names starting with ours. function matchModels(model, recallModels) { var k = key(model); if (!k) return []; var names = (recallModels || []).map(function (m) { return typeof m === 'string' ? m : m.model; }); var exact = names.filter(function (n) { return key(n) === k; }); if (exact.length) return exact.slice(0, 1); var starts = names.filter(function (n) { return key(n).indexOf(k) === 0 || k.indexOf(key(n)) === 0; }); return starts.slice(0, 4); } // api.nhtsa.gov recallsByVehicle rows -> deduped campaigns, newest first function normalizeRecalls(rows) { var seen = {}, out = []; (rows || []).forEach(function (r) { var id = r.NHTSACampaignNumber; if (!id || seen[id]) return; seen[id] = 1; out.push({ campaign: id, date: parseNhtsaDate(r.ReportReceivedDate), component: r.Component || '', summary: r.Summary || '', consequence: r.Consequence || '', remedy: r.Remedy || '', notes: r.Notes || '', manufacturer: r.Manufacturer || '', doNotDrive: !!r.parkIt, parkOutside: !!r.parkOutSide, ota: !!r.overTheAirUpdate, model: r.Model || '', year: r.ModelYear || '', }); }); out.sort(function (a, b) { return (b.date || '').localeCompare(a.date || ''); }); return out; } // The Recalls API sends day/month/year: "27/06/2019" -> "2019-06-27"; ISO strings pass through. function parseNhtsaDate(s) { s = String(s || ''); var m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})/); if (m) return m[3] + '-' + m[2] + '-' + m[1]; m = s.match(/^(\d{4}-\d{2}-\d{2})/); return m ? m[1] : ''; } function csvCell(v) { var s = v == null ? '' : String(v); if (/^[=+\-@]/.test(s)) s = "'" + s; // no formula injection when opened in a spreadsheet return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; } function toCsv(rows, cols) { return [cols.join(',')].concat(rows.map(function (r) { return cols.map(function (c) { return csvCell(r[c]); }).join(','); })).join('\r\n') + '\r\n'; } // filter the snapshot's recent campaigns by free text + make function filterRecent(list, q, make, flagsOnly) { q = String(q || '').toLowerCase().trim(); return (list || []).filter(function (c) { if (make && c.makes.split(', ').indexOf(make) < 0) return false; if (flagsOnly && !(c.do_not_drive || c.park_outside)) return false; if (!q) return true; return (c.vehicles + ' ' + c.component + ' ' + c.manufacturer + ' ' + c.campaign + ' ' + (c.summary || '')).toLowerCase().indexOf(q) >= 0; }); } var api = { checkVin: checkVin, extractVins: extractVins, vehicleFromVpic: vehicleFromVpic, matchModels: matchModels, normalizeRecalls: normalizeRecalls, parseNhtsaDate: parseNhtsaDate, toCsv: toCsv, csvCell: csvCell, filterRecent: filterRecent, normVin: normVin }; if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.RR = api; })(typeof window !== 'undefined' ? window : this);