vehicles 0.4.1 → 0.8.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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +164 -0
- data/CHANGELOG.md +321 -0
- data/README.md +88 -15
- data/data/PROVENANCE-vehicles.md +12 -0
- data/data/plates/PROVENANCE.md +10 -0
- data/data/plates/_decode/at-districts.yml +287 -0
- data/data/plates/_decode/ch-cantons.yml +110 -0
- data/data/plates/_decode/cn-issuing-offices.yml +463 -0
- data/data/plates/_decode/cn-provinces.yml +614 -0
- data/data/plates/_decode/cz-kraj.yml +159 -0
- data/data/plates/_decode/dk-serial-blocks.yml +327 -0
- data/data/plates/_decode/es-provinces.yml +116 -0
- data/data/plates/_decode/fr-departements.yml +191 -0
- data/data/plates/_decode/gb-age-identifiers.yml +170 -0
- data/data/plates/_decode/gb-memory-tags.yml +202 -0
- data/data/plates/_decode/gr-prefectures.yml +1408 -0
- data/data/plates/_decode/hr-cities.yml +315 -0
- data/data/plates/_decode/ie-counties.yml +227 -0
- data/data/plates/_decode/in-state-codes.yml +169 -0
- data/data/plates/_decode/is-districts-1938.yml +116 -0
- data/data/plates/_decode/it-provinces.yml +348 -0
- data/data/plates/_decode/jp-office-marks.yml +960 -0
- data/data/plates/_decode/jp-use-marks.yml +205 -0
- data/data/plates/_decode/kr-authority-marks.yml +146 -0
- data/data/plates/_decode/kr-use-syllables.yml +194 -0
- data/data/plates/_decode/lu-series-order.yml +638 -0
- data/data/plates/_decode/mx-entity-series.yml +380 -0
- data/data/plates/_decode/my-index-marks.yml +271 -0
- data/data/plates/_decode/no-counties-1913.yml +148 -0
- data/data/plates/_decode/no-districts.yml +901 -0
- data/data/plates/_decode/pl-powiats.yml +567 -0
- data/data/plates/_decode/pt-trailer-services.yml +96 -0
- data/data/plates/_decode/ro-counties.yml +213 -0
- data/data/plates/_decode/se-diplomatic.yml +220 -0
- data/data/plates/_decode/si-regions.yml +123 -0
- data/data/plates/_decode/sk-districts.yml +314 -0
- data/data/plates/_decode/th-registration-elements.yml +208 -0
- data/data/plates/_decode/ua-regions.yml +393 -0
- data/data/plates/_decode/za-licence-marks.yml +215 -0
- data/data/plates/_meta/classes.yml +26 -0
- data/data/plates/_meta/separators.yml +102 -0
- data/data/plates/ad.yml +961 -0
- data/data/plates/ar.yml +762 -0
- data/data/plates/at.yml +1424 -0
- data/data/plates/au-act.yml +472 -0
- data/data/plates/au-nsw.yml +282 -0
- data/data/plates/au-nt.yml +431 -0
- data/data/plates/au-qld.yml +272 -0
- data/data/plates/au-sa.yml +248 -0
- data/data/plates/au-tas.yml +302 -0
- data/data/plates/au-vic.yml +215 -0
- data/data/plates/au-wa.yml +309 -0
- data/data/plates/be.yml +1840 -0
- data/data/plates/br.yml +1152 -0
- data/data/plates/ca-ab.yml +430 -0
- data/data/plates/ca-bc.yml +697 -0
- data/data/plates/ca-mb.yml +499 -0
- data/data/plates/ca-nb.yml +537 -0
- data/data/plates/ca-nl.yml +743 -0
- data/data/plates/ca-ns.yml +808 -0
- data/data/plates/ca-nt.yml +610 -0
- data/data/plates/ca-nu.yml +280 -0
- data/data/plates/ca-on.yml +881 -0
- data/data/plates/ca-pe.yml +592 -0
- data/data/plates/ca-qc.yml +1036 -0
- data/data/plates/ca-sk.yml +256 -0
- data/data/plates/ca-yt.yml +656 -0
- data/data/plates/ch.yml +1273 -0
- data/data/plates/cn.yml +1431 -0
- data/data/plates/cy.yml +688 -0
- data/data/plates/cz.yml +1179 -0
- data/data/plates/de.yml +1107 -0
- data/data/plates/dk.yml +1647 -0
- data/data/plates/ee.yml +1275 -0
- data/data/plates/es.yml +888 -0
- data/data/plates/fi.yml +1271 -0
- data/data/plates/fr.yml +1265 -0
- data/data/plates/gb.yml +1188 -0
- data/data/plates/gr.yml +721 -0
- data/data/plates/hr.yml +860 -0
- data/data/plates/hu.yml +959 -0
- data/data/plates/ie.yml +775 -0
- data/data/plates/in.yml +1172 -0
- data/data/plates/is.yml +667 -0
- data/data/plates/it.yml +1317 -0
- data/data/plates/jp.yml +3650 -0
- data/data/plates/kr.yml +1375 -0
- data/data/plates/li.yml +1010 -0
- data/data/plates/lt.yml +879 -0
- data/data/plates/lu.yml +842 -0
- data/data/plates/lv.yml +924 -0
- data/data/plates/mc.yml +1024 -0
- data/data/plates/mt.yml +854 -0
- data/data/plates/mx.yml +1372 -0
- data/data/plates/my.yml +685 -0
- data/data/plates/nl.yml +1016 -0
- data/data/plates/no.yml +1018 -0
- data/data/plates/nz.yml +1699 -0
- data/data/plates/pl.yml +1296 -0
- data/data/plates/pt.yml +847 -0
- data/data/plates/ro.yml +785 -0
- data/data/plates/se.yml +1091 -0
- data/data/plates/si.yml +1105 -0
- data/data/plates/sk.yml +1439 -0
- data/data/plates/sm.yml +1119 -0
- data/data/plates/th.yml +1150 -0
- data/data/plates/ua.yml +1672 -0
- data/data/plates/us-ak.yml +763 -0
- data/data/plates/us-al.yml +457 -0
- data/data/plates/us-ar.yml +427 -0
- data/data/plates/us-as.yml +454 -0
- data/data/plates/us-az.yml +430 -0
- data/data/plates/us-ca.yml +892 -0
- data/data/plates/us-co.yml +452 -0
- data/data/plates/us-ct.yml +427 -0
- data/data/plates/us-dc.yml +614 -0
- data/data/plates/us-de.yml +551 -0
- data/data/plates/us-fl.yml +741 -0
- data/data/plates/us-ga.yml +644 -0
- data/data/plates/us-gu.yml +658 -0
- data/data/plates/us-hi.yml +585 -0
- data/data/plates/us-ia.yml +438 -0
- data/data/plates/us-id.yml +531 -0
- data/data/plates/us-il.yml +516 -0
- data/data/plates/us-in.yml +324 -0
- data/data/plates/us-ks.yml +351 -0
- data/data/plates/us-ky.yml +770 -0
- data/data/plates/us-la.yml +568 -0
- data/data/plates/us-ma.yml +379 -0
- data/data/plates/us-md.yml +536 -0
- data/data/plates/us-me.yml +445 -0
- data/data/plates/us-mi.yml +408 -0
- data/data/plates/us-mn.yml +411 -0
- data/data/plates/us-mo.yml +356 -0
- data/data/plates/us-mp.yml +402 -0
- data/data/plates/us-ms.yml +432 -0
- data/data/plates/us-mt.yml +619 -0
- data/data/plates/us-nc.yml +602 -0
- data/data/plates/us-nd.yml +419 -0
- data/data/plates/us-ne.yml +483 -0
- data/data/plates/us-nh.yml +394 -0
- data/data/plates/us-nj.yml +547 -0
- data/data/plates/us-nm.yml +574 -0
- data/data/plates/us-nv.yml +655 -0
- data/data/plates/us-ny.yml +592 -0
- data/data/plates/us-oh.yml +404 -0
- data/data/plates/us-ok.yml +513 -0
- data/data/plates/us-or.yml +392 -0
- data/data/plates/us-pa.yml +506 -0
- data/data/plates/us-pr.yml +507 -0
- data/data/plates/us-ri.yml +445 -0
- data/data/plates/us-sc.yml +605 -0
- data/data/plates/us-sd.yml +428 -0
- data/data/plates/us-tn.yml +471 -0
- data/data/plates/us-tx.yml +535 -0
- data/data/plates/us-ut.yml +384 -0
- data/data/plates/us-va.yml +567 -0
- data/data/plates/us-vi.yml +489 -0
- data/data/plates/us-vt.yml +458 -0
- data/data/plates/us-wa.yml +484 -0
- data/data/plates/us-wi.yml +509 -0
- data/data/plates/us-wv.yml +446 -0
- data/data/plates/us-wy.yml +523 -0
- data/data/plates/va.yml +604 -0
- data/data/plates/za.yml +1141 -0
- data/data/vehicles.json +160653 -105090
- data/lib/vehicles/configuration.rb +18 -2
- data/lib/vehicles/dataset.rb +73 -18
- data/lib/vehicles/fuzzy.rb +115 -0
- data/lib/vehicles/http.rb +48 -0
- data/lib/vehicles/make.rb +36 -6
- data/lib/vehicles/mcp_server.rb +5 -5
- data/lib/vehicles/model.rb +42 -6
- data/lib/vehicles/plates/jurisdiction.rb +32 -0
- data/lib/vehicles/plates/series.rb +291 -0
- data/lib/vehicles/plates/template_fill.rb +188 -0
- data/lib/vehicles/plates/templates.rb +162 -0
- data/lib/vehicles/plates.rb +122 -0
- data/lib/vehicles/providers/hosted_provider.rb +49 -58
- data/lib/vehicles/providers/local_provider.rb +4 -3
- data/lib/vehicles/validators/vehicle_make_validator.rb +3 -1
- data/lib/vehicles/validators/vehicle_model_validator.rb +1 -1
- data/lib/vehicles/version.rb +1 -1
- data/lib/vehicles.rb +53 -16
- data/rakelib/data_refresh.rake +109 -0
- data/rakelib/plates_refresh.rake +29 -0
- data/sig/vehicles.rbs +18 -0
- metadata +174 -2
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Vehicles
|
|
6
|
+
# License-plate & registration-mark knowledge (the VehiclesDB PRD-PLATES
|
|
7
|
+
# dataset, bundled offline like everything else in this gem). Ask it two
|
|
8
|
+
# kinds of question:
|
|
9
|
+
#
|
|
10
|
+
# Vehicles.plates # => every Jurisdiction
|
|
11
|
+
# Vehicles.plates(:nl) # => one Jurisdiction (series, charset, authority)
|
|
12
|
+
# Vehicles.plate("12-GB-BD", jurisdiction: :nl) # => Plates::Match
|
|
13
|
+
#
|
|
14
|
+
# Matching is TWO-TIER, because humans type plates every way at once:
|
|
15
|
+
#
|
|
16
|
+
# exact — the input, upcased, matches a series' STRICT regex as-typed
|
|
17
|
+
# (separators included): formatted exactly as issued.
|
|
18
|
+
# lenient — the input with separators stripped ("1234XYZ", "1234 xyz",
|
|
19
|
+
# "1234-XYZ" all collapse to "1234XYZ") matches the same strict
|
|
20
|
+
# alphabet with the separators removed. Still the authority's
|
|
21
|
+
# real issuing alphabet — only the punctuation is forgiven.
|
|
22
|
+
#
|
|
23
|
+
# A lenient-only match carries `suggestion`: the serial re-formatted the
|
|
24
|
+
# way it appears on the physical plate ("did you mean 1234 XYZ").
|
|
25
|
+
# Same posture as every lookup in this gem: garbage in => empty result,
|
|
26
|
+
# never an exception.
|
|
27
|
+
module Plates
|
|
28
|
+
DATA_DIR = File.expand_path("../../data/plates", __dir__)
|
|
29
|
+
|
|
30
|
+
# What people type between plate groups: space(s), hyphen/en/em dash,
|
|
31
|
+
# middot, period. Stripped for the lenient tier (and from the strict
|
|
32
|
+
# regexes to derive their lenient twins).
|
|
33
|
+
SEPARATORS = /[\s\-–—·.]+/
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
def jurisdictions
|
|
38
|
+
@jurisdictions ||= Dir.glob(File.join(DATA_DIR, "*.yml")).sort.map do |path|
|
|
39
|
+
Jurisdiction.load(path)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def jurisdiction(code)
|
|
44
|
+
jurisdictions.find { |j| j.code == code.to_s.downcase.tr("_", "-") }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def classes
|
|
48
|
+
@classes ||= YAML.safe_load_file(File.join(DATA_DIR, "_meta", "classes.yml")) || {}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A _decode table by bare name ("at-districts", "jp-office-marks") —
|
|
52
|
+
# the region/office/use-mark lookups that make plates decodable and
|
|
53
|
+
# let renderers resolve subregion emblems from a serial. Nil for
|
|
54
|
+
# unknown names; memoized like everything else here.
|
|
55
|
+
def decode_table(name)
|
|
56
|
+
@decode_tables ||= {}
|
|
57
|
+
@decode_tables[name] ||= begin
|
|
58
|
+
path = File.join(DATA_DIR, "_decode", "#{name.to_s.tr('_', '-')}.yml")
|
|
59
|
+
File.exist?(path) ? YAML.safe_load_file(path) : nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The matcher behind Vehicles.plate. Unknown jurisdiction => empty Match.
|
|
64
|
+
def match(input, jurisdiction:)
|
|
65
|
+
juris = jurisdiction(jurisdiction)
|
|
66
|
+
typed = input.to_s.upcase.strip
|
|
67
|
+
canon = typed.gsub(SEPARATORS, "")
|
|
68
|
+
return Match.new(input: input.to_s, jurisdiction: juris, canon: canon, hits: []) if juris.nil? || canon.empty?
|
|
69
|
+
|
|
70
|
+
hits = juris.series.filter_map do |series|
|
|
71
|
+
next nil unless series.validation_regexp
|
|
72
|
+
|
|
73
|
+
exact = series.validation_regexp.match?(typed)
|
|
74
|
+
lenient = exact || series.lenient_regexp&.match?(canon)
|
|
75
|
+
Match::Hit.new(series: series, exact: exact) if lenient
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
Match.new(input: input.to_s, jurisdiction: juris, canon: canon, hits: hits)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The answer to "is this a plate, and which series issues it?".
|
|
82
|
+
class Match
|
|
83
|
+
Hit = Struct.new(:series, :exact, keyword_init: true) do
|
|
84
|
+
def exact? = exact
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
attr_reader :input, :jurisdiction, :canon, :hits
|
|
88
|
+
|
|
89
|
+
def initialize(input:, jurisdiction:, canon:, hits:)
|
|
90
|
+
@input = input
|
|
91
|
+
@jurisdiction = jurisdiction
|
|
92
|
+
@canon = canon
|
|
93
|
+
# Exact hits outrank lenient ones, strict-validated series outrank
|
|
94
|
+
# recall-only catch-alls; series order (standard first) breaks ties.
|
|
95
|
+
@hits = hits.sort_by { |h| [ h.exact? ? 0 : 1, h.series.strict? ? 0 : 1 ] }.freeze
|
|
96
|
+
freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def valid? = hits.any?
|
|
100
|
+
def exact? = hits.any?(&:exact?)
|
|
101
|
+
|
|
102
|
+
# True when at least one hit is a strict-validated series — the strong
|
|
103
|
+
# signal ("the authority issues this alphabet"), vs a catch-all shape
|
|
104
|
+
# union like an export plate. Prefer this for user-input validation.
|
|
105
|
+
def strict? = hits.any? { |h| h.series.strict? }
|
|
106
|
+
|
|
107
|
+
def series = hits.map(&:series)
|
|
108
|
+
def best = hits.first&.series
|
|
109
|
+
|
|
110
|
+
# The serial formatted the way the best-matching series prints it on
|
|
111
|
+
# the physical plate — "1234XYZ" typed, "1234 XYZ" issued.
|
|
112
|
+
def formatted
|
|
113
|
+
best&.format_serial(canon)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Only when the user's punctuation was off: the "did you mean" string.
|
|
117
|
+
def suggestion
|
|
118
|
+
formatted if valid? && !exact?
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -4,83 +4,74 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module Vehicles
|
|
6
6
|
module Providers
|
|
7
|
-
# Talks to the hosted VehiclesDB API
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
7
|
+
# Talks to the hosted VehiclesDB API for vehicle imagery. It is STRICTLY
|
|
8
|
+
# OPTIONAL: `available?` is true only when an api_key is configured, so a
|
|
9
|
+
# key-less install never reaches the network. Every call is error-isolated
|
|
10
|
+
# — a slow, missing, or failing API yields nil and the resolver falls back
|
|
11
|
+
# to the local data. Enrichment must never break the host app, so this
|
|
12
|
+
# never raises out.
|
|
13
13
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
14
|
+
# The images endpoint is LIVE (since 2026-07): mint a key at
|
|
15
|
+
# https://vehiclesdb.com/settings/api-keys, set `config.api_key`, and
|
|
16
|
+
# `model.image` / `model.images` answer with rendered vehicle imagery.
|
|
17
|
+
#
|
|
18
|
+
# `years` and `segment` are the wired-up seam for enrichment the API does
|
|
19
|
+
# not serve yet: they answer nil WITHOUT a request (a call to an endpoint
|
|
20
|
+
# that doesn't exist would only cost latency), so the chain falls through
|
|
21
|
+
# to the local data. They light up here when the API ships them.
|
|
18
22
|
module HostedProvider
|
|
23
|
+
# Every VehiclesDB API endpoint lives under this path on
|
|
24
|
+
# `config.api_base_url` (https://vehiclesdb.com/api/v1/...).
|
|
25
|
+
API_PREFIX = "/api/v1"
|
|
26
|
+
|
|
19
27
|
module_function
|
|
20
28
|
|
|
21
29
|
def available?
|
|
22
30
|
!Vehicles.configuration.api_key.to_s.empty?
|
|
23
31
|
end
|
|
24
32
|
|
|
25
|
-
def years(
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
def years(_model)
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
28
36
|
|
|
29
|
-
|
|
37
|
+
def segment(_model)
|
|
38
|
+
nil
|
|
30
39
|
end
|
|
31
40
|
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
# One variant URL — the common "just give me an <img src>" case.
|
|
42
|
+
# `size` picks from the API's rendered variants (:sm 320×180, :md
|
|
43
|
+
# 640×360, :lg 1280×720; webp).
|
|
44
|
+
def image(model, year: nil, color: nil, size: :md)
|
|
45
|
+
images(model, color: color)&.dig("variants", size.to_s, "url")
|
|
34
46
|
end
|
|
35
47
|
|
|
36
|
-
|
|
48
|
+
# The full images payload for a model — palette, every variant with
|
|
49
|
+
# dimensions, provenance, and the honest color fallback: `color` is what
|
|
50
|
+
# the API actually served, `requested_color` what you asked for (a color
|
|
51
|
+
# that isn't rendered yet falls back rather than 404ing).
|
|
52
|
+
#
|
|
53
|
+
# GET /api/v1/vehicles/:kind/:make_slug/:model_slug/images?color=<slug>
|
|
54
|
+
#
|
|
55
|
+
# `year`/`trim` filters are RESERVED server-side today (the API 422s on
|
|
56
|
+
# them by contract, so callers can't silently build on unimplemented
|
|
57
|
+
# semantics) — which is why `image` accepts `year:` but never sends it:
|
|
58
|
+
# serving the current rendering beats an error until the filter ships.
|
|
59
|
+
def images(model, color: nil)
|
|
37
60
|
params = {}
|
|
38
|
-
params[:
|
|
39
|
-
params
|
|
40
|
-
get("/v1/models/#{model.slug}/image", params)&.dig("url")
|
|
61
|
+
params[:color] = color.to_s unless color.to_s.empty?
|
|
62
|
+
get("#{API_PREFIX}/vehicles/#{model.kind}/#{model.make_slug}/#{model.model_slug}/images", params)
|
|
41
63
|
end
|
|
42
64
|
|
|
43
65
|
# --- internals -----------------------------------------------------------
|
|
44
66
|
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return @cache[model.slug] if @cache.key?(model.slug)
|
|
49
|
-
|
|
50
|
-
@cache[model.slug] = get("/v1/models/#{model.slug}", {})
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def reset!
|
|
54
|
-
@cache = {}
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# Issue a GET and parse JSON. Returns nil on ANY failure (network, timeout,
|
|
58
|
-
# non-200, bad JSON) — callers treat nil as "fall back to local data".
|
|
67
|
+
# Issue a keyed GET and parse JSON. Returns nil on ANY failure (network,
|
|
68
|
+
# timeout, non-200, bad JSON) — callers treat nil as "fall back to local
|
|
69
|
+
# data". The wire rules (timeout, no retry) are Vehicles::HTTP's.
|
|
59
70
|
def get(path, params)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
config = Vehicles.configuration
|
|
66
|
-
uri = URI.join(config.api_base_url, path)
|
|
67
|
-
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
68
|
-
|
|
69
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
70
|
-
http.use_ssl = uri.scheme == "https"
|
|
71
|
-
http.open_timeout = config.api_timeout
|
|
72
|
-
http.read_timeout = config.api_timeout
|
|
73
|
-
|
|
74
|
-
request = Net::HTTP::Get.new(uri)
|
|
75
|
-
request["Authorization"] = "Bearer #{config.api_key}"
|
|
76
|
-
request["Accept"] = "application/json"
|
|
77
|
-
|
|
78
|
-
response = http.request(request)
|
|
79
|
-
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
80
|
-
|
|
81
|
-
JSON.parse(response.body)
|
|
82
|
-
rescue StandardError => e
|
|
83
|
-
warn "[vehicles] hosted lookup failed (#{e.class}); using local data" if $DEBUG
|
|
71
|
+
body = HTTP.get(HTTP.url(path, params),
|
|
72
|
+
"Authorization" => "Bearer #{Vehicles.configuration.api_key}", "Accept" => "application/json")
|
|
73
|
+
body && JSON.parse(body)
|
|
74
|
+
rescue StandardError # bad JSON, a malformed api_base_url
|
|
84
75
|
nil
|
|
85
76
|
end
|
|
86
77
|
end
|
|
@@ -17,9 +17,10 @@ module Vehicles
|
|
|
17
17
|
# The bundled snapshot is make/model/kind/body_type only — richer fields
|
|
18
18
|
# come from the hosted API. Returning nil here lets the resolver move on
|
|
19
19
|
# (and ultimately yield nil) instead of raising.
|
|
20
|
-
def years(_model)
|
|
21
|
-
def segment(_model)
|
|
22
|
-
def image(_model, year
|
|
20
|
+
def years(_model) = nil
|
|
21
|
+
def segment(_model) = nil
|
|
22
|
+
def image(_model, year: nil, color: nil, size: :md) = nil
|
|
23
|
+
def images(_model, color: nil) = nil
|
|
23
24
|
end
|
|
24
25
|
end
|
|
25
26
|
end
|
|
@@ -15,7 +15,9 @@ class VehicleMakeValidator < ActiveModel::EachValidator
|
|
|
15
15
|
def validate_each(record, attribute, value)
|
|
16
16
|
return if value.blank?
|
|
17
17
|
return if options[:allow_other] && Vehicles.other?(value)
|
|
18
|
-
|
|
18
|
+
# Exact-only: a typo must fail validation, not be stored as if it were the
|
|
19
|
+
# make it resembles (Vehicles.make is typo-tolerant since 0.7.8).
|
|
20
|
+
return if Vehicles.make(value, fuzzy: false)
|
|
19
21
|
|
|
20
22
|
record.errors.add(attribute, options[:message] || "is not a recognized vehicle make")
|
|
21
23
|
rescue StandardError => e
|
|
@@ -20,7 +20,7 @@ class VehicleModelValidator < ActiveModel::EachValidator
|
|
|
20
20
|
|
|
21
21
|
make_attribute = options[:make] || :make
|
|
22
22
|
make_value = record.respond_to?(make_attribute) ? record.public_send(make_attribute) : nil
|
|
23
|
-
make = Vehicles.make(make_value)
|
|
23
|
+
make = Vehicles.make(make_value, fuzzy: false) # exact, same rule as the make validator
|
|
24
24
|
return if make.nil? # unknown make -> defer to the make validator
|
|
25
25
|
return if make.model(value)
|
|
26
26
|
|
data/lib/vehicles/version.rb
CHANGED
data/lib/vehicles.rb
CHANGED
|
@@ -2,14 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "vehicles/version"
|
|
4
4
|
require_relative "vehicles/configuration"
|
|
5
|
+
require_relative "vehicles/http"
|
|
5
6
|
require_relative "vehicles/make"
|
|
6
7
|
require_relative "vehicles/model"
|
|
7
8
|
require_relative "vehicles/color"
|
|
9
|
+
require_relative "vehicles/fuzzy"
|
|
8
10
|
require_relative "vehicles/dataset"
|
|
9
11
|
require_relative "vehicles/refresher"
|
|
10
12
|
require_relative "vehicles/providers/local_provider"
|
|
11
13
|
require_relative "vehicles/providers/hosted_provider"
|
|
12
14
|
require_relative "vehicles/other_option"
|
|
15
|
+
require_relative "vehicles/plates"
|
|
16
|
+
require_relative "vehicles/plates/jurisdiction"
|
|
17
|
+
require_relative "vehicles/plates/series"
|
|
18
|
+
require_relative "vehicles/plates/template_fill"
|
|
19
|
+
require_relative "vehicles/plates/templates"
|
|
13
20
|
|
|
14
21
|
# Car makes & models for your Rails app — dropdowns, search, validation. Bundled
|
|
15
22
|
# data, zero config, no network calls. Standalone first; an SDK for the hosted
|
|
@@ -43,7 +50,6 @@ module Vehicles
|
|
|
43
50
|
@providers = nil
|
|
44
51
|
@data_path = nil
|
|
45
52
|
Dataset.reset!
|
|
46
|
-
Providers::HostedProvider.reset!
|
|
47
53
|
end
|
|
48
54
|
|
|
49
55
|
# --- data access ---------------------------------------------------------
|
|
@@ -108,20 +114,28 @@ module Vehicles
|
|
|
108
114
|
# Vehicles.models("Toyota", include_other: true) # ... + the "Other" escape hatch
|
|
109
115
|
# With `include_other:`, an unknown make (e.g. the "Other" make itself) yields
|
|
110
116
|
# just `[other_label]`, so a make→model picker never dead-ends.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
117
|
+
# `fuzzy:` is forwarded to `make` (typo-tolerant by default since 0.7.8).
|
|
118
|
+
def models(make, kind: nil, body_type: nil, region: nil, rarity: nil, max_decile: nil, include_other: false,
|
|
119
|
+
fuzzy: true)
|
|
120
|
+
names = make(make, fuzzy: fuzzy)&.models(kind: kind, body_type: body_type,
|
|
121
|
+
region: region || configuration.region,
|
|
122
|
+
rarity: rarity, max_decile: max_decile)&.map(&:name) || []
|
|
114
123
|
append_other_name(names, include_other)
|
|
115
124
|
end
|
|
116
125
|
|
|
117
|
-
# The rich Make object (or nil). Forgiving: name, slug,
|
|
118
|
-
|
|
119
|
-
|
|
126
|
+
# The rich Make object (or nil). Forgiving: name, slug, alias — and, since
|
|
127
|
+
# 0.7.8, typos: "marcedes" → Mercedes-Benz, "volkswagon" → Volkswagen
|
|
128
|
+
# (bounded edit distance, only when ONE make is closest; ties → nil).
|
|
129
|
+
# Pass `fuzzy: false` for exact-only resolution (the validators do).
|
|
130
|
+
def make(query, fuzzy: true)
|
|
131
|
+
dataset.find_make(query, fuzzy: fuzzy)
|
|
120
132
|
end
|
|
121
133
|
|
|
122
134
|
# Resolve a free-text "make + model" string into one Model (or nil).
|
|
123
|
-
|
|
124
|
-
|
|
135
|
+
# Exact makes are tried first; a misspelled make is retried with the same
|
|
136
|
+
# typo tolerance as `make` ("volkswagon golf"). `fuzzy: false` disables it.
|
|
137
|
+
def find(query, fuzzy: true)
|
|
138
|
+
dataset.find_model(query, fuzzy: fuzzy)
|
|
125
139
|
end
|
|
126
140
|
|
|
127
141
|
# Resolve a stored make + model PAIR into a Model (or nil). The structured
|
|
@@ -130,9 +144,31 @@ module Vehicles
|
|
|
130
144
|
# model's metadata (kind, body_type, …) back.
|
|
131
145
|
# Vehicles.model("Audi", "A3") # => #<Vehicles::Model "Audi A3">
|
|
132
146
|
# Vehicles.model("vw", "golf") # forgiving, like every other lookup
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
147
|
+
# Vehicles.model("Kia", "XCeed") # spacing-blind: the dataset's "X Ceed"
|
|
148
|
+
# Vehicles.model("BMW", "Serie 1") # => BMW 1 Series
|
|
149
|
+
# Vehicles.model("Toyota", "Land Cruiser") # => the car (preferred)
|
|
150
|
+
# Vehicles.model("Toyota", "Land Cruiser", kind: :bus)
|
|
151
|
+
def model(make_name, model_name, fuzzy: true, kind: nil)
|
|
152
|
+
found = make(make_name, fuzzy: fuzzy)
|
|
153
|
+
found&.model(model_name, kind: kind)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# License plates (the bundled PRD-PLATES dataset). No argument: every
|
|
157
|
+
# jurisdiction. With a code: that jurisdiction (or nil — forgiving).
|
|
158
|
+
# Vehicles.plates # => [#<Jurisdiction nl …>, …]
|
|
159
|
+
# Vehicles.plates(:nl) # => the Netherlands entry (33 series)
|
|
160
|
+
def plates(code = nil)
|
|
161
|
+
code.nil? ? Plates.jurisdictions : Plates.jurisdiction(code)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Validate a typed registration against a jurisdiction's real series.
|
|
165
|
+
# Two-tier: exact as-issued, else separator-forgiving with a formatting
|
|
166
|
+
# suggestion. Never raises; unknown jurisdictions yield an empty match.
|
|
167
|
+
# Vehicles.plate("12-GB-BD", jurisdiction: :nl).valid? # => true (exact)
|
|
168
|
+
# Vehicles.plate("1234XYZ", jurisdiction: :es).suggestion # => "1234 XYZ"
|
|
169
|
+
# Vehicles.plate("12-AB-CD", jurisdiction: :nl).valid? # => false (vowel purge)
|
|
170
|
+
def plate(input, jurisdiction:)
|
|
171
|
+
Plates.match(input, jurisdiction: jurisdiction)
|
|
136
172
|
end
|
|
137
173
|
|
|
138
174
|
# Every model matching a query, ranked. => [Vehicles::Model, ...]
|
|
@@ -149,8 +185,9 @@ module Vehicles
|
|
|
149
185
|
# first. Filter by country (ISO alpha-2) or continent (:eu/:as/…).
|
|
150
186
|
# Vehicles.top_models(kind: :car, country: :nl, limit: 10).map(&:name)
|
|
151
187
|
# Vehicles.top_models(kind: :motorcycle, region: :as, limit: 10)
|
|
152
|
-
|
|
153
|
-
|
|
188
|
+
# Vehicles.top_models(kind: :car, by: :mass_decile) # by registration mass (data 2026.10.1+)
|
|
189
|
+
def top_models(kind: nil, country: nil, region: nil, limit: 20, by: :global_decile)
|
|
190
|
+
dataset.top_models(kind: kind, country: country, region: region, limit: limit, by: by)
|
|
154
191
|
end
|
|
155
192
|
|
|
156
193
|
# A curated slice of models by kind/continent/rarity — the "give me sensible
|
|
@@ -198,8 +235,8 @@ module Vehicles
|
|
|
198
235
|
|
|
199
236
|
# [[label, value], ...] of a make's models for a Rails `select`. Unknown => [].
|
|
200
237
|
# Vehicles.model_options("audi", include_other: true) # ... + [other_label, "other"]
|
|
201
|
-
def model_options(make, kind: nil, body_type: nil, include_other: false)
|
|
202
|
-
found = make(make)
|
|
238
|
+
def model_options(make, kind: nil, body_type: nil, include_other: false, fuzzy: true)
|
|
239
|
+
found = make(make, fuzzy: fuzzy)
|
|
203
240
|
opts = found ? found.model_options(kind: kind, body_type: body_type) : []
|
|
204
241
|
append_other_option(opts, include_other)
|
|
205
242
|
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "English"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
# The bundled dataset refresh (data/vehicles.json), as a task so the
|
|
7
|
+
# provenance step and the country-rank merge can't be forgotten.
|
|
8
|
+
#
|
|
9
|
+
# rake "data:refresh[v2026.10.0]" # a published release TAG, never a branch
|
|
10
|
+
#
|
|
11
|
+
# Source: the public data repo at a release tag — dist/vehicles.json (the gem
|
|
12
|
+
# projection) unchanged, EXCEPT that each model gains `country_ranks`
|
|
13
|
+
# ({ "gb" => 2, … }) when the projection does not carry it yet. Those ranks
|
|
14
|
+
# are copied from the SAME tag's catalog/<kind>/models.json
|
|
15
|
+
# (popularity.by_country[cc].rank — open CC-BY data), keyed by the full
|
|
16
|
+
# kind/make/model id, so the merged file is what the pipeline's own projection
|
|
17
|
+
# emits once it ships the key (vehiclesdb-pipeline emit.rb write_dist_json!,
|
|
18
|
+
# 2026-10-10). When the projection DOES carry the key, the task asserts every
|
|
19
|
+
# rank equals the catalog's and aborts on any mismatch. Output formatting is
|
|
20
|
+
# the pipeline's (JSON.pretty_generate + "\n"); stripping `country_ranks`
|
|
21
|
+
# from the output gives back the release file byte for byte.
|
|
22
|
+
module DataRefresh
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def git_show(repo, rev, path)
|
|
26
|
+
out = IO.popen(["git", "-C", repo, "show", "#{rev}:#{path}"], &:read)
|
|
27
|
+
abort "missing #{path} at #{rev}" unless $CHILD_STATUS.success?
|
|
28
|
+
out
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# "kind/make/model" => { "gb" => 2, … } for every ranked model at the tag.
|
|
32
|
+
def catalog_ranks(repo, rev, kinds)
|
|
33
|
+
kinds.each_with_object({}) do |kind, ranks|
|
|
34
|
+
JSON.parse(git_show(repo, rev, "catalog/#{kind}/models.json")).each do |m|
|
|
35
|
+
by_country = m.dig("popularity", "by_country") || {}
|
|
36
|
+
r = by_country.filter_map { |cc, p| [cc, p["rank"]] if p["rank"] }.sort.to_h
|
|
37
|
+
ranks["#{kind}/#{m["id"]}"] = r unless r.empty?
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Insert `country_ranks` where the pipeline emits it (right after
|
|
43
|
+
# global_decile), or assert it when the projection already carries it.
|
|
44
|
+
# Returns [merged, native] counts.
|
|
45
|
+
def merge!(dist, ranks)
|
|
46
|
+
merged = native = 0
|
|
47
|
+
dist["makes"].each do |mk|
|
|
48
|
+
mk["models"].each do |m|
|
|
49
|
+
id = "#{m["kind"]}/#{mk["slug"]}/#{m["slug"]}"
|
|
50
|
+
want = ranks[id]
|
|
51
|
+
if m.key?("country_ranks")
|
|
52
|
+
native += 1
|
|
53
|
+
abort "country_ranks mismatch for #{id}" unless m["country_ranks"] == want
|
|
54
|
+
elsif want
|
|
55
|
+
m.replace(with_ranks(m, want))
|
|
56
|
+
merged += 1
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
[merged, native]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def with_ranks(model, ranks)
|
|
64
|
+
out = model.each_with_object({}) do |(k, v), h|
|
|
65
|
+
h[k] = v
|
|
66
|
+
h["country_ranks"] = ranks if k == "global_decile"
|
|
67
|
+
end
|
|
68
|
+
out["country_ranks"] ||= ranks
|
|
69
|
+
out
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def provenance(tag, commit, version, merged, native)
|
|
73
|
+
<<~MD
|
|
74
|
+
# Bundled vehicles dataset
|
|
75
|
+
|
|
76
|
+
`data/vehicles.json` is the public data repo's `dist/vehicles.json`
|
|
77
|
+
(the gem projection) at a release tag:
|
|
78
|
+
|
|
79
|
+
- **Source**: https://github.com/vehiclesdb/vehiclesdb `dist/vehicles.json`
|
|
80
|
+
- **Tag**: `#{tag}` (commit `#{commit}`), dataset version `#{version}`
|
|
81
|
+
- **country_ranks**: #{native} model(s) carried it natively; #{merged} merged from
|
|
82
|
+
the same tag's `catalog/<kind>/models.json` `popularity.by_country[cc].rank`
|
|
83
|
+
- **License**: CC-BY 4.0 (see the data repo's LICENSE + ATTRIBUTION.md)
|
|
84
|
+
|
|
85
|
+
Refresh: `rake "data:refresh[<tag>]"` (this file is generated by it).
|
|
86
|
+
MD
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
namespace :data do
|
|
91
|
+
desc "Refresh data/vehicles.json from a release tag of the local dataset repo"
|
|
92
|
+
task :refresh, [:tag] do |_t, args|
|
|
93
|
+
tag = args[:tag] or abort 'usage: rake "data:refresh[vYYYY.MM.P]" (a release tag)'
|
|
94
|
+
root = File.expand_path("..", __dir__)
|
|
95
|
+
repo = File.expand_path(ENV.fetch("VDB_DATA_REPO", "~/GitHub/vehiclesdb"))
|
|
96
|
+
sh "git", "-C", repo, "fetch", "origin", "--tags", "-q"
|
|
97
|
+
# refs/tags/ only: a branch name or a bare SHA must not pass for a release.
|
|
98
|
+
commit = IO.popen(["git", "-C", repo, "rev-parse", "--verify", "-q", "refs/tags/#{tag}^{commit}"], &:read).strip
|
|
99
|
+
abort "not a release tag: #{tag}" unless $CHILD_STATUS.success? && !commit.empty?
|
|
100
|
+
|
|
101
|
+
# Read at the VERIFIED commit, not the tag name (a same-named branch must never win).
|
|
102
|
+
dist = JSON.parse(DataRefresh.git_show(repo, commit, "dist/vehicles.json"))
|
|
103
|
+
merged, native = DataRefresh.merge!(dist, DataRefresh.catalog_ranks(repo, commit, dist["kinds"]))
|
|
104
|
+
File.write("#{root}/data/vehicles.json", "#{JSON.pretty_generate(dist)}\n")
|
|
105
|
+
File.write("#{root}/data/PROVENANCE-vehicles.md",
|
|
106
|
+
DataRefresh.provenance(tag, commit, dist["version"], merged, native))
|
|
107
|
+
puts "data refreshed to #{tag} (#{dist["version"]}): #{merged} merged, #{native} native country_ranks"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# The plates-data refresh, as a task so the PROVENANCE step can't be
|
|
2
|
+
# forgotten (it is gem-local; a bare rm+archive deletes it silently —
|
|
3
|
+
# that shipped once in 0.7.4 and broke consumers reading the pin).
|
|
4
|
+
namespace :plates do
|
|
5
|
+
desc "Refresh data/plates from the local dataset repo's origin/main"
|
|
6
|
+
task :refresh do
|
|
7
|
+
root = File.expand_path("..", __dir__)
|
|
8
|
+
ds = File.expand_path("~/GitHub/vehiclesdb")
|
|
9
|
+
sh "git -C #{ds} fetch origin -q"
|
|
10
|
+
rm_rf "#{root}/data/plates"
|
|
11
|
+
mkdir_p "#{root}/data/plates"
|
|
12
|
+
sh "git -C #{ds} archive origin/main plates | tar -x --exclude 'plates/_art' -C #{root}/data/"
|
|
13
|
+
rm_rf "#{root}/data/plates/_art"
|
|
14
|
+
commit = `git -C #{ds} rev-parse origin/main`.strip
|
|
15
|
+
File.write("#{root}/data/plates/PROVENANCE.md", <<~MD)
|
|
16
|
+
# Bundled plates dataset (PRD-PLATES, full corpus)
|
|
17
|
+
|
|
18
|
+
Copy of `plates/` from the public data repo (EXCLUDING `_art/`),
|
|
19
|
+
refreshed on gem releases:
|
|
20
|
+
|
|
21
|
+
- **Source**: https://github.com/vehiclesdb/vehiclesdb `plates/`
|
|
22
|
+
- **Commit**: `#{commit}`
|
|
23
|
+
- **License**: CC-BY 4.0 (see the data repo's LICENSE + ATTRIBUTION.md)
|
|
24
|
+
|
|
25
|
+
Refresh: `rake plates:refresh` (this file is generated by it).
|
|
26
|
+
MD
|
|
27
|
+
puts "plates refreshed at #{commit}"
|
|
28
|
+
end
|
|
29
|
+
end
|
data/sig/vehicles.rbs
CHANGED
|
@@ -49,6 +49,24 @@ module Vehicles
|
|
|
49
49
|
attr_accessor cache_path: String
|
|
50
50
|
attr_accessor use_cache: bool
|
|
51
51
|
attr_accessor refresh_timeout: Integer
|
|
52
|
+
attr_accessor plate_template_cache: untyped
|
|
53
|
+
attr_accessor plate_template_ttl: Integer
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
module Plates
|
|
57
|
+
def self.template: (code: String | Symbol, series: String, ?shape: String?, ?region: String?) -> String?
|
|
58
|
+
def self.render: (String registration, jurisdiction: String | Symbol, ?region: String?, ?label: String?) -> String?
|
|
59
|
+
|
|
60
|
+
module TemplateFill
|
|
61
|
+
class Error < StandardError
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
SCHEMA_VERSION: Integer
|
|
65
|
+
def self.fill: (String svg, String registration, ?label: String?) -> String
|
|
66
|
+
def self.shape_of: (String text) -> String?
|
|
67
|
+
def self.fit_size: (Numeric size, Numeric room, Numeric natural_width) -> Float
|
|
68
|
+
def self.recentered_baseline: (Numeric origin, Numeric size, Numeric per_size) -> Float
|
|
69
|
+
end
|
|
52
70
|
end
|
|
53
71
|
|
|
54
72
|
class Make
|