File size: 6,333 Bytes
2b8a1f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
/* 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);