vehicles 0.9.0 → 0.9.2

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.
Files changed (195) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -7
  3. data/README.md +353 -69
  4. data/data/PROVENANCE-vehicles.md +2 -2
  5. data/data/plates/PROVENANCE.md +2 -2
  6. data/data/plates/_decode/at-districts.yml +2 -2
  7. data/data/plates/_decode/cn-issuing-offices.yml +2 -2
  8. data/data/plates/_decode/cn-provinces.yml +2 -2
  9. data/data/plates/_decode/cz-kraj.yml +1 -1
  10. data/data/plates/_decode/dk-serial-blocks.yml +1 -1
  11. data/data/plates/_decode/es-provinces.yml +1 -1
  12. data/data/plates/_decode/fr-departements.yml +2 -2
  13. data/data/plates/_decode/gb-age-identifiers.yml +1 -1
  14. data/data/plates/_decode/gb-memory-tags.yml +1 -1
  15. data/data/plates/_decode/gr-prefectures.yml +1 -1
  16. data/data/plates/_decode/hr-cities.yml +3 -3
  17. data/data/plates/_decode/ie-counties.yml +3 -3
  18. data/data/plates/_decode/in-state-codes.yml +2 -2
  19. data/data/plates/_decode/it-provinces.yml +3 -3
  20. data/data/plates/_decode/jp-office-marks.yml +2 -2
  21. data/data/plates/_decode/jp-use-marks.yml +2 -2
  22. data/data/plates/_decode/kr-authority-marks.yml +1 -1
  23. data/data/plates/_decode/mx-entity-series.yml +1 -1
  24. data/data/plates/_decode/my-index-marks.yml +4 -4
  25. data/data/plates/_decode/pt-trailer-services.yml +1 -1
  26. data/data/plates/_decode/ro-counties.yml +1 -1
  27. data/data/plates/_decode/se-diplomatic.yml +1 -1
  28. data/data/plates/_decode/si-regions.yml +1 -1
  29. data/data/plates/_decode/ua-regions.yml +1 -1
  30. data/data/plates/_meta/classes.yml +1 -1
  31. data/data/plates/_meta/separators.yml +1 -1
  32. data/data/plates/ad.yml +555 -52
  33. data/data/plates/ar.yml +8 -8
  34. data/data/plates/at.yml +91 -10
  35. data/data/plates/au-act.yml +13 -13
  36. data/data/plates/au-nsw.yml +9 -9
  37. data/data/plates/au-nt.yml +13 -14
  38. data/data/plates/au-qld.yml +7 -7
  39. data/data/plates/au-sa.yml +7 -7
  40. data/data/plates/au-tas.yml +10 -10
  41. data/data/plates/au-vic.yml +6 -6
  42. data/data/plates/au-wa.yml +8 -8
  43. data/data/plates/be.yml +11 -11
  44. data/data/plates/br.yml +14 -14
  45. data/data/plates/ca-ab.yml +10 -10
  46. data/data/plates/ca-bc.yml +6 -6
  47. data/data/plates/ca-mb.yml +4 -4
  48. data/data/plates/ca-nb.yml +9 -9
  49. data/data/plates/ca-nl.yml +11 -11
  50. data/data/plates/ca-ns.yml +12 -12
  51. data/data/plates/ca-nt.yml +7 -7
  52. data/data/plates/ca-nu.yml +7 -7
  53. data/data/plates/ca-on.yml +21 -21
  54. data/data/plates/ca-pe.yml +13 -13
  55. data/data/plates/ca-qc.yml +11 -11
  56. data/data/plates/ca-sk.yml +6 -6
  57. data/data/plates/ca-yt.yml +6 -6
  58. data/data/plates/ch.yml +14 -14
  59. data/data/plates/cn.yml +9 -9
  60. data/data/plates/cy.yml +6 -6
  61. data/data/plates/cz.yml +9 -9
  62. data/data/plates/de.yml +8 -8
  63. data/data/plates/dk.yml +12 -12
  64. data/data/plates/ee.yml +13 -13
  65. data/data/plates/eg.yml +708 -0
  66. data/data/plates/es.yml +8 -8
  67. data/data/plates/fi.yml +14 -14
  68. data/data/plates/fr.yml +12 -12
  69. data/data/plates/gb.yml +16 -16
  70. data/data/plates/gr.yml +14 -14
  71. data/data/plates/hr.yml +15 -15
  72. data/data/plates/hu.yml +16 -16
  73. data/data/plates/ie.yml +7 -7
  74. data/data/plates/in.yml +7 -7
  75. data/data/plates/is.yml +3 -3
  76. data/data/plates/it.yml +21 -21
  77. data/data/plates/jp.yml +121 -121
  78. data/data/plates/kr.yml +6 -6
  79. data/data/plates/li.yml +6 -6
  80. data/data/plates/lt.yml +10 -10
  81. data/data/plates/lu.yml +4 -4
  82. data/data/plates/lv.yml +9 -9
  83. data/data/plates/mc.yml +4 -4
  84. data/data/plates/mt.yml +5 -5
  85. data/data/plates/mx.yml +7 -7
  86. data/data/plates/my.yml +18 -18
  87. data/data/plates/ng.yml +346 -0
  88. data/data/plates/nl.yml +5 -5
  89. data/data/plates/no.yml +7 -7
  90. data/data/plates/nz.yml +18 -18
  91. data/data/plates/pl.yml +4 -4
  92. data/data/plates/pt.yml +13 -5
  93. data/data/plates/ro.yml +14 -14
  94. data/data/plates/se.yml +13 -13
  95. data/data/plates/si.yml +8 -8
  96. data/data/plates/sk.yml +15 -15
  97. data/data/plates/sm.yml +7 -7
  98. data/data/plates/th.yml +10 -10
  99. data/data/plates/tr.yml +432 -0
  100. data/data/plates/ua.yml +9 -9
  101. data/data/plates/us-ak.yml +11 -11
  102. data/data/plates/us-al.yml +12 -12
  103. data/data/plates/us-ar.yml +15 -15
  104. data/data/plates/us-as.yml +10 -10
  105. data/data/plates/us-az.yml +10 -10
  106. data/data/plates/us-ca.yml +13 -13
  107. data/data/plates/us-co.yml +14 -14
  108. data/data/plates/us-ct.yml +24 -24
  109. data/data/plates/us-dc.yml +7 -7
  110. data/data/plates/us-de.yml +10 -10
  111. data/data/plates/us-fl.yml +17 -17
  112. data/data/plates/us-ga.yml +10 -10
  113. data/data/plates/us-gu.yml +11 -11
  114. data/data/plates/us-hi.yml +11 -11
  115. data/data/plates/us-ia.yml +9 -9
  116. data/data/plates/us-id.yml +13 -13
  117. data/data/plates/us-il.yml +11 -11
  118. data/data/plates/us-in.yml +9 -9
  119. data/data/plates/us-ks.yml +8 -8
  120. data/data/plates/us-ky.yml +11 -11
  121. data/data/plates/us-la.yml +11 -11
  122. data/data/plates/us-ma.yml +10 -10
  123. data/data/plates/us-md.yml +7 -7
  124. data/data/plates/us-me.yml +16 -16
  125. data/data/plates/us-mi.yml +8 -8
  126. data/data/plates/us-mn.yml +13 -13
  127. data/data/plates/us-mo.yml +13 -13
  128. data/data/plates/us-mp.yml +12 -12
  129. data/data/plates/us-ms.yml +8 -8
  130. data/data/plates/us-mt.yml +12 -12
  131. data/data/plates/us-nc.yml +12 -12
  132. data/data/plates/us-nd.yml +10 -10
  133. data/data/plates/us-ne.yml +10 -10
  134. data/data/plates/us-nh.yml +8 -8
  135. data/data/plates/us-nj.yml +8 -8
  136. data/data/plates/us-nm.yml +14 -14
  137. data/data/plates/us-nv.yml +18 -18
  138. data/data/plates/us-ny.yml +17 -17
  139. data/data/plates/us-oh.yml +13 -13
  140. data/data/plates/us-ok.yml +12 -12
  141. data/data/plates/us-or.yml +15 -15
  142. data/data/plates/us-pa.yml +14 -14
  143. data/data/plates/us-pr.yml +15 -15
  144. data/data/plates/us-ri.yml +10 -10
  145. data/data/plates/us-sc.yml +7 -7
  146. data/data/plates/us-sd.yml +12 -12
  147. data/data/plates/us-tn.yml +8 -8
  148. data/data/plates/us-tx.yml +17 -17
  149. data/data/plates/us-ut.yml +12 -12
  150. data/data/plates/us-va.yml +14 -14
  151. data/data/plates/us-vi.yml +16 -16
  152. data/data/plates/us-vt.yml +9 -9
  153. data/data/plates/us-wa.yml +18 -18
  154. data/data/plates/us-wi.yml +8 -8
  155. data/data/plates/us-wv.yml +9 -9
  156. data/data/plates/us-wy.yml +10 -10
  157. data/data/plates/va.yml +7 -7
  158. data/data/plates/za.yml +11 -11
  159. data/data/vehicles.json +121081 -62015
  160. data/lib/generators/vehicles/templates/initializer.rb +21 -12
  161. data/lib/generators/vehicles/templates/vehicles_refresh_job.rb +8 -4
  162. data/lib/vehicles/color.rb +8 -1
  163. data/lib/vehicles/colors.rb +14 -10
  164. data/lib/vehicles/configuration.rb +82 -3
  165. data/lib/vehicles/data_source.rb +220 -0
  166. data/lib/vehicles/dataset.rb +38 -6
  167. data/lib/vehicles/dataset_resolution.rb +39 -3
  168. data/lib/vehicles/dataset_slice.rb +90 -0
  169. data/lib/vehicles/hosted_result.rb +94 -0
  170. data/lib/vehicles/http.rb +75 -7
  171. data/lib/vehicles/id_lookup.rb +37 -0
  172. data/lib/vehicles/kind_rule.rb +63 -0
  173. data/lib/vehicles/labels.rb +131 -0
  174. data/lib/vehicles/make.rb +45 -19
  175. data/lib/vehicles/model.rb +66 -19
  176. data/lib/vehicles/model_match.rb +16 -2
  177. data/lib/vehicles/model_matcher.rb +6 -7
  178. data/lib/vehicles/plates/series.rb +87 -8
  179. data/lib/vehicles/plates/template_fill.rb +2 -2
  180. data/lib/vehicles/plates/templates.rb +9 -5
  181. data/lib/vehicles/plates.rb +54 -8
  182. data/lib/vehicles/providers/hosted_provider.rb +145 -32
  183. data/lib/vehicles/providers/local_provider.rb +2 -2
  184. data/lib/vehicles/refresh_result.rb +74 -0
  185. data/lib/vehicles/refresher.rb +190 -54
  186. data/lib/vehicles/release_checks.rb +72 -0
  187. data/lib/vehicles/text_matcher.rb +19 -10
  188. data/lib/vehicles/validators/vehicle_make_validator.rb +18 -1
  189. data/lib/vehicles/validators/vehicle_model_validator.rb +17 -1
  190. data/lib/vehicles/version.rb +1 -1
  191. data/lib/vehicles.rb +119 -67
  192. data/rakelib/data_refresh.rake +1 -1
  193. data/rakelib/plates_refresh.rake +1 -1
  194. data/sig/vehicles.rbs +67 -13
  195. metadata +13 -2
data/README.md CHANGED
@@ -2,10 +2,12 @@
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/vehicles.svg)](https://badge.fury.io/rb/vehicles) [![Build Status](https://github.com/vehiclesdb/vehicles/workflows/Tests/badge.svg)](https://github.com/vehiclesdb/vehicles/actions)
4
4
 
5
+ > **Get your free key, the integration guide and the credit snippet at [vehiclesdb.com](https://vehiclesdb.com/users/sign_up?for=app_saas&cta=readme_vehicles)**
6
+
5
7
  > [!TIP]
6
8
  > **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=vehicles)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=vehicles)!
7
9
 
8
- `vehicles` gives your Rails app a clean, curated list of vehicle makes and models — **~15k models across 850+ makes: cars, motorcycles, mopeds, vans, trucks and buses**, reconciled from official registers of 15 countries — ready for dropdowns, search, and validation. No API keys, no database table, no migration — it works **fully offline** the second you `bundle install`, because the data ships inside the gem. (Optionally, it can [refresh](#staying-current-optional) the data without a gem upgrade.)
10
+ `vehicles` gives your Rails app a clean, curated list of vehicle makes and models — **every make and model in the VehiclesDB open dataset: cars, motorcycles, mopeds, vans, trucks and buses**, reconciled from the official vehicle registers of every country the dataset covers (current counts: [vehiclesdb.com/makes](https://vehiclesdb.com/makes), or `manifest.json` in the data repo) — ready for dropdowns, search, and validation. No API keys, no database table, no migration — it works **fully offline** the second you `bundle install`, because the data ships inside the gem. (Optionally, it can [refresh](#staying-current-optional) the data without a gem upgrade.)
9
11
 
10
12
  ✨ Perfect for marketplaces, carpooling & rideshare apps, fleet tools, parking & EV-charging apps, insurance and booking forms — anywhere a user has to pick their vehicle.
11
13
 
@@ -62,7 +64,7 @@ Two methods cover 90% of what you need:
62
64
 
63
65
  ```ruby
64
66
  Vehicles.makes
65
- # => ["Abarth", "Acura", "Alfa Romeo", ...] (alphabetical, 850+ makes)
67
+ # => ["Abarth", "Acura", "Alfa Romeo", ...] (alphabetical, every make)
66
68
 
67
69
  Vehicles.models("Toyota")
68
70
  # => ["Yaris", "Corolla", "Aygo", "RAV4", "C-HR", "Prius", ...]
@@ -155,7 +157,24 @@ It tries, strongest first, and stops at the first rung that answers:
155
157
  - **No guessing from prefixes.** "C3 Aircr" is not "C3" (it's the C3 Aircross); only a curated form can say so.
156
158
  - **Ids stay exact.** Slug-, id- and path-shaped input (`"golf-gti"`, `"car/volkswagen/golf"`, symbols) only takes the exact rungs.
157
159
 
158
- `make.model(...)` and the `vehicle_model` validator stay strict — a form or a typo is not a valid model name to store. `kind:` works as in `model` (the car is preferred without it).
160
+ `make.model(...)` and the `vehicle_model` validator stay strict — a form or a typo is not a valid model name to store.
161
+
162
+ **Safe to write back without asking?** `confident?` is the recommended auto-apply threshold: an answer (not ambiguous) from a curated rung — the id, a former id, the name, a published alias, a curated form, or one of those after `:cleaned`. Never `:fuzzy`: a typo correction is a guess to *show* the user ("did you mean Touareg?"), not to store silently.
163
+
164
+ ```ruby
165
+ m = Vehicles.match_model(row.make, row.model, kind: :car)
166
+ row.update!(make: m.model.make, model: m.model.name, vehiclesdb_id: m.model.id) if m&.confident?
167
+ ```
168
+
169
+ **Kinds.** One nameplate can be several records (Iveco's Daily is a van, a truck and a bus). That is not ambiguity: without a kind, every VehiclesDB surface (gem, API, SDKs, widget) applies the same rule — the record with the lowest `mass_decile`, then van → car → bus → truck → moped → motorcycle, then the id — so "Iveco Daily" is the van everywhere (`Vehicles::KindRule`; until 0.9.1 the gem preferred the car). Pass `kind:` to choose one, or `kinds:` to allow only some: a record outside `kinds:` never answers, on any rung, ids included.
170
+
171
+ ```ruby
172
+ Vehicles.model("Iveco", "Daily") # => the van
173
+ Vehicles.model("Iveco", "Daily", kind: :bus) # => the bus
174
+ Vehicles.match_model("Iveco", "Daily", kinds: [:car]) # => nil (there is no car Daily)
175
+ Vehicles.identify("bus/iveco/daily", kinds: [:car]) # => nil (an id outside the scope too)
176
+ Vehicles.identify("bus/iveco/daily", kind: :car).model # => the bus: `kind:` never overrides an explicit id
177
+ ```
159
178
 
160
179
  One free-text string instead of a pair? `identify` splits off the make (the longest leading run that is a make) and runs the same ladder. It also takes canonical ids (`:exact`, 1.0), ids a record has replaced (`:former_id`, 0.98), and a whole string that is exactly one model's published alias (`:alias`, 0.90). Apart from those, nothing resolves without a make:
161
180
 
@@ -180,13 +199,14 @@ Every `Vehicles::Model` reads like English and serializes cleanly:
180
199
 
181
200
  ```ruby
182
201
  car = Vehicles.find("seat leon")
202
+ car.id # => "car/seat/leon" ← the canonical id: store this
183
203
  car.make # => "SEAT"
184
- car.name # => "Leon"
185
- car.full_name # => "SEAT Leon"
204
+ car.name # => "León"
205
+ car.full_name # => "SEAT León"
186
206
  car.slug # => "seat-leon"
187
- car.to_h # => { make: "SEAT", model: "Leon", slug: "seat-leon",
207
+ car.to_h # => { id: "car/seat/leon", make: "SEAT", model: "León", slug: "seat-leon",
188
208
  # kind: :car, body_type: :hatchback, global_decile: 2,
189
- # availability: ["es", "fi", "gb", ...] }
209
+ # availability: ["es", "fi", "gb", ...], ... }
190
210
  ```
191
211
 
192
212
  ## Kinds & body types
@@ -222,6 +242,15 @@ Vehicles.makes(kind: :motorcycle) # => makes that build bikes
222
242
  Vehicles.search("golf").select(&:hatchback?)
223
243
  ```
224
244
 
245
+ Labels for a kind or body type `<select>` ship in English and Spanish (you store the symbol):
246
+
247
+ ```ruby
248
+ Vehicles.kind_options(locale: :es) # => [["Autobús", "bus"], ["Camión", "truck"], ["Ciclomotor", "moped"], ["Coche", "car"], ...]
249
+ Vehicles.kind_label(:van, locale: :es) # => "Furgoneta"
250
+ Vehicles.body_type_label(:wagon, locale: :es) # => "Familiar"
251
+ Vehicles.body_type_options(locale: :es) # => [["Compacto", "hatchback"], ["Berlina", "sedan"], ...]
252
+ ```
253
+
225
254
  > [!NOTE]
226
255
  > `body_type` is honest: cars always carry one; other kinds return `nil` until
227
256
  > an honest vocabulary exists for them (a truck is not a "hatchback"). The
@@ -262,6 +291,8 @@ imports count, because they're real vehicles on real roads.
262
291
 
263
292
  ## MCP server for AI agents
264
293
 
294
+ > **Get your free key, the integration guide and the credit snippet at [vehiclesdb.com](https://vehiclesdb.com/users/sign_up?for=ai_app&cta=readme_vehicles_mcp)**
295
+
265
296
  The gem ships `vehicles-mcp` — a read-only [MCP](https://modelcontextprotocol.io)
266
297
  server over the bundled dataset, so your agents ground themselves in real
267
298
  vehicle data instead of hallucinating nameplates. No network, no keys:
@@ -290,11 +321,16 @@ Vehicles.color("navy") # forgiving: synonyms, case, diacritics
290
321
  Vehicles.color("navy").slug # => "blue" ← the stable value you store
291
322
  Vehicles.color("navy").hex # => "#27408B" (representative swatch)
292
323
 
293
- Vehicles.color_options # => [["White", "white"], ["Black", "black"], ...]
324
+ Vehicles.color_options # => [["White", "white"], ["Black", "black"], ...]
325
+ Vehicles.color_options(locale: :es) # => [["Blanco", "white"], ["Negro", "black"], ["Gris", "grey"], ...]
326
+ Vehicles.color("red").label(:es) # => "Rojo"
327
+ Vehicles.color("Marrón").slug # => "brown" — Spanish input resolves too ("rojo", "plateada", "granate")
328
+ Vehicles.colors(locale: :es).first # => #<Vehicles::Color white "Blanco" #F4F4F4>
294
329
  ```
295
330
 
296
- Names are English — store the **slug** and localize the label in your app (the
297
- slug is also what maps to image color-variants down the line).
331
+ Store the **slug** (it is also what maps to the image color variants); labels
332
+ ship in English and Spanish (`locale: "es-MX"` folds to `:es`; other locales
333
+ fall back to English).
298
334
 
299
335
  ## Rails dropdowns
300
336
 
@@ -310,17 +346,25 @@ Vehicles.model_options("audi")
310
346
 
311
347
  ### Dependent make → model picker
312
348
 
313
- The whole dataset is small, so the simplest, snappiest way to wire a dependent
314
- picker is **client-side**: embed [`Vehicles.catalog`](#the-full-ruby-api) once and
315
- switch the model list with no request at all — **no route, no controller, no
316
- fetch.**
349
+ Scope the picker to what your app serves and embed
350
+ [`Vehicles.catalog`](#the-full-ruby-api) once: the model list switches with no
351
+ request at all — **no route, no controller, no fetch.** For a one-country app,
352
+ `country:` keeps it to the models with official evidence there, in that
353
+ country's own popularity order (Spain's cars: ~140 makes, ~1,300 models, ~14 KB
354
+ of JSON; the whole worldwide car catalog is ~63 KB):
355
+
356
+ ```ruby
357
+ # app/helpers/vehicles_helper.rb
358
+ PICKER = { kind: :car, country: :es }.freeze
359
+ def vehicle_catalog = Vehicles.catalog(**PICKER)
360
+ ```
317
361
 
318
362
  ```erb
319
363
  <div data-controller="vehicle-picker"
320
- data-vehicle-picker-catalog-value="<%= Vehicles.catalog.to_json %>">
321
- <%= form.select :make, Vehicles.makes, { include_blank: "Make" },
364
+ data-vehicle-picker-catalog-value="<%= vehicle_catalog.to_json %>">
365
+ <%= form.select :make, vehicle_catalog.keys, { include_blank: "Make" },
322
366
  data: { vehicle_picker_target: "make", action: "change->vehicle-picker#makeChanged" } %>
323
- <%= form.select :model, Vehicles.models(@car&.make), { include_blank: "Model" },
367
+ <%= form.select :model, vehicle_catalog.fetch(@car&.make.to_s, []), { include_blank: "Model" },
324
368
  data: { vehicle_picker_target: "model" } %>
325
369
  </div>
326
370
  ```
@@ -346,7 +390,74 @@ export default class extends Controller {
346
390
 
347
391
  That's the whole picker — the gem ships the data, you keep your markup and ~12
348
392
  lines of JS. (Storing slugs instead of names? Swap the selects for
349
- `make_options` / `model_options` and key the catalog by slug.)
393
+ `make_options` / `model_options` and key the catalog by slug.) The same scope
394
+ also works on every list helper: `Vehicles.makes(kind: :car, country: :es)`,
395
+ `Vehicles.models("SEAT", kind: :car, country: :es)`, `make_options` /
396
+ `model_options(country:)`. Country evidence means *presence* in official data,
397
+ not an exhaustive list of what is legal there — keep an "Other" option.
398
+
399
+ ### The make-model-dropdown widget, fed by your server
400
+
401
+ The [make-model-dropdown](https://github.com/vehiclesdb/make-model-dropdown)
402
+ widget (0.4.1+) does the picker for you: search, kept stored values, the
403
+ canonical id. `Vehicles.dataset_slice` takes the same scope as `catalog` and
404
+ returns the records behind the names, in the shape of the published
405
+ `dist/vehicles.json`, so the widget resolves stored values (aliases, curated
406
+ forms, former ids) and writes ids exactly as it would with the whole release.
407
+ Embed it and the page makes **no request** for vehicle data — no CDN, no API
408
+ key — and shows the release your server runs:
409
+
410
+ ```ruby
411
+ # app/helpers/vehicles_helper.rb
412
+ # Built once per data release (Spain's cars: ~360 KB of JSON, ~57 KB gzipped).
413
+ def vehicles_dataset_json(**scope)
414
+ Rails.cache.fetch(["vehicles/dataset_slice", Vehicles.data_version, scope]) do
415
+ Vehicles.dataset_slice(**scope).to_json
416
+ end
417
+ end
418
+ ```
419
+
420
+ ```erb
421
+ <script type="application/json" id="vehicles-data"><%= json_escape(vehicles_dataset_json(kind: :car, country: :es)).html_safe %></script>
422
+
423
+ <div data-controller="vehicle-picker">
424
+ <%= form.select :make, [@car.make].compact, {}, data: { vehicle_picker_target: "make" } %>
425
+ <%= form.select :model, [@car.model].compact, {}, data: { vehicle_picker_target: "model" } %>
426
+ <%= form.hidden_field :vehiclesdb_id, data: { vehicle_picker_target: "id" } %>
427
+ </div>
428
+ ```
429
+
430
+ ```js
431
+ // app/javascript/controllers/vehicle_picker_controller.js
432
+ import { Controller } from "@hotwired/stimulus"
433
+ import VehiclesDropdown from "make-model-dropdown"
434
+
435
+ export default class extends Controller {
436
+ static targets = ["make", "model", "id"]
437
+
438
+ connect() {
439
+ const data = JSON.parse(document.getElementById("vehicles-data").textContent)
440
+ this.dropdown = VehiclesDropdown.attach({
441
+ data, kind: "car", country: "es", locale: "es", values: "name",
442
+ make: this.makeTarget, model: this.modelTarget, idInput: this.idTarget
443
+ })
444
+ }
445
+
446
+ disconnect() { this.dropdown.destroy() }
447
+ }
448
+ ```
449
+
450
+ Give the widget the same `kind`/`country` you sliced by. What the slice
451
+ keeps: each make's `name`, `slug`, `kinds`, `aliases`, `former_slugs`; each
452
+ model's `name`, `slug`, `kind`, `aliases`, `forms`, `former_ids`,
453
+ `global_decile`, `mass_decile`, `availability`, `country_ranks` and
454
+ `registration_years`, plus the release's `version`, `schema_version`,
455
+ `license` and `attribution`. It leaves out `regions` and `body_type`, and with
456
+ `country:` it keeps only that country in `availability` and `country_ranks`
457
+ (every record in the slice is evidenced there; the other countries are most of
458
+ the bytes). The ids are not stored: they are `kind/make-slug/model-slug`, as
459
+ everywhere. The JS and Python SDKs read a slice too (dataset mode, when it is
460
+ what they load as `dist/vehicles.json`).
350
461
 
351
462
  > [!TIP]
352
463
  > Prefer a server round-trip (huge/remote dataset, or you'd rather not embed it)?
@@ -373,9 +484,16 @@ Car.new(make: "Tesler", model: "Model 3").valid? # => false
373
484
 
374
485
  They're forgiving the same way the lookups are (aliases, case, slugs), and they never raise on blank/garbage input — they just add an error.
375
486
 
487
+ Scope them like your picker, so a value the picker never offered (a truck, a model never seen in your market) fails too:
488
+
489
+ ```ruby
490
+ validates :make, vehicle_make: { kind: :car, country: :es }
491
+ validates :model, vehicle_model: { make: :make, kind: :car, country: :es } # kinds: [:car, :van] also works
492
+ ```
493
+
376
494
  ### An "Other / not in the list" escape hatch
377
495
 
378
- Make/model dropdowns should never be a dead end — ~15k models is a lot, but
496
+ Make/model dropdowns should never be a dead end — the full model list is a lot, but
379
497
  someone always shows up with the one car that isn't there. Turn on a first-class
380
498
  "Other" option and the pieces agree by construction: `include_other:` adds it to
381
499
  the picker, `allow_other:` lets it through validation.
@@ -424,17 +542,52 @@ specs/images) are *properties of that make+model* owned by the dataset — look
424
542
  them up via the gem, don't duplicate them per row (they'd just drift). So the
425
543
  schema is small and stable:
426
544
 
427
- | Column | Type | Store | Example |
428
- |----------|-----------|-----------------------------------------|---------------|
429
- | `make` | `string` | display name | `"Volkswagen"`|
430
- | `model` | `string` | display name | `"Golf"` |
431
- | `year` | `integer` | the year | `2022` |
432
- | `color` | `string` | a canonical color **slug** | `"blue"` |
545
+ | Column | Type | Store | Example |
546
+ |-----------------|-----------|-----------------------------------------|-------------------------|
547
+ | `make` | `string` | display name | `"Volkswagen"` |
548
+ | `model` | `string` | display name | `"Golf"` |
549
+ | `vehiclesdb_id` | `string` | the canonical **id** (`Model#id`), nullable | `"car/volkswagen/golf"` |
550
+ | `year` | `integer` | the year | `2022` |
551
+ | `color` | `string` | a canonical color **slug** | `"blue"` |
433
552
 
434
- Everything else (`kind`, `body_type`, production years, images, …) is derived:
435
- `Vehicles.model(record.make, record.model)`. No `kind`/`body_type` columns, no
553
+ Everything else (`kind`, `body_type`, images, …) is derived from the id:
554
+ `Vehicles.by_id(record.vehiclesdb_id)`. No `kind`/`body_type` columns, no
436
555
  migration to add new metadata later — it lands in the dataset, not your schema.
437
556
 
557
+ ### Store the id
558
+
559
+ Names are for people; the **id** (`"car/volkswagen/golf"` — kind/make/model) is
560
+ what every VehiclesDB surface agrees on: the API, the JS/Python SDKs and the
561
+ widget take the same string, and it survives dataset releases. When a release
562
+ renames or merges a record, the new record lists the old id in `former_ids`, so a
563
+ stored id never dead-ends:
564
+
565
+ ```ruby
566
+ car = Vehicles.model(params[:make], params[:model], kind: :car)
567
+ record.vehiclesdb_id = car&.id # nil for "Other" / unknown pairs
568
+
569
+ Vehicles.by_id("car/volkswagen/golf") # => #<Vehicles::Model "Volkswagen Golf">
570
+ Vehicles.by_id(old_id) # => the record that replaced it (follow: true)
571
+ Vehicles.by_id(old_id, follow: false) # => nil
572
+ Vehicles.live?(old_id) # => false
573
+ Vehicles.successor(old_id) # => the id to store now ("car/…/…")
574
+ ```
575
+
576
+ After each data refresh, migrate stored ids in one pass:
577
+
578
+ ```ruby
579
+ Car.where.not(vehiclesdb_id: nil).find_each do |car|
580
+ now = Vehicles.successor(car.vehiclesdb_id)
581
+ next if now.nil? || now == car.vehiclesdb_id
582
+ model = Vehicles.by_id(now)
583
+ car.update!(vehiclesdb_id: now, make: model.make, model: model.name)
584
+ end
585
+ ```
586
+
587
+ Two records are equal when their ids are (`==`, `eql?`, `hash`): the van and the
588
+ bus of one nameplate are different records. A make that is renamed or merged
589
+ is followed too once the dataset publishes its `former_slugs`.
590
+
438
591
  **Store the display name.** The simplest, most readable thing to persist is the
439
592
  name itself (`"Volkswagen"`, `"Golf"`). Populate the selects with the plain
440
593
  **name lists**, where the option value *is* the name:
@@ -471,10 +624,17 @@ car&.suv? # => false
471
624
  ```
472
625
 
473
626
  It reads what people actually type: case, accents and spacing don't matter
474
- (`"XCeed"` is Kia's `"X Ceed"`, `"A 5"` is `"A5"`), BMW's Spanish/Italian
627
+ (`"X Ceed"` is Kia's `"XCeed"`, `"A 5"` is `"A5"`), BMW's Spanish/Italian
475
628
  `"Serie 3"` is the `"3 Series"`, and a name that exists as several kinds
476
- (Toyota's Land Cruiser: car, van, truck, bus) answers with the car unless you
477
- pass `kind:` — `Vehicles.model("Toyota", "Land Cruiser", kind: :bus)`.
629
+ (Iveco's Daily: van, truck, bus) follows the [one kind rule](#matching-stored-make--model-pairs)
630
+ unless you pass `kind:` — a car-only app passes `kind: :car` everywhere.
631
+
632
+ **Year pickers.** `Model#registration_years` is a *hint* — `{ first:, last: }`, the first and last
633
+ year the nameplate shows up in official registrations, when the dataset
634
+ publishes it (nil until then) — good for ordering or pre-filling a year
635
+ `<select>`, never a validation bound (imports and late registrations fall
636
+ outside it legitimately). It is not production years: `Model#years` stays nil
637
+ until VehiclesDB publishes those.
478
638
 
479
639
  **Only accept the kinds you support.** Filter every list/lookup by `kind:` so
480
640
  the picker only ever offers vehicles your app handles — and widening later (when
@@ -486,9 +646,9 @@ Vehicles.makes(kind: :car) # makes that build cars
486
646
  Vehicles.models("Toyota", kind: :car) # ...their cars only
487
647
  ```
488
648
 
489
- No migration, no seed task, no API key — the data is bundled and read from
490
- memory. The whole integration is the markup above plus a 3-line endpoint for the
491
- dependent model list (see [Rails dropdowns](#rails-dropdowns)).
649
+ No seed task, no API key — the data is bundled and read from memory. The
650
+ whole integration is the markup above plus the embedded catalog (see
651
+ [Rails dropdowns](#rails-dropdowns)).
492
652
 
493
653
  ## Country & region awareness
494
654
 
@@ -500,8 +660,8 @@ Vehicles.find("vw golf").available_in?(:nz) # => true
500
660
  Vehicles.top_models(kind: :car, country: :es) # country-scoped popularity
501
661
  ```
502
662
 
503
- The bundled snapshot is **global** (official registers of 15 countries across
504
- Europe, North America, South America, SE Asia, Oceania and Ukraine — see [Where the data
663
+ The bundled snapshot is **global** (official registers of every covered country, across
664
+ Europe, North America, South America, Asia, Oceania and Ukraine — see [Where the data
505
665
  comes from](#where-the-data-comes-from)), so the region gate accepts every
506
666
  region query — `region: :eu` callers from 0.1.x keep working unchanged:
507
667
 
@@ -512,7 +672,7 @@ Vehicles.region # => :global
512
672
 
513
673
  ## License plates
514
674
 
515
- The gem bundles the VehiclesDB **registration-mark dataset** (PRD-PLATES): every plate series per jurisdiction, researched to the issuing authority's own documents — formats, eras, alphabets, designs. Ask it whether a typed registration is real:
675
+ The gem bundles the VehiclesDB **registration-mark dataset**: every plate series per jurisdiction, researched to the issuing authority's own documents — formats, eras, alphabets, designs. Ask it whether a typed registration is real:
516
676
 
517
677
  ```ruby
518
678
  Vehicles.plate("12-GB-BD", jurisdiction: :nl).valid? # => true — exact, as issued
@@ -520,11 +680,42 @@ Vehicles.plate("12gbbd", jurisdiction: :nl).suggestion # => "12-GB-BD" (did yo
520
680
  Vehicles.plate("12-AB-CD", jurisdiction: :nl).strict? # => false — vowels aren't issued
521
681
  ```
522
682
 
523
- Matching is **two-tier**: exact as-issued first, then a separator-forgiving tier — `"1234XYZ"`, `"1234 xyz"` and `"1234-XYZ"` all resolve to the same serial, with `suggestion` giving the formatting the plate actually wears. Leniency forgives punctuation, **never the alphabet**: the strict regexes encode what the authority really issues (the Dutch vowel purge, the Spanish consonant-triplet rule…). `Match#strict?` separates authority-alphabet hits from recall-only catch-alls (export plates accept any current shape, by law).
683
+ Matching is **two-tier**: exact as-issued first, then a separator-forgiving tier — `"1234XYZ"`, `"1234 xyz"` and `"1234-XYZ"` all resolve to the same serial, with `suggestion` giving the formatting the plate actually wears. Leniency forgives punctuation, **never the alphabet**: the strict regexes encode what the authority really issues (the Dutch vowel purge, the Spanish consonant-triplet rule…). `Match#strict?` separates validity claims from shape hits: it is true when a hit comes from a series the dataset marks `matching: strict` (the plates spec §2.7), and false for recall-only catch-alls (export plates accept any current shape, by law; Spain's 1900–1971 numeric era is one wide union). Validate user input with `strict?`.
684
+
685
+ When a plate has a series' shape but breaks its alphabet, `near_misses` names that series, so a form can say why:
686
+
687
+ ```ruby
688
+ m = Vehicles.plate("1234 ABC", jurisdiction: :es)
689
+ m.valid? # => false
690
+ m.near_misses.map(&:id) # => ["es-national-2000"]
691
+ m.jurisdiction.charset["excluded_letters"] # => ["A", "E", "I", "O", "U", "Ñ", "Q"]
692
+ ```
693
+
694
+ One reason code per answer, for the error message (the hosted API answers the same codes):
695
+
696
+ ```ruby
697
+ Vehicles.plate("1234 BCD", jurisdiction: :es).reason # => :ok
698
+ Vehicles.plate("1234 ABC", jurisdiction: :es).reason # => :excluded_letters
699
+ Vehicles.plate("1234 ABC", jurisdiction: :es).excluded_letters # => ["A"]
700
+ Vehicles.plate("12345", jurisdiction: :es).reason # => :bad_shape
701
+ # also :unverifiable (only a recall-only series has the shape), :empty, :unknown_jurisdiction
702
+ ```
703
+
704
+ Moving people (ride sharing, rentals)? `passenger?` says whether a strictly valid
705
+ plate belongs to a series issued to passenger-carrying vehicles — a trailer
706
+ (`R-1234-BCD`) or tractor plate (`E-1234-BCD`) is valid but not that:
707
+
708
+ ```ruby
709
+ Vehicles.plate("1234 BCD", jurisdiction: :es).passenger? # => true
710
+ Vehicles.plate("R-1234-BCD", jurisdiction: :es).passenger? # => false (reason is still :ok)
711
+ Vehicles.plates(:es).series.reject(&:passenger?).map(&:klass) # => ["agricultural", "trailer"]
712
+ ```
713
+
714
+ A plate is personal data: the matcher is local, and nothing here sends it anywhere.
524
715
 
525
716
  ```ruby
526
717
  Vehicles.plates # => every jurisdiction
527
- Vehicles.plates(:es).series # => 18 series: formats, periods, sourced designs
718
+ Vehicles.plates(:es).series # => 19 series: formats, periods, sourced designs
528
719
  ```
529
720
 
530
721
  Pilot coverage (gate L0): 🇳🇱 🇪🇸 🇩🇪 + US-Florida, 73 series, corpus-proven in NL. Same posture as everything else here: bundled, offline, forgiving in, honest out.
@@ -565,21 +756,51 @@ end
565
756
 
566
757
  car = Vehicles.find("vw golf")
567
758
 
568
- # Rendered vehicle imagery — LIVE today. Mint a key at
759
+ # Studio vehicle imagery — LIVE today. Mint a key at
569
760
  # https://vehiclesdb.com/settings/api-keys
570
761
  car.image # => "https://vehiclesdb.com/…/md.webp" (640×360)
571
762
  car.image(size: :lg) # :sm 320×180 · :md 640×360 · :lg 1280×720
572
- car.image(color: "red") # color-accurate; un-rendered colors fall back honestly
763
+ car.image(color: "red") # color-accurate; unavailable colors fall back honestly
573
764
  car.images(color: "red") # the full payload: every variant + dimensions,
574
- # rendered palette, served-vs-requested color
765
+ # available palette, served-vs-requested color
575
766
  # color slugs are the gem's own canon — Vehicles.colors — so a stored
576
- # dropdown value maps 1:1 to the rendered imagery.
767
+ # dropdown value maps 1:1 to the studio imagery.
768
+
769
+ # Enrichment — the wired-up seam. nil today: the API does not serve these yet.
770
+ car.years # production years (nil until published)
771
+ car.segment # editorial segment (nil until published)
772
+ ```
773
+
774
+ **Images in a Rails app.** Every served image is billed, so the gem caches
775
+ whole payloads (in-process by default; share them with `Rails.cache`) and never
776
+ raises. `image` answers nil on any failure; when you need to know **why**, ask
777
+ `images_result`:
778
+
779
+ ```ruby
780
+ Vehicles.configure do |config|
781
+ config.image_cache = Rails.cache # a hit is kept a day (capped by the payload's expiry), a 404 ten minutes
782
+ config.on_hosted_error = ->(result) { Rails.error.report(result.to_error, handled: true) }
783
+ end
577
784
 
578
- # Enrichment — the wired-up seam; ships next. nil until then, same objects.
579
- car.years # => 1974..2024 production years
580
- car.segment # => :hatchback / :hot_hatch editorial segment
785
+ car = Vehicles.by_id(record.vehiclesdb_id)
786
+ car.image(color: record.color, exact_color: true) # nil instead of another color's image
787
+ r = car.images_result(color: "red")
788
+ r.ok? # => false
789
+ r.reason # => :payment_required (:no_api_key :not_found :unauthorized :rate_limited
790
+ # :invalid_request :server_error :timeout :network :color_not_available …)
791
+ r.error # => "insufficient_credits" (the API's own code, verbatim)
581
792
  ```
582
793
 
794
+ - `on_hosted_error` (and a warn log line) fires for a bad key, an empty wallet,
795
+ rate limiting, server errors and timeouts — never for a plain "no image yet".
796
+ - A record a dataset release renamed answers with a 301 to its successor; the gem
797
+ follows it (same host only, so your key never leaves it).
798
+ - `year:` is sent to the API, which answers `422 unsupported_filter` until
799
+ year-accurate images ship (`image` → nil, `reason: :invalid_request`); until
800
+ 0.9.1 the gem silently dropped it.
801
+ - With `exact_color: true` a model's known palette is remembered, so a color it
802
+ is not available in costs no request at all.
803
+
583
804
  Under the hood this is a simple **provider** model: a `LocalProvider` (the bundled data) is always available, and a `HostedProvider` activates only when an API key is configured. Calls prefer the hosted data when it's there and **fall back to the local data otherwise — never raising, never blocking** your request:
584
805
 
585
806
  ```ruby
@@ -599,18 +820,19 @@ Vehicles.configure do |config|
599
820
  config.region = :eu # default region for queries
600
821
  config.api_key = ENV["VEHICLESDB_API_KEY"] # optional hosted VehiclesDB data
601
822
  config.aliases = { "Chevy" => "Chevrolet" } # add your own make aliases
602
- config.use_cache = true # prefer a refreshed dataset over the bundled one
823
+ config.use_cache = true # serve a refreshed dataset when it is usable
824
+ config.cache_dir = Rails.root.join("storage", "vehicles") # a shared volume for multi-container deploys
825
+ config.image_cache = Rails.cache # hosted image payloads, shared across processes
603
826
  end
604
827
  ```
605
828
 
606
829
  ## Staying current (optional)
607
830
 
608
831
  The bundled snapshot works offline forever — but vehicle data changes (new
609
- makes, fixes), and you shouldn't have to ship a gem upgrade to every app just to
610
- get them. So `vehicles` can **refresh** from the published
611
- [dataset](https://github.com/vehiclesdb/vehiclesdb) into a local file cache;
612
- loads prefer the cache over the bundled copy. Data fixes reach your app **without
613
- a gem bump.**
832
+ makes, fixes, renamed records), and you shouldn't have to ship a gem upgrade to
833
+ every app just to get them. So `vehicles` can **refresh** from the published
834
+ [dataset](https://github.com/vehiclesdb/vehiclesdb) into a file cache. Data
835
+ fixes reach your app **without a gem bump.**
614
836
 
615
837
  `rails g vehicles:install` drops a `VehiclesRefreshJob` — schedule it (daily is
616
838
  plenty), e.g. with solid_queue:
@@ -622,16 +844,58 @@ vehicles_refresh:
622
844
  schedule: every day at 3am
623
845
  ```
624
846
 
625
- Or refresh manually:
847
+ Or refresh by hand:
848
+
849
+ ```ruby
850
+ result = Vehicles.refresh # never raises
851
+ result.status # => :updated | :current | :rejected | :failed
852
+ result.message # => "dataset 2026.11.2 → 2026.12.0"
853
+ Vehicles.refresh! # the same, as true/false (true when updated or already current)
854
+ Vehicles.data_version # => the version now in effect (cached, or bundled)
855
+ ```
856
+
857
+ **Safe by construction.** A release is written only when all of these hold;
858
+ otherwise your current data stays untouched and `result.reason` says why:
859
+
860
+ 1. The release **manifest** (a few KB, read first) announces a dataset schema
861
+ this gem reads and a version **newer** than the newest data you already have.
862
+ Same version → nothing is downloaded. Older → refused: never a downgrade.
863
+ 2. The payload is fetched **from that exact release**, parses, carries that
864
+ version and schema, and matches the manifest's per-kind model counts (and
865
+ its checksum, when the manifest publishes one).
866
+ 3. No kind, and no kind-in-a-country (e.g. Spain's cars), shrinks below
867
+ `config.refresh_min_ratio` (0.9) of the data in use — a broken release can
868
+ never empty your pickers.
869
+ 4. Your own `config.refresh_guard`, if set, accepts it:
870
+ `->(new_data, current) { … }` with two `Vehicles::Dataset`s.
871
+
872
+ It is written atomically (temp file + rename). And the file a process serves is
873
+ chosen just as carefully: a refreshed cache is used only while it is **not older
874
+ than the gem's bundled data** and in a schema the gem reads — upgrading the gem
875
+ always wins over an older download.
876
+
877
+ **Every process picks it up on its own.** Each process re-checks the file at
878
+ most every `config.reload_check_interval` seconds (default 5; one `File.stat`)
879
+ and switches to a new one — no restart, no hook. A file that is corrupt or
880
+ half-written never replaces the dataset in memory.
881
+
882
+ **Deploying web and jobs as separate containers** (Kamal, Docker Compose,
883
+ Kubernetes)? Give them one shared volume and point the gem at it, so the job's
884
+ refresh reaches the web containers:
626
885
 
627
886
  ```ruby
628
- Vehicles.refresh! # pull latest -> cache; true/false, never raises
629
- Vehicles.data_version # => the version now in effect (cached, or bundled)
887
+ # config/initializers/vehicles.rb — or set VEHICLES_CACHE_DIR in both containers
888
+ Vehicles.configure { |c| c.cache_dir = Rails.root.join("storage", "vehicles") }
630
889
  ```
631
890
 
632
- It's safe by design: a failed/partial download never replaces good data, and the
633
- gem keeps serving the cache (or the bundled snapshot) no matter what. Want it
634
- fully offline/deterministic? `config.use_cache = false`.
891
+ ```yaml
892
+ # config/deploy.yml (Kamal): web and job already share the storage volume
893
+ volumes:
894
+ - "myapp_storage:/rails/storage"
895
+ ```
896
+
897
+ Run the refresh in ONE place (the job container); every container serves the
898
+ result. Want it fully offline/deterministic instead? `config.use_cache = false`.
635
899
 
636
900
  ## The full Ruby API
637
901
 
@@ -639,8 +903,10 @@ fully offline/deterministic? `config.use_cache = false`.
639
903
  # Lists (strings — drop straight into a form)
640
904
  Vehicles.makes # => [String]
641
905
  Vehicles.makes(kind: :car, region: :eu)
906
+ Vehicles.makes(kind: :car, country: :es) # only makes evidenced in that country
642
907
  Vehicles.models("Audi") # => [String]
643
908
  Vehicles.models("Audi", body_type: :suv)
909
+ Vehicles.models("SEAT", kind: :car, country: :es) # in Spain's popularity order
644
910
 
645
911
  # Option pairs (for Rails `select`)
646
912
  Vehicles.make_options # => [[label, value]]
@@ -648,14 +914,24 @@ Vehicles.model_options("Audi") # => [[label, value]]
648
914
 
649
915
  # Whole make => [models] map (embed once for a client-side dependent picker)
650
916
  Vehicles.catalog(kind: :car) # => { "Audi" => ["A3", ...], ... }
917
+ Vehicles.catalog(kind: :car, country: :es) # one country's picker list
918
+ Vehicles.dataset_slice(kind: :car, country: :es) # the same scope as dist/vehicles.json records (widget `data`)
651
919
 
652
920
  # Objects
653
921
  Vehicles.make("Audi") # => Vehicles::Make | nil
654
922
  Vehicles.find("audi a3") # => Vehicles::Model | nil (one free-text string)
655
923
  Vehicles.model("Audi", "A3") # => Vehicles::Model | nil (a stored make+model pair)
656
- Vehicles.model("Toyota", "Land Cruiser", kind: :bus) # pick a kind (default: the car)
924
+ Vehicles.model("Iveco", "Daily", kind: :bus) # pick a kind (default: the one kind rule)
925
+ Vehicles.model("Iveco", "Daily", kinds: [:car, :van]) # allow only some kinds
657
926
  Vehicles.match_model("VW", "Tuareg") # => Vehicles::ModelMatch | nil (model, rung, confidence, via, candidates)
927
+ Vehicles.match_model("VW", "Golf").confident? # => true: safe to auto-apply
658
928
  Vehicles.identify("vw tuareg") # => Vehicles::ModelMatch | nil (one free-text string)
929
+
930
+ # Canonical ids
931
+ Vehicles.by_id("car/volkswagen/golf") # => Vehicles::Model | nil (follows former ids)
932
+ Vehicles.by_id(id, follow: false) # exact only
933
+ Vehicles.successor(id) # => the id to store now | nil
934
+ Vehicles.live?(id) # => true when the id names a current record
659
935
  Vehicles.search("a3") # => [Vehicles::Model]
660
936
 
661
937
  # Popularity & availability (open data, measured from official registers)
@@ -672,26 +948,35 @@ model.aliases model.forms # published alternate names; cura
672
948
  model.kind model.body_type model.suv? model.coupe? # …predicates
673
949
  model.global_decile model.popular? # 1 = top 10% worldwide; nil-safe
674
950
  model.availability model.available_in?(:nl) model.two_wheeler?
675
- model.years model.segment model.image(year:, color:) # ← hosted VehiclesDB API
951
+ model.id model.former_ids # "car/volkswagen/golf"; ids this record replaced
952
+ model.registration_years # { first: 1998, last: 2026 } hint, or nil (never a validation bound)
953
+ model.years model.segment # production years / segment: nil until published
954
+ model.image(color:, size:, exact_color:) model.images(color:) model.images_result(color:) # ← hosted API
676
955
 
677
956
  # Colors (canonical palette)
678
957
  Vehicles.colors # => [Vehicles::Color]
679
958
  Vehicles.color("navy") # => Vehicles::Color | nil (forgiving)
680
- Vehicles.color_options # => [[name, slug]] (for select)
681
- color.slug color.name color.hex
959
+ Vehicles.color_options(locale: :es) # => [[label, slug]] (for select; :en default)
960
+ color.slug color.name color.hex color.label(:es)
961
+ Vehicles.kind_options(locale: :es) Vehicles.kind_label(:van, locale: :es)
962
+ Vehicles.body_type_options(locale: :es) Vehicles.body_type_label(:wagon, locale: :es)
963
+
964
+ # Plates
965
+ Vehicles.plate("1234 BCD", jurisdiction: :es) # => Plates::Match: valid? strict? reason passenger? suggestion
682
966
 
683
967
  # Meta
684
968
  Vehicles.data_version # => "2026.07.0" (version in effect: cached or bundled)
685
969
  Vehicles.region # => :global
686
970
 
687
971
  # Refresh (optional — keep data current without a gem upgrade)
688
- Vehicles.refresh! # pull latest published data -> cache; true/false, never raises
689
- Vehicles.reload! # drop the in-memory dataset (reload from disk)
972
+ Vehicles.refresh # => Vehicles::RefreshResult (status, reason, message); never raises
973
+ Vehicles.refresh! # the same as true/false
974
+ Vehicles.reload! # drop the in-memory dataset now (processes also re-check on their own)
690
975
  ```
691
976
 
692
977
  ## Where the data comes from
693
978
 
694
- The bundled dataset is [**VehiclesDB**](https://github.com/vehiclesdb/vehiclesdb) — an open dataset **reconciled from official vehicle registers of 15 countries on 5 continents** (the Dutch RDW, UK DfT, Spanish DGT, Finnish Traficom, German KBA, US EPA, New Zealand's Motor Vehicle Register, Thailand's DLT, Ukraine's MVS, and more — the exact list for the bundled 2026.10.0 snapshot is `countries` in the data repo's `manifest.json`). A model ships only when **two independent official sources corroborate it** — or one shows a registration count no typo could produce — so you get real nameplates without the registry noise.
979
+ The bundled dataset is [**VehiclesDB**](https://github.com/vehiclesdb/vehiclesdb) — an open dataset **reconciled from the official vehicle registers of every covered country, on five continents** (the Dutch RDW, UK DfT, Spanish DGT, Finnish Traficom, German KBA, US EPA, New Zealand's Motor Vehicle Register, Thailand's DLT, Ukraine's MVS, and more — the exact list for the bundled 2026.11.6 snapshot is `countries` in the data repo's `manifest.json`). A model ships only when **two independent official sources corroborate it** — or one shows a registration count no typo could produce — so you get real nameplates without the registry noise.
695
980
 
696
981
  Every record is shaped like this:
697
982
 
@@ -725,15 +1010,14 @@ No magic, just good defaults:
725
1010
 
726
1011
  ## Roadmap
727
1012
 
728
- Bundled today: ~15k models across 6 kinds and 15 countries, with popularity +
729
- availability, plus the MCP server. On the way:
1013
+ Bundled today: the full VehiclesDB catalog across all 6 kinds, with popularity +
1014
+ availability, plus the MCP server (the live counts are in the data repo's
1015
+ `manifest.json`; this README deliberately carries none). On the way:
730
1016
 
731
- - 🌍 More **countries** (Switzerland next; the dataset grows monthly)
1017
+ - 🌍 More **countries** (the dataset grows monthly)
732
1018
  - 📅 Production **years** in the local data
733
1019
  - 🧬 **Generations & variants** layers (the schema already reserves them)
734
- - 🖼️ Model **images** (via VehiclesDB)
735
1020
  - 🏷️ **Segments** (supercar, city car, hot hatch) and richer metadata
736
- - 🔎 A mountable **autocomplete endpoint** so the dependent-dropdown recipe becomes one line
737
1021
 
738
1022
  Want one of these sooner? Open an issue.
739
1023