@meterapp/vehicle-db 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -39,6 +39,13 @@ export interface GetModelsOptions {
39
39
  year?: number;
40
40
  vehicleTypeId?: number;
41
41
  makeId?: number;
42
+ modelId?: number;
43
+ sourceId?: string;
44
+ }
45
+ export interface GetAvailableYearsOptions {
46
+ vehicleTypeId?: number;
47
+ makeId?: number;
48
+ modelId?: number;
42
49
  sourceId?: string;
43
50
  }
44
51
  /**
@@ -58,6 +65,6 @@ export declare function getMakes(options?: GetMakesOptions): Make[];
58
65
  */
59
66
  export declare function getModels(options?: GetModelsOptions): Model[];
60
67
  /**
61
- * Get all available years in the database.
68
+ * Gets available years, optionally filtered by vehicle identity and source.
62
69
  */
63
- export declare function getAvailableYears(): number[];
70
+ export declare function getAvailableYears(options?: GetAvailableYearsOptions): number[];
package/dist/index.js CHANGED
@@ -105,7 +105,7 @@ function getMakes(options = {}) {
105
105
  function getModels(options = {}) {
106
106
  const makeMap = getMakeMap();
107
107
  const typeMap = getTypeMap();
108
- const { year, vehicleTypeId, makeId, sourceId } = options;
108
+ const { year, vehicleTypeId, makeId, modelId, sourceId } = options;
109
109
  const sourceMask = sourceId == null ? undefined : getSourceMaskMap().get(sourceId);
110
110
  if (sourceId != null && sourceMask == null)
111
111
  return [];
@@ -115,6 +115,8 @@ function getModels(options = {}) {
115
115
  continue;
116
116
  if (makeId != null && m[1] !== makeId)
117
117
  continue;
118
+ if (modelId != null && m[2] !== modelId)
119
+ continue;
118
120
  if (vehicleTypeId != null && m[4] !== vehicleTypeId)
119
121
  continue;
120
122
  if (sourceMask != null && (m[5] & sourceMask) === 0)
@@ -133,11 +135,23 @@ function getModels(options = {}) {
133
135
  a.vehicleTypeId - b.vehicleTypeId);
134
136
  }
135
137
  /**
136
- * Get all available years in the database.
138
+ * Gets available years, optionally filtered by vehicle identity and source.
137
139
  */
138
- function getAvailableYears() {
140
+ function getAvailableYears(options = {}) {
141
+ const { vehicleTypeId, makeId, modelId, sourceId } = options;
142
+ const sourceMask = sourceId == null ? undefined : getSourceMaskMap().get(sourceId);
143
+ if (sourceId != null && sourceMask == null)
144
+ return [];
139
145
  const years = new Set();
140
146
  for (const m of data_1.default.models) {
147
+ if (makeId != null && m[1] !== makeId)
148
+ continue;
149
+ if (modelId != null && m[2] !== modelId)
150
+ continue;
151
+ if (vehicleTypeId != null && m[4] !== vehicleTypeId)
152
+ continue;
153
+ if (sourceMask != null && (m[5] & sourceMask) === 0)
154
+ continue;
141
155
  years.add(m[0]);
142
156
  }
143
157
  return [...years].sort((a, b) => a - b);
@@ -0,0 +1,47 @@
1
+ export type VehicleSearchMatchKind = "exact" | "prefix" | "token" | "contains" | "fuzzy";
2
+ export interface SearchVehiclesOptions {
3
+ /** Restrict results to one catalog year. A year in the query is detected automatically. */
4
+ year?: number;
5
+ vehicleTypeId?: number;
6
+ sourceId?: string;
7
+ /** Maximum number of results. Defaults to 10 and is capped at 100. */
8
+ limit?: number;
9
+ /** Enables conservative typo matching. Defaults to true. */
10
+ fuzzy?: boolean;
11
+ }
12
+ interface VehicleSearchResultBase {
13
+ makeId: number;
14
+ makeName: string;
15
+ /** Available years after applying the supplied filters, sorted ascending. */
16
+ years: number[];
17
+ sourceIds: string[];
18
+ matchKind: VehicleSearchMatchKind;
19
+ }
20
+ export interface VehicleMakeSearchResult extends VehicleSearchResultBase {
21
+ kind: "make";
22
+ }
23
+ export interface VehicleSearchVariant {
24
+ year: number;
25
+ /** The preferred catalog model ID for this canonical make/model/type/year. */
26
+ modelId: number;
27
+ sourceIds: string[];
28
+ }
29
+ export interface VehicleModelSearchResult extends VehicleSearchResultBase {
30
+ kind: "model";
31
+ modelName: string;
32
+ vehicleTypeId: number;
33
+ vehicleTypeName: string;
34
+ /** One directly selectable catalog identity per available year. */
35
+ variants: VehicleSearchVariant[];
36
+ }
37
+ export type VehicleSearchResult = VehicleMakeSearchResult | VehicleModelSearchResult;
38
+ /**
39
+ * Finds canonical make and model candidates for a driver-entered query.
40
+ *
41
+ * Matching is deterministic and popularity-neutral: exact, prefix, token,
42
+ * substring, then conservative typo matching. Applications can request a
43
+ * larger candidate set and rerank within the returned `matchKind` using local
44
+ * recents or regional telemetry.
45
+ */
46
+ export declare function searchVehicles(query: string, options?: SearchVehiclesOptions): VehicleSearchResult[];
47
+ export {};
package/dist/search.js ADDED
@@ -0,0 +1,482 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.searchVehicles = searchVehicles;
7
+ const data_1 = __importDefault(require("./data"));
8
+ const MATCH_RANK = {
9
+ exact: 0,
10
+ prefix: 1,
11
+ token: 2,
12
+ contains: 3,
13
+ fuzzy: 4,
14
+ };
15
+ // These are search-only colloquialisms. They do not rewrite catalog identities.
16
+ const MAKE_SEARCH_ALIASES = new Map([
17
+ ["alfa romeo", ["alfa"]],
18
+ ["bmw", ["bimmer", "beemer"]],
19
+ ["chevrolet", ["chevy"]],
20
+ ["general motors", ["gm"]],
21
+ ["harley davidson", ["harley"]],
22
+ ["mercedes benz", ["mercedes", "benz", "merc"]],
23
+ ["volkswagen", ["vw"]],
24
+ ]);
25
+ const sourceMaskById = new Map(data_1.default.sources.map((source, index) => [source.source_id, 2 ** index]));
26
+ const vehicleTypeNameById = new Map(data_1.default.vehicleTypes.map((type) => [type.vehicle_type_id, type.vehicle_type_name]));
27
+ let _searchIndex = null;
28
+ /**
29
+ * Finds canonical make and model candidates for a driver-entered query.
30
+ *
31
+ * Matching is deterministic and popularity-neutral: exact, prefix, token,
32
+ * substring, then conservative typo matching. Applications can request a
33
+ * larger candidate set and rerank within the returned `matchKind` using local
34
+ * recents or regional telemetry.
35
+ */
36
+ function searchVehicles(query, options = {}) {
37
+ if (options.year != null && !Number.isInteger(options.year))
38
+ return [];
39
+ const parsedQueries = parseQueries(query, options.year);
40
+ if (parsedQueries.length === 0)
41
+ return [];
42
+ const sourceMask = options.sourceId == null ? undefined : sourceMaskById.get(options.sourceId);
43
+ if (options.sourceId != null && sourceMask == null)
44
+ return [];
45
+ const limit = normalizeLimit(options.limit);
46
+ if (limit === 0)
47
+ return [];
48
+ const index = getSearchIndex();
49
+ const rankedByKey = collectQueryInterpretations(index, parsedQueries, options, sourceMask, false);
50
+ preferUnmodifiedInterpretation(rankedByKey);
51
+ // Typo matching is a fallback, not filler after a good lexical match. This
52
+ // keeps an exact vehicle from being followed by unrelated near-spellings.
53
+ if (options.fuzzy !== false && rankedByKey.size === 0) {
54
+ for (const [key, result] of collectQueryInterpretations(index, parsedQueries, options, sourceMask, true)) {
55
+ rankedByKey.set(key, result);
56
+ }
57
+ preferUnmodifiedInterpretation(rankedByKey);
58
+ }
59
+ const ranked = [...rankedByKey.values()];
60
+ ranked.sort(compareRankedResults);
61
+ return ranked.slice(0, limit).map(({ result }) => result);
62
+ }
63
+ function preferUnmodifiedInterpretation(results) {
64
+ if (![...results.values()].some((result) => result.interpretationRank === 0))
65
+ return;
66
+ for (const [key, result] of results) {
67
+ if (result.interpretationRank > 0)
68
+ results.delete(key);
69
+ }
70
+ }
71
+ function collectQueryInterpretations(index, parsedQueries, options, sourceMask, fuzzyOnly) {
72
+ const rankedByKey = new Map();
73
+ for (const parsed of parsedQueries) {
74
+ const interpretationResults = [];
75
+ collectMatches(index, parsed.text, parsed.year, parsed.interpretationRank, options, sourceMask, fuzzyOnly, interpretationResults, new Set());
76
+ for (const result of interpretationResults) {
77
+ const existing = rankedByKey.get(result.key);
78
+ if (!existing || compareRankedResults(result, existing) < 0) {
79
+ rankedByKey.set(result.key, result);
80
+ }
81
+ }
82
+ }
83
+ return rankedByKey;
84
+ }
85
+ function collectMatches(index, query, year, interpretationRank, options, sourceMask, fuzzyOnly, results, includedKeys) {
86
+ for (const make of index.makes) {
87
+ const key = `make\u0000${make.makeId}`;
88
+ if (includedKeys.has(key))
89
+ continue;
90
+ const match = matchForms(query, make.forms, fuzzyOnly);
91
+ if (!match || (fuzzyOnly && match.kind !== "fuzzy"))
92
+ continue;
93
+ const availability = selectMakeAvailability(make.rows, year, options.vehicleTypeId, sourceMask);
94
+ if (availability.years.length === 0)
95
+ continue;
96
+ includedKeys.add(key);
97
+ results.push({
98
+ key,
99
+ rank: match.rank,
100
+ interpretationRank,
101
+ closeness: match.closeness,
102
+ result: {
103
+ kind: "make",
104
+ makeId: make.makeId,
105
+ makeName: make.makeName,
106
+ years: availability.years,
107
+ sourceIds: getSourceIds(availability.sourceMask),
108
+ matchKind: match.kind,
109
+ },
110
+ });
111
+ }
112
+ for (const model of index.models) {
113
+ if (options.vehicleTypeId != null && model.vehicleTypeId !== options.vehicleTypeId)
114
+ continue;
115
+ const key = `model\u0000${model.makeId}\u0000${model.modelForms[0].compact}\u0000${model.vehicleTypeId}`;
116
+ if (includedKeys.has(key))
117
+ continue;
118
+ const modelMatch = matchForms(query, model.modelForms, fuzzyOnly);
119
+ const makeMatch = matchForms(query, model.makeForms, fuzzyOnly);
120
+ const match = modelMatch ?? (makeMatch ? undefined : matchForms(query, model.combinedForms, fuzzyOnly));
121
+ if (!match || (fuzzyOnly && match.kind !== "fuzzy"))
122
+ continue;
123
+ const variants = selectModelVariants(model.rows, year, sourceMask);
124
+ if (variants.length === 0)
125
+ continue;
126
+ const representative = variants.reduce((best, variant) => compareRepresentativeVariants(variant, best) < 0 ? variant : best);
127
+ const combinedSourceMask = variants.reduce((mask, variant) => mask | variant.sourceMask, 0);
128
+ includedKeys.add(key);
129
+ results.push({
130
+ key,
131
+ rank: match.rank,
132
+ interpretationRank,
133
+ closeness: match.closeness,
134
+ result: {
135
+ kind: "model",
136
+ makeId: model.makeId,
137
+ makeName: model.makeName,
138
+ modelName: representative.modelName,
139
+ vehicleTypeId: model.vehicleTypeId,
140
+ vehicleTypeName: model.vehicleTypeName,
141
+ years: variants.map((variant) => variant.year),
142
+ sourceIds: getSourceIds(combinedSourceMask),
143
+ matchKind: match.kind,
144
+ variants: variants.map(({ year: variantYear, modelId, sourceIds }) => ({
145
+ year: variantYear,
146
+ modelId,
147
+ sourceIds,
148
+ })),
149
+ },
150
+ });
151
+ }
152
+ }
153
+ function getSearchIndex() {
154
+ if (_searchIndex)
155
+ return _searchIndex;
156
+ const makesById = new Map();
157
+ const makeRowsById = new Map();
158
+ for (const make of data_1.default.makes) {
159
+ const baseForm = normalizeSearchText(make.make_name);
160
+ const aliases = MAKE_SEARCH_ALIASES.get(baseForm.spaced) ?? [];
161
+ makesById.set(make.make_id, {
162
+ makeId: make.make_id,
163
+ makeName: make.make_name,
164
+ forms: uniqueForms([baseForm, ...aliases.map(normalizeSearchText)]),
165
+ rows: [],
166
+ });
167
+ makeRowsById.set(make.make_id, new Map());
168
+ }
169
+ const modelsByKey = new Map();
170
+ for (const row of data_1.default.models) {
171
+ const [year, makeId, modelId, modelNameIndex, vehicleTypeId, sourceMask] = row;
172
+ const make = makesById.get(makeId);
173
+ const modelName = data_1.default.modelNames[modelNameIndex];
174
+ const vehicleTypeName = vehicleTypeNameById.get(vehicleTypeId);
175
+ if (!make || modelName == null || vehicleTypeName == null)
176
+ continue;
177
+ const makeRows = makeRowsById.get(makeId);
178
+ const makeRowKey = `${year}\u0000${vehicleTypeId}`;
179
+ const existingMakeRow = makeRows.get(makeRowKey);
180
+ if (existingMakeRow)
181
+ existingMakeRow.sourceMask |= sourceMask;
182
+ else
183
+ makeRows.set(makeRowKey, { year, vehicleTypeId, sourceMask });
184
+ const modelForm = normalizeSearchText(modelName);
185
+ const key = `${makeId}\u0000${modelForm.compact}\u0000${vehicleTypeId}`;
186
+ let model = modelsByKey.get(key);
187
+ if (!model) {
188
+ model = {
189
+ makeId,
190
+ makeName: make.makeName,
191
+ vehicleTypeId,
192
+ vehicleTypeName,
193
+ makeForms: make.forms,
194
+ modelForms: [],
195
+ combinedForms: [],
196
+ modelFormMap: new Map(),
197
+ rows: [],
198
+ };
199
+ modelsByKey.set(key, model);
200
+ }
201
+ model.modelFormMap.set(`${modelForm.spaced}\u0000${modelForm.compact}`, modelForm);
202
+ model.rows.push(row);
203
+ }
204
+ for (const make of makesById.values()) {
205
+ make.rows = [...makeRowsById.get(make.makeId).values()];
206
+ }
207
+ const models = [...modelsByKey.values()].map(({ modelFormMap, ...model }) => {
208
+ model.modelForms = [...modelFormMap.values()];
209
+ model.combinedForms = uniqueForms(model.makeForms.flatMap((makeForm) => model.modelForms.map((modelForm) => normalizeSearchText(`${makeForm.spaced} ${modelForm.spaced}`))));
210
+ return model;
211
+ });
212
+ _searchIndex = {
213
+ makes: [...makesById.values()],
214
+ models,
215
+ };
216
+ return _searchIndex;
217
+ }
218
+ function normalizeSearchText(value) {
219
+ const spaced = value
220
+ .normalize("NFKD")
221
+ .replace(/[\u0300-\u036f]/g, "")
222
+ .toLocaleLowerCase("en-US")
223
+ .replace(/&/g, " and ")
224
+ .replace(/[\u2019']/g, "")
225
+ .replace(/[^a-z0-9]+/g, " ")
226
+ .trim()
227
+ .replace(/\s+/g, " ");
228
+ return {
229
+ spaced,
230
+ compact: spaced.replace(/\s/g, ""),
231
+ tokens: spaced.length === 0 ? [] : spaced.split(" "),
232
+ };
233
+ }
234
+ function uniqueForms(forms) {
235
+ const seen = new Set();
236
+ return forms.filter((form) => {
237
+ const key = `${form.spaced}\u0000${form.compact}`;
238
+ if (seen.has(key))
239
+ return false;
240
+ seen.add(key);
241
+ return true;
242
+ });
243
+ }
244
+ function parseQueries(query, optionYear) {
245
+ const fullText = normalizeSearchText(query);
246
+ if (fullText.spaced.length === 0)
247
+ return [];
248
+ const detectedYears = [...query.matchAll(/\b(?:19|20)\d{2}\b/g)].map((match) => Number(match[0]));
249
+ const uniqueYears = [...new Set(detectedYears)];
250
+ const onlyYearTokens = fullText.tokens.length > 0 && fullText.tokens.every((token) => {
251
+ const value = Number(token);
252
+ return Number.isInteger(value) && uniqueYears.includes(value);
253
+ });
254
+ if (onlyYearTokens)
255
+ return [];
256
+ const interpretations = [
257
+ { text: fullText, year: optionYear, interpretationRank: 0 },
258
+ ];
259
+ const inferredYears = optionYear == null ? uniqueYears : uniqueYears.filter((year) => year === optionYear);
260
+ for (const year of inferredYears) {
261
+ const text = normalizeSearchText(query.replace(new RegExp(`\\b${year}\\b`, "g"), " "));
262
+ if (text.spaced.length === 0)
263
+ continue;
264
+ interpretations.push({ text, year: optionYear ?? year, interpretationRank: 1 });
265
+ }
266
+ return interpretations;
267
+ }
268
+ function normalizeLimit(limit) {
269
+ if (limit == null)
270
+ return 10;
271
+ if (!Number.isFinite(limit) || limit <= 0)
272
+ return 0;
273
+ return Math.min(100, Math.floor(limit));
274
+ }
275
+ function matchForms(query, forms, fuzzyOnly) {
276
+ let best;
277
+ for (const form of forms) {
278
+ const match = fuzzyOnly ? fuzzyMatch(query, form) : nonFuzzyMatch(query, form);
279
+ if (match && (!best || compareMatches(match, best) < 0))
280
+ best = match;
281
+ }
282
+ return best;
283
+ }
284
+ function nonFuzzyMatch(query, target) {
285
+ if (query.spaced === target.spaced || query.compact === target.compact) {
286
+ return lexicalMatch("exact", 0);
287
+ }
288
+ if (target.spaced.startsWith(query.spaced) || target.compact.startsWith(query.compact)) {
289
+ return lexicalMatch("prefix", Math.max(0, target.compact.length - query.compact.length));
290
+ }
291
+ const tokenCloseness = tokenPrefixCloseness(query.tokens, target.tokens);
292
+ if (tokenCloseness != null)
293
+ return lexicalMatch("token", tokenCloseness);
294
+ const spacedIndex = target.spaced.indexOf(query.spaced);
295
+ const compactIndex = target.compact.indexOf(query.compact);
296
+ if (spacedIndex >= 0 || compactIndex >= 0) {
297
+ const index = Math.min(spacedIndex < 0 ? Number.MAX_SAFE_INTEGER : spacedIndex, compactIndex < 0 ? Number.MAX_SAFE_INTEGER : compactIndex);
298
+ return lexicalMatch("contains", index + target.compact.length - query.compact.length);
299
+ }
300
+ return undefined;
301
+ }
302
+ function fuzzyMatch(query, target) {
303
+ const fullLimit = allowedEdits(query.compact.length);
304
+ if (fullLimit > 0 && Math.abs(query.compact.length - target.compact.length) <= fullLimit) {
305
+ const distance = damerauLevenshtein(query.compact, target.compact, fullLimit);
306
+ if (distance <= fullLimit)
307
+ return lexicalMatch("fuzzy", distance);
308
+ }
309
+ const tokenDistance = fuzzyTokenDistance(query.tokens, target.tokens);
310
+ return tokenDistance == null ? undefined : lexicalMatch("fuzzy", tokenDistance);
311
+ }
312
+ function lexicalMatch(kind, closeness) {
313
+ return { kind, rank: MATCH_RANK[kind], closeness };
314
+ }
315
+ function compareMatches(left, right) {
316
+ return left.rank - right.rank || left.closeness - right.closeness;
317
+ }
318
+ function tokenPrefixCloseness(queryTokens, targetTokens) {
319
+ if (queryTokens.length === 0 || queryTokens.length > targetTokens.length)
320
+ return undefined;
321
+ const unused = new Set(targetTokens.map((_, index) => index));
322
+ let closeness = 0;
323
+ for (const queryToken of [...queryTokens].sort((left, right) => right.length - left.length)) {
324
+ let bestIndex;
325
+ let bestDifference = Number.MAX_SAFE_INTEGER;
326
+ for (const index of unused) {
327
+ const targetToken = targetTokens[index];
328
+ if (!targetToken.startsWith(queryToken))
329
+ continue;
330
+ const difference = targetToken.length - queryToken.length;
331
+ if (difference < bestDifference) {
332
+ bestDifference = difference;
333
+ bestIndex = index;
334
+ }
335
+ }
336
+ if (bestIndex == null)
337
+ return undefined;
338
+ unused.delete(bestIndex);
339
+ closeness += bestDifference;
340
+ }
341
+ return closeness;
342
+ }
343
+ function fuzzyTokenDistance(queryTokens, targetTokens) {
344
+ if (queryTokens.length === 0 || queryTokens.length > targetTokens.length)
345
+ return undefined;
346
+ const unused = new Set(targetTokens.map((_, index) => index));
347
+ let totalDistance = 0;
348
+ for (const queryToken of [...queryTokens].sort((left, right) => right.length - left.length)) {
349
+ const limit = allowedEdits(queryToken.length);
350
+ if (limit === 0)
351
+ return undefined;
352
+ let bestIndex;
353
+ let bestDistance = Number.MAX_SAFE_INTEGER;
354
+ for (const index of unused) {
355
+ const targetToken = targetTokens[index];
356
+ if (Math.abs(queryToken.length - targetToken.length) > limit)
357
+ continue;
358
+ const distance = damerauLevenshtein(queryToken, targetToken, limit);
359
+ if (distance < bestDistance) {
360
+ bestDistance = distance;
361
+ bestIndex = index;
362
+ }
363
+ }
364
+ if (bestIndex == null || bestDistance > limit)
365
+ return undefined;
366
+ unused.delete(bestIndex);
367
+ totalDistance += bestDistance;
368
+ }
369
+ return totalDistance;
370
+ }
371
+ function allowedEdits(length) {
372
+ if (length < 4)
373
+ return 0;
374
+ return length < 6 ? 1 : 2;
375
+ }
376
+ function damerauLevenshtein(left, right, maximum) {
377
+ if (Math.abs(left.length - right.length) > maximum)
378
+ return maximum + 1;
379
+ let previousPrevious = new Array(right.length + 1).fill(0);
380
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
381
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex++) {
382
+ const current = new Array(right.length + 1).fill(0);
383
+ current[0] = leftIndex;
384
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex++) {
385
+ const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
386
+ current[rightIndex] = Math.min(previous[rightIndex] + 1, current[rightIndex - 1] + 1, previous[rightIndex - 1] + substitutionCost);
387
+ if (leftIndex > 1 &&
388
+ rightIndex > 1 &&
389
+ left[leftIndex - 1] === right[rightIndex - 2] &&
390
+ left[leftIndex - 2] === right[rightIndex - 1]) {
391
+ current[rightIndex] = Math.min(current[rightIndex], previousPrevious[rightIndex - 2] + 1);
392
+ }
393
+ }
394
+ previousPrevious = previous;
395
+ previous = current;
396
+ }
397
+ return previous[right.length];
398
+ }
399
+ function selectMakeAvailability(rows, year, vehicleTypeId, sourceMask) {
400
+ const years = new Set();
401
+ let combinedSourceMask = 0;
402
+ for (const row of rows) {
403
+ if (year != null && row.year !== year)
404
+ continue;
405
+ if (vehicleTypeId != null && row.vehicleTypeId !== vehicleTypeId)
406
+ continue;
407
+ if (sourceMask != null && (row.sourceMask & sourceMask) === 0)
408
+ continue;
409
+ years.add(row.year);
410
+ combinedSourceMask |= row.sourceMask;
411
+ }
412
+ return { years: [...years].sort((left, right) => left - right), sourceMask: combinedSourceMask };
413
+ }
414
+ function selectModelVariants(rows, year, sourceMask) {
415
+ const variantsByYear = new Map();
416
+ for (const row of rows) {
417
+ const [rowYear, , modelId, modelNameIndex, , rowSourceMask] = row;
418
+ if (year != null && rowYear !== year)
419
+ continue;
420
+ if (sourceMask != null && (rowSourceMask & sourceMask) === 0)
421
+ continue;
422
+ const existing = variantsByYear.get(rowYear);
423
+ const candidate = {
424
+ year: rowYear,
425
+ modelId,
426
+ modelName: data_1.default.modelNames[modelNameIndex],
427
+ sourceMask: rowSourceMask,
428
+ sourceIds: getSourceIds(rowSourceMask),
429
+ };
430
+ if (!existing) {
431
+ variantsByYear.set(rowYear, candidate);
432
+ continue;
433
+ }
434
+ const combinedSourceMask = existing.sourceMask | rowSourceMask;
435
+ const preferred = compareRepresentativeVariants(candidate, existing) < 0 ? candidate : existing;
436
+ variantsByYear.set(rowYear, {
437
+ ...preferred,
438
+ sourceMask: combinedSourceMask,
439
+ sourceIds: getSourceIds(combinedSourceMask),
440
+ });
441
+ }
442
+ return [...variantsByYear.values()].sort((left, right) => left.year - right.year);
443
+ }
444
+ function compareRepresentativeVariants(left, right) {
445
+ return (firstSourceIndex(left.sourceMask) - firstSourceIndex(right.sourceMask) ||
446
+ displayNameRank(left.modelName) - displayNameRank(right.modelName) ||
447
+ left.modelName.length - right.modelName.length ||
448
+ compareStrings(left.modelName, right.modelName) ||
449
+ left.modelId - right.modelId);
450
+ }
451
+ function firstSourceIndex(sourceMask) {
452
+ for (let index = 0; index < data_1.default.sources.length; index++) {
453
+ if ((sourceMask & 2 ** index) !== 0)
454
+ return index;
455
+ }
456
+ return Number.MAX_SAFE_INTEGER;
457
+ }
458
+ function displayNameRank(value) {
459
+ return /[a-z]/.test(value) && /[A-Z]/.test(value) ? 0 : 1;
460
+ }
461
+ function getSourceIds(sourceMask) {
462
+ const sourceIds = [];
463
+ for (let index = 0; index < data_1.default.sources.length; index++) {
464
+ if ((sourceMask & 2 ** index) !== 0)
465
+ sourceIds.push(data_1.default.sources[index].source_id);
466
+ }
467
+ return sourceIds;
468
+ }
469
+ function compareRankedResults(left, right) {
470
+ return (left.rank - right.rank ||
471
+ left.interpretationRank - right.interpretationRank ||
472
+ left.closeness - right.closeness ||
473
+ (left.result.kind === right.result.kind ? 0 : left.result.kind === "make" ? -1 : 1) ||
474
+ compareStrings(left.result.makeName, right.result.makeName) ||
475
+ compareStrings(left.result.kind === "model" ? left.result.modelName : "", right.result.kind === "model" ? right.result.modelName : "") ||
476
+ (left.result.kind === "model" ? left.result.vehicleTypeId : 0) -
477
+ (right.result.kind === "model" ? right.result.vehicleTypeId : 0) ||
478
+ compareStrings(left.key, right.key));
479
+ }
480
+ function compareStrings(left, right) {
481
+ return left < right ? -1 : left > right ? 1 : 0;
482
+ }
package/package.json CHANGED
@@ -1,13 +1,34 @@
1
1
  {
2
2
  "name": "@meterapp/vehicle-db",
3
- "version": "2.4.0",
4
- "description": "Offline international vehicle makes/models database — zero dependencies",
3
+ "version": "2.6.0",
4
+ "description": "Offline international database for cars, motorcycles, trucks, buses, MPVs, and more",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/MeterApp/vehicle-db.git"
8
8
  },
9
9
  "main": "dist/index.js",
10
10
  "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "require": "./dist/index.js",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./search": {
18
+ "types": "./dist/search.d.ts",
19
+ "require": "./dist/search.js",
20
+ "default": "./dist/search.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "typesVersions": {
25
+ "*": {
26
+ "search": [
27
+ "dist/search.d.ts"
28
+ ]
29
+ }
30
+ },
31
+ "sideEffects": false,
11
32
  "files": [
12
33
  "dist"
13
34
  ],
@@ -16,6 +37,8 @@
16
37
  "build:data": "tsx scripts/build-db.ts",
17
38
  "refresh:nhtsa": "tsx scripts/import-nhtsa.ts",
18
39
  "refresh:uk-dft": "tsx scripts/import-uk-dft.ts",
40
+ "refresh:nzta-asia-pacific": "tsx scripts/import-nzta-asia-pacific.ts",
41
+ "refresh:malaysia-jpj": "tsx scripts/import-malaysia-jpj.ts",
19
42
  "build:ts": "tsc",
20
43
  "build": "npm run build:ts",
21
44
  "typecheck": "tsc --noEmit && tsc -p tsconfig.scripts.json",
@@ -26,10 +49,17 @@
26
49
  "nhtsa",
27
50
  "dvla",
28
51
  "vehicles",
52
+ "vehicle-search",
53
+ "autocomplete",
54
+ "fuzzy-search",
29
55
  "makes",
30
56
  "models",
31
57
  "motorcycles",
32
58
  "buses",
59
+ "asia",
60
+ "japan",
61
+ "malaysia",
62
+ "china",
33
63
  "international",
34
64
  "offline"
35
65
  ],