cybermax / recallroll /core.js
CyberMax
Deploy recallroll to the hub
2b8a1f7 verified
Raw History Blame Contribute Delete
6.33 kB
/* 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);