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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -7
- data/README.md +353 -69
- data/data/PROVENANCE-vehicles.md +2 -2
- data/data/plates/PROVENANCE.md +2 -2
- data/data/plates/_decode/at-districts.yml +2 -2
- data/data/plates/_decode/cn-issuing-offices.yml +2 -2
- data/data/plates/_decode/cn-provinces.yml +2 -2
- data/data/plates/_decode/cz-kraj.yml +1 -1
- data/data/plates/_decode/dk-serial-blocks.yml +1 -1
- data/data/plates/_decode/es-provinces.yml +1 -1
- data/data/plates/_decode/fr-departements.yml +2 -2
- data/data/plates/_decode/gb-age-identifiers.yml +1 -1
- data/data/plates/_decode/gb-memory-tags.yml +1 -1
- data/data/plates/_decode/gr-prefectures.yml +1 -1
- data/data/plates/_decode/hr-cities.yml +3 -3
- data/data/plates/_decode/ie-counties.yml +3 -3
- data/data/plates/_decode/in-state-codes.yml +2 -2
- data/data/plates/_decode/it-provinces.yml +3 -3
- data/data/plates/_decode/jp-office-marks.yml +2 -2
- data/data/plates/_decode/jp-use-marks.yml +2 -2
- data/data/plates/_decode/kr-authority-marks.yml +1 -1
- data/data/plates/_decode/mx-entity-series.yml +1 -1
- data/data/plates/_decode/my-index-marks.yml +4 -4
- data/data/plates/_decode/pt-trailer-services.yml +1 -1
- data/data/plates/_decode/ro-counties.yml +1 -1
- data/data/plates/_decode/se-diplomatic.yml +1 -1
- data/data/plates/_decode/si-regions.yml +1 -1
- data/data/plates/_decode/ua-regions.yml +1 -1
- data/data/plates/_meta/classes.yml +1 -1
- data/data/plates/_meta/separators.yml +1 -1
- data/data/plates/ad.yml +555 -52
- data/data/plates/ar.yml +8 -8
- data/data/plates/at.yml +91 -10
- data/data/plates/au-act.yml +13 -13
- data/data/plates/au-nsw.yml +9 -9
- data/data/plates/au-nt.yml +13 -14
- data/data/plates/au-qld.yml +7 -7
- data/data/plates/au-sa.yml +7 -7
- data/data/plates/au-tas.yml +10 -10
- data/data/plates/au-vic.yml +6 -6
- data/data/plates/au-wa.yml +8 -8
- data/data/plates/be.yml +11 -11
- data/data/plates/br.yml +14 -14
- data/data/plates/ca-ab.yml +10 -10
- data/data/plates/ca-bc.yml +6 -6
- data/data/plates/ca-mb.yml +4 -4
- data/data/plates/ca-nb.yml +9 -9
- data/data/plates/ca-nl.yml +11 -11
- data/data/plates/ca-ns.yml +12 -12
- data/data/plates/ca-nt.yml +7 -7
- data/data/plates/ca-nu.yml +7 -7
- data/data/plates/ca-on.yml +21 -21
- data/data/plates/ca-pe.yml +13 -13
- data/data/plates/ca-qc.yml +11 -11
- data/data/plates/ca-sk.yml +6 -6
- data/data/plates/ca-yt.yml +6 -6
- data/data/plates/ch.yml +14 -14
- data/data/plates/cn.yml +9 -9
- data/data/plates/cy.yml +6 -6
- data/data/plates/cz.yml +9 -9
- data/data/plates/de.yml +8 -8
- data/data/plates/dk.yml +12 -12
- data/data/plates/ee.yml +13 -13
- data/data/plates/eg.yml +708 -0
- data/data/plates/es.yml +8 -8
- data/data/plates/fi.yml +14 -14
- data/data/plates/fr.yml +12 -12
- data/data/plates/gb.yml +16 -16
- data/data/plates/gr.yml +14 -14
- data/data/plates/hr.yml +15 -15
- data/data/plates/hu.yml +16 -16
- data/data/plates/ie.yml +7 -7
- data/data/plates/in.yml +7 -7
- data/data/plates/is.yml +3 -3
- data/data/plates/it.yml +21 -21
- data/data/plates/jp.yml +121 -121
- data/data/plates/kr.yml +6 -6
- data/data/plates/li.yml +6 -6
- data/data/plates/lt.yml +10 -10
- data/data/plates/lu.yml +4 -4
- data/data/plates/lv.yml +9 -9
- data/data/plates/mc.yml +4 -4
- data/data/plates/mt.yml +5 -5
- data/data/plates/mx.yml +7 -7
- data/data/plates/my.yml +18 -18
- data/data/plates/ng.yml +346 -0
- data/data/plates/nl.yml +5 -5
- data/data/plates/no.yml +7 -7
- data/data/plates/nz.yml +18 -18
- data/data/plates/pl.yml +4 -4
- data/data/plates/pt.yml +13 -5
- data/data/plates/ro.yml +14 -14
- data/data/plates/se.yml +13 -13
- data/data/plates/si.yml +8 -8
- data/data/plates/sk.yml +15 -15
- data/data/plates/sm.yml +7 -7
- data/data/plates/th.yml +10 -10
- data/data/plates/tr.yml +432 -0
- data/data/plates/ua.yml +9 -9
- data/data/plates/us-ak.yml +11 -11
- data/data/plates/us-al.yml +12 -12
- data/data/plates/us-ar.yml +15 -15
- data/data/plates/us-as.yml +10 -10
- data/data/plates/us-az.yml +10 -10
- data/data/plates/us-ca.yml +13 -13
- data/data/plates/us-co.yml +14 -14
- data/data/plates/us-ct.yml +24 -24
- data/data/plates/us-dc.yml +7 -7
- data/data/plates/us-de.yml +10 -10
- data/data/plates/us-fl.yml +17 -17
- data/data/plates/us-ga.yml +10 -10
- data/data/plates/us-gu.yml +11 -11
- data/data/plates/us-hi.yml +11 -11
- data/data/plates/us-ia.yml +9 -9
- data/data/plates/us-id.yml +13 -13
- data/data/plates/us-il.yml +11 -11
- data/data/plates/us-in.yml +9 -9
- data/data/plates/us-ks.yml +8 -8
- data/data/plates/us-ky.yml +11 -11
- data/data/plates/us-la.yml +11 -11
- data/data/plates/us-ma.yml +10 -10
- data/data/plates/us-md.yml +7 -7
- data/data/plates/us-me.yml +16 -16
- data/data/plates/us-mi.yml +8 -8
- data/data/plates/us-mn.yml +13 -13
- data/data/plates/us-mo.yml +13 -13
- data/data/plates/us-mp.yml +12 -12
- data/data/plates/us-ms.yml +8 -8
- data/data/plates/us-mt.yml +12 -12
- data/data/plates/us-nc.yml +12 -12
- data/data/plates/us-nd.yml +10 -10
- data/data/plates/us-ne.yml +10 -10
- data/data/plates/us-nh.yml +8 -8
- data/data/plates/us-nj.yml +8 -8
- data/data/plates/us-nm.yml +14 -14
- data/data/plates/us-nv.yml +18 -18
- data/data/plates/us-ny.yml +17 -17
- data/data/plates/us-oh.yml +13 -13
- data/data/plates/us-ok.yml +12 -12
- data/data/plates/us-or.yml +15 -15
- data/data/plates/us-pa.yml +14 -14
- data/data/plates/us-pr.yml +15 -15
- data/data/plates/us-ri.yml +10 -10
- data/data/plates/us-sc.yml +7 -7
- data/data/plates/us-sd.yml +12 -12
- data/data/plates/us-tn.yml +8 -8
- data/data/plates/us-tx.yml +17 -17
- data/data/plates/us-ut.yml +12 -12
- data/data/plates/us-va.yml +14 -14
- data/data/plates/us-vi.yml +16 -16
- data/data/plates/us-vt.yml +9 -9
- data/data/plates/us-wa.yml +18 -18
- data/data/plates/us-wi.yml +8 -8
- data/data/plates/us-wv.yml +9 -9
- data/data/plates/us-wy.yml +10 -10
- data/data/plates/va.yml +7 -7
- data/data/plates/za.yml +11 -11
- data/data/vehicles.json +121081 -62015
- data/lib/generators/vehicles/templates/initializer.rb +21 -12
- data/lib/generators/vehicles/templates/vehicles_refresh_job.rb +8 -4
- data/lib/vehicles/color.rb +8 -1
- data/lib/vehicles/colors.rb +14 -10
- data/lib/vehicles/configuration.rb +82 -3
- data/lib/vehicles/data_source.rb +220 -0
- data/lib/vehicles/dataset.rb +38 -6
- data/lib/vehicles/dataset_resolution.rb +39 -3
- data/lib/vehicles/dataset_slice.rb +90 -0
- data/lib/vehicles/hosted_result.rb +94 -0
- data/lib/vehicles/http.rb +75 -7
- data/lib/vehicles/id_lookup.rb +37 -0
- data/lib/vehicles/kind_rule.rb +63 -0
- data/lib/vehicles/labels.rb +131 -0
- data/lib/vehicles/make.rb +45 -19
- data/lib/vehicles/model.rb +66 -19
- data/lib/vehicles/model_match.rb +16 -2
- data/lib/vehicles/model_matcher.rb +6 -7
- data/lib/vehicles/plates/series.rb +87 -8
- data/lib/vehicles/plates/template_fill.rb +2 -2
- data/lib/vehicles/plates/templates.rb +9 -5
- data/lib/vehicles/plates.rb +54 -8
- data/lib/vehicles/providers/hosted_provider.rb +145 -32
- data/lib/vehicles/providers/local_provider.rb +2 -2
- data/lib/vehicles/refresh_result.rb +74 -0
- data/lib/vehicles/refresher.rb +190 -54
- data/lib/vehicles/release_checks.rb +72 -0
- data/lib/vehicles/text_matcher.rb +19 -10
- data/lib/vehicles/validators/vehicle_make_validator.rb +18 -1
- data/lib/vehicles/validators/vehicle_model_validator.rb +17 -1
- data/lib/vehicles/version.rb +1 -1
- data/lib/vehicles.rb +119 -67
- data/rakelib/data_refresh.rake +1 -1
- data/rakelib/plates_refresh.rake +1 -1
- data/sig/vehicles.rbs +67 -13
- metadata +13 -2
data/lib/vehicles/model.rb
CHANGED
|
@@ -11,8 +11,10 @@ module Vehicles
|
|
|
11
11
|
# car.body_type # => :hatchback
|
|
12
12
|
# car.suv? # => false
|
|
13
13
|
class Model
|
|
14
|
-
# Vehicle kinds (
|
|
15
|
-
#
|
|
14
|
+
# Vehicle kinds the predicates know (`car?`, `van?`, …). The data itself
|
|
15
|
+
# publishes car, motorcycle, moped, van, truck and bus (Vehicles.kinds);
|
|
16
|
+
# pickup/trailer/quad/trike are kept so existing predicate calls answer
|
|
17
|
+
# false instead of raising.
|
|
16
18
|
KINDS = %i[car motorcycle van truck pickup trailer bus moped quad trike].freeze
|
|
17
19
|
|
|
18
20
|
# Body types — the sub-classification within a kind. For cars these are the
|
|
@@ -31,7 +33,7 @@ module Vehicles
|
|
|
31
33
|
# optional body_type/global_decile/availability/regions/aliases/forms/former_ids)
|
|
32
34
|
# @param make [String] the parent make's display name
|
|
33
35
|
# @param make_slug [String] the parent make's slug (for the composite slug)
|
|
34
|
-
def initialize(attrs, make:, make_slug:)
|
|
36
|
+
def initialize(attrs, make:, make_slug:) # rubocop:disable Metrics/AbcSize -- one line per published field
|
|
35
37
|
@make = make
|
|
36
38
|
@make_slug = make_slug
|
|
37
39
|
@name = attrs["name"]
|
|
@@ -74,9 +76,29 @@ module Vehicles
|
|
|
74
76
|
# dataset 2026.10.1 (pipeline#255, ruling R3); nil on older snapshots
|
|
75
77
|
# (including the bundled 2026.10.0) and for unranked records.
|
|
76
78
|
@mass_decile = mass_decile_of(attrs)
|
|
79
|
+
# The first and last year this nameplate appears in official
|
|
80
|
+
# REGISTRATION data (dataset key `registration_years`), when published.
|
|
81
|
+
# Not production or model years: grey imports and late registrations
|
|
82
|
+
# widen it, and a register's coverage starts when its data does.
|
|
83
|
+
@registration_years = registration_years_of(attrs)
|
|
84
|
+
@id = id_of(make_slug)
|
|
77
85
|
freeze
|
|
78
86
|
end
|
|
79
87
|
|
|
88
|
+
# The canonical VehiclesDB id, "car/volkswagen/golf" (RESOLUTION.md §1):
|
|
89
|
+
# what to STORE. The same string the API, the SDKs and the widget use, and
|
|
90
|
+
# what Vehicles.by_id / Vehicles.identify read back — including after a
|
|
91
|
+
# dataset release renames the record (it lists the old id in former_ids).
|
|
92
|
+
attr_reader :id
|
|
93
|
+
|
|
94
|
+
# Years this nameplate shows up in official registrations, as
|
|
95
|
+
# `{ first: 1998, last: 2026 }` (the SDKs' shape), or nil when the dataset
|
|
96
|
+
# does not publish them.
|
|
97
|
+
# A HINT for ordering or pre-filling a year picker — never a validation
|
|
98
|
+
# bound: an import, a late registration or a register that starts in 2015
|
|
99
|
+
# all fall outside it legitimately. Not `years` (production years).
|
|
100
|
+
attr_reader :registration_years
|
|
101
|
+
|
|
80
102
|
# "Volkswagen Golf" — the natural label for a model on its own.
|
|
81
103
|
def full_name
|
|
82
104
|
"#{make} #{name}"
|
|
@@ -154,20 +176,24 @@ module Vehicles
|
|
|
154
176
|
def rare? = rarity == :rare
|
|
155
177
|
|
|
156
178
|
def to_h
|
|
157
|
-
{ make: make, model: name, slug: slug, kind: kind, body_type: body_type,
|
|
179
|
+
{ id: id, make: make, model: name, slug: slug, model_slug: model_slug, kind: kind, body_type: body_type,
|
|
158
180
|
global_decile: global_decile, rarity: rarity,
|
|
159
|
-
availability: availability, regions: regions, aliases: aliases,
|
|
160
|
-
former_ids: former_ids, country_ranks: country_ranks, mass_decile: mass_decile
|
|
181
|
+
availability: availability, regions: regions, aliases: aliases, forms: forms,
|
|
182
|
+
former_ids: former_ids, country_ranks: country_ranks, mass_decile: mass_decile,
|
|
183
|
+
registration_years: registration_years }
|
|
161
184
|
end
|
|
162
185
|
|
|
163
|
-
# Value-object equality
|
|
186
|
+
# Value-object equality by canonical id (since 0.9.2). Until 0.9.1 it
|
|
187
|
+
# compared the composite slug, so the car and the van of one nameplate
|
|
188
|
+
# (Iveco Daily, Toyota Land Cruiser) were "equal" and collapsed in sets,
|
|
189
|
+
# `uniq` and hash keys.
|
|
164
190
|
def ==(other)
|
|
165
|
-
other.is_a?(Model) && other.
|
|
191
|
+
other.is_a?(Model) && other.id == id
|
|
166
192
|
end
|
|
167
193
|
alias eql? ==
|
|
168
194
|
|
|
169
195
|
def hash
|
|
170
|
-
|
|
196
|
+
id.hash
|
|
171
197
|
end
|
|
172
198
|
|
|
173
199
|
def inspect
|
|
@@ -180,7 +206,10 @@ module Vehicles
|
|
|
180
206
|
# They NEVER raise — a missing/slow API just yields nil and your view renders
|
|
181
207
|
# a placeholder. See Vehicles::Providers.
|
|
182
208
|
|
|
183
|
-
# Production years as a Range, e.g. 1974..2024.
|
|
209
|
+
# Production years as a Range, e.g. 1974..2024. Honest status: neither
|
|
210
|
+
# the bundled data nor the hosted API publishes production years yet, so
|
|
211
|
+
# this answers nil today (the seam lights up when the API serves them).
|
|
212
|
+
# For a year-picker hint, see `registration_years`.
|
|
184
213
|
def years
|
|
185
214
|
Vehicles.resolve(:years, self)
|
|
186
215
|
end
|
|
@@ -190,25 +219,43 @@ module Vehicles
|
|
|
190
219
|
Vehicles.resolve(:segment, self)
|
|
191
220
|
end
|
|
192
221
|
|
|
193
|
-
# One
|
|
222
|
+
# One image URL, ready for an <img src>. `size` picks the
|
|
194
223
|
# variant (:sm/:md/:lg), `color` a palette slug (Vehicles.colors); an
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
|
|
199
|
-
|
|
224
|
+
# unavailable color falls back honestly server-side (another color is
|
|
225
|
+
# served) unless `exact_color: true`, which answers nil instead. `year`
|
|
226
|
+
# is sent to the API, which rejects it until year-accurate images ship
|
|
227
|
+
# (→ nil; `images_result` says :invalid_request "unsupported_filter").
|
|
228
|
+
# nil without hosted data or on any failure — always render a
|
|
229
|
+
# placeholder path, and ask `images_result` when you need to know why.
|
|
230
|
+
def image(year: nil, color: nil, size: :md, exact_color: false)
|
|
231
|
+
Vehicles.resolve(:image, self, year: year, color: color, size: size, exact_color: exact_color)
|
|
200
232
|
end
|
|
201
233
|
|
|
202
234
|
# The full hosted images payload — every variant with dimensions, the
|
|
203
|
-
#
|
|
235
|
+
# available palette for this model, served-vs-requested color, provenance.
|
|
204
236
|
# Reach for this when one URL isn't enough (srcset, color pickers,
|
|
205
237
|
# caching whole responses). nil without hosted data.
|
|
206
|
-
def images(color: nil)
|
|
207
|
-
Vehicles.resolve(:images, self, color: color)
|
|
238
|
+
def images(color: nil, year: nil, exact_color: false)
|
|
239
|
+
Vehicles.resolve(:images, self, color: color, year: year, exact_color: exact_color)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# The same call as `images`, answering a Vehicles::HostedResult that says
|
|
243
|
+
# WHY when there is no image (no key, 401/402/429, not available, timeout…).
|
|
244
|
+
# Cached like `images`; never raises.
|
|
245
|
+
def images_result(color: nil, year: nil, exact_color: false)
|
|
246
|
+
Providers::HostedProvider.images_result(self, color: color, year: year, exact_color: exact_color)
|
|
208
247
|
end
|
|
209
248
|
|
|
210
249
|
private
|
|
211
250
|
|
|
251
|
+
def id_of(make_slug) = "#{@kind}/#{make_slug}/#{@model_slug}".freeze
|
|
252
|
+
|
|
253
|
+
def registration_years_of(attrs)
|
|
254
|
+
years = attrs["registration_years"]
|
|
255
|
+
first, last = years.is_a?(Hash) ? years.values_at("first", "last") : Array(years).first(2)
|
|
256
|
+
first.is_a?(Integer) && last.is_a?(Integer) && first <= last ? { first: first, last: last }.freeze : nil
|
|
257
|
+
end
|
|
258
|
+
|
|
212
259
|
# The flat `mass_decile` key, or the nested `popularity.mass_decile` some
|
|
213
260
|
# projections use.
|
|
214
261
|
def mass_decile_of(attrs)
|
data/lib/vehicles/model_match.rb
CHANGED
|
@@ -46,9 +46,23 @@ module Vehicles
|
|
|
46
46
|
model.nil?
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
# Rungs that rest on curated data: safe to apply WITHOUT asking a human.
|
|
50
|
+
CONFIDENT_RUNGS = %i[exact former_id fold alias form cleaned].freeze
|
|
51
|
+
|
|
52
|
+
# Safe to auto-apply (e.g. canonicalize a stored make/model, or pre-fill a
|
|
53
|
+
# form without asking): an answer (not ambiguous) reached through curated
|
|
54
|
+
# data — the id, a former id, the name, a published alias, a curated
|
|
55
|
+
# registry form, or one of those after peeling an echoed make or a "new"
|
|
56
|
+
# filler. Not :fuzzy: a typo correction is a guess ("Clase" → "Classe")
|
|
57
|
+
# to SHOW the user ("did you mean…"), never to write silently.
|
|
58
|
+
# Equivalent to `!ambiguous? && confidence >= 0.9`.
|
|
59
|
+
def confident?
|
|
60
|
+
!ambiguous? && CONFIDENT_RUNGS.include?(rung)
|
|
61
|
+
end
|
|
62
|
+
|
|
49
63
|
def to_h
|
|
50
|
-
{ model: model&.to_h, method: rung.to_s, confidence: confidence, via: via,
|
|
51
|
-
candidates: candidates.map(&:to_h), ambiguous: ambiguous? }
|
|
64
|
+
{ model: model&.to_h, id: model&.id, method: rung.to_s, confidence: confidence, via: via,
|
|
65
|
+
candidates: candidates.map(&:to_h), ambiguous: ambiguous?, confident: confident? }
|
|
52
66
|
end
|
|
53
67
|
|
|
54
68
|
def ==(other)
|
|
@@ -44,12 +44,12 @@ module Vehicles
|
|
|
44
44
|
module_function
|
|
45
45
|
|
|
46
46
|
# The full ladder a–e. A ModelMatch, or nil when nothing matched at all.
|
|
47
|
-
def match(make_query, model_query, kind: nil)
|
|
47
|
+
def match(make_query, model_query, kind: nil, kinds: nil)
|
|
48
48
|
make, via, make_typo = resolve_make(make_query)
|
|
49
49
|
return nil unless make
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
ladder(make, model_query, KindRule.scope(kind, kinds), KindRule.via(kind, kinds) + via,
|
|
52
|
+
make_query: make_query, make_typo: make_typo)
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
# The model half of the ladder on a resolved make: a–c, then (for
|
|
@@ -69,8 +69,6 @@ module Vehicles
|
|
|
69
69
|
heuristic(make, query, kind, via, make_query: make_query, make_typo: make_typo)
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
-
def kind_hint(kind) = kind ? ["kind_hint:#{kind}"] : []
|
|
73
|
-
|
|
74
72
|
# Rungs a–c only, on a resolved Make — what Vehicles.model answers. Rung a
|
|
75
73
|
# is Make#model itself, so every pair that resolved before 0.9.0 resolves
|
|
76
74
|
# identically; b–c are curated evidence, so on by default. A Model, or nil
|
|
@@ -189,9 +187,10 @@ module Vehicles
|
|
|
189
187
|
def typo_hits(make, query, kind)
|
|
190
188
|
limit = Fuzzy.budget(query.length)
|
|
191
189
|
words = query.split
|
|
190
|
+
allowed = KindRule.symbols(kind)
|
|
192
191
|
best = {}
|
|
193
192
|
make.typo_keys.each do |key, model|
|
|
194
|
-
next
|
|
193
|
+
next unless KindRule.allows?(allowed, model)
|
|
195
194
|
next if (key.length - query.length).abs > limit
|
|
196
195
|
next unless (dist = word_distance(words, key, limit))
|
|
197
196
|
|
|
@@ -242,7 +241,7 @@ module Vehicles
|
|
|
242
241
|
key = Vehicles.normalize(make_query)
|
|
243
242
|
if (make = ds.find_make(make_query, fuzzy: false))
|
|
244
243
|
own = key.empty? || key == Vehicles.normalize(make.name) || key == Vehicles.normalize(make.slug)
|
|
245
|
-
return [make, own ? [] : [
|
|
244
|
+
return [make, own ? [] : [ds.make_step(make, key)], false]
|
|
246
245
|
end
|
|
247
246
|
make = fuzzy ? ds.find_make(make_query, fuzzy: true) : nil
|
|
248
247
|
make ? [make, ["make_typo:#{key}→#{make.slug}"], true] : [nil, [], false]
|
|
@@ -11,11 +11,16 @@ module Vehicles
|
|
|
11
11
|
attr_reader :id, :klass, :categories, :period, :period_evidence,
|
|
12
12
|
:pattern, :display_pattern, :regex, :regex_strict, :design,
|
|
13
13
|
:variants, :sources, :notes, :validation_regexp, :lenient_regexp,
|
|
14
|
-
:issued_regexp, :issued_separators
|
|
14
|
+
:issued_regexp, :issued_separators, :matching, :recall_lenient_regexp,
|
|
15
|
+
:letters
|
|
15
16
|
|
|
16
17
|
def initialize(entry)
|
|
17
18
|
@id = entry["id"]
|
|
18
19
|
@klass = entry["class"] || "standard"
|
|
20
|
+
# the plates spec §2.7 (normative): a series says whether a regex hit is a
|
|
21
|
+
# validity claim (`strict`) or only a shape hit (`recall-only`).
|
|
22
|
+
# Absent => strict, the spec's backward-compatible default.
|
|
23
|
+
@matching = entry["matching"] || "strict"
|
|
19
24
|
@categories = Array(entry["categories"]).freeze
|
|
20
25
|
@period = (entry["period"] || {}).freeze
|
|
21
26
|
@period_evidence = entry["period_evidence"]
|
|
@@ -25,6 +30,11 @@ module Vehicles
|
|
|
25
30
|
@display_pattern = @pattern&.gsub(/\\(.)/) { $1 }
|
|
26
31
|
@regex = entry.dig("format", "regex")
|
|
27
32
|
@regex_strict = entry.dig("format", "regex_strict")
|
|
33
|
+
# The letters this series issues, when the dataset enumerates them
|
|
34
|
+
# (es-national-2000: the 20 consonants). Nil when it does not; a few
|
|
35
|
+
# series carry prose there ("A-Z, no exclusions published"), not a set.
|
|
36
|
+
letters = entry.dig("format", "charset", "letters")
|
|
37
|
+
@letters = letters.is_a?(Array) ? letters.map(&:to_s).freeze : nil
|
|
28
38
|
@design = (entry["design"] || {}).freeze
|
|
29
39
|
@variants = Array(entry["variants"]).freeze
|
|
30
40
|
@sources = Array(entry["sources"]).freeze
|
|
@@ -32,18 +42,48 @@ module Vehicles
|
|
|
32
42
|
# Compiled eagerly — the object is frozen, so no lazy memoization.
|
|
33
43
|
@validation_regexp = compile(@regex_strict || @regex)
|
|
34
44
|
@lenient_regexp = compile(self.class.strip_separators(@regex_strict || @regex))
|
|
45
|
+
# The series' SHAPE without its issuing alphabet, only where the two
|
|
46
|
+
# differ (a `regex_strict` exists): what Match#near_misses tests.
|
|
47
|
+
@recall_lenient_regexp = @regex_strict && compile(self.class.strip_separators(@regex))
|
|
35
48
|
segments, separators = self.class.segment_separators(@regex_strict || @regex)
|
|
36
49
|
@issued_separators = (separators || []).freeze
|
|
37
50
|
@issued_regexp = segments && compile("\\A(#{segments.join(')(')})\\z")
|
|
51
|
+
# The same segmentation with every quantifier lazy. When the greedy
|
|
52
|
+
# and lazy readings of a serial split it differently, the groups are
|
|
53
|
+
# not recoverable from the bare serial ("CD12345" is CD-123-45 or
|
|
54
|
+
# CD-1234-5): format_serial must not pick one.
|
|
55
|
+
@issued_lazy_regexp = segments && compile("\\A(#{segments.map { |seg| self.class.lazy(seg) }.join(')(')})\\z")
|
|
38
56
|
freeze
|
|
39
57
|
end
|
|
40
58
|
|
|
41
|
-
# A strict
|
|
59
|
+
# A strict series' regex hit is a validity claim for that series; a
|
|
42
60
|
# recall-only series (export/dark-blue catch-alls that accept any
|
|
43
|
-
# current shape) matches loosely by
|
|
44
|
-
#
|
|
61
|
+
# current shape, the ES 1900-1971 numeric era) matches loosely by
|
|
62
|
+
# design. Read from the dataset's `matching:` key, never inferred from
|
|
63
|
+
# `regex_strict` being present: The plates spec §2.7 forbids that inference
|
|
64
|
+
# because most strict series need no strict twin (their `regex` already
|
|
65
|
+
# IS the issuing grammar — Portugal's AA-00-AA, the Spanish CD/CC/OI/TA
|
|
66
|
+
# series). Until 0.9.2 the gem inferred it, so 668 strict series
|
|
67
|
+
# (Portugal's whole national range among them) answered "not strict".
|
|
68
|
+
# Consumers validating user input should weight strict hits above
|
|
69
|
+
# recall-only ones — Match does.
|
|
45
70
|
def strict?
|
|
46
|
-
|
|
71
|
+
matching != "recall-only"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Vehicle categories that carry people (dataset `categories`
|
|
75
|
+
# vocabulary). A series issued only to trailers, semitrailers,
|
|
76
|
+
# caravans, tractors, machines or trucks plates nothing a passenger
|
|
77
|
+
# rides in.
|
|
78
|
+
PASSENGER_CATEGORIES = %w[car van bus coach motorhome motorcycle moped scooter quadricycle tricycle
|
|
79
|
+
ambulance].freeze
|
|
80
|
+
|
|
81
|
+
# Is this series issued to at least one passenger-carrying vehicle
|
|
82
|
+
# category? (es-national-2000: yes; es-trailer-1999 and the
|
|
83
|
+
# es-special-vehicles-1999 tractor plates: no.) Series without
|
|
84
|
+
# categories answer true: unknown is not "no".
|
|
85
|
+
def passenger?
|
|
86
|
+
categories.empty? || categories.intersect?(PASSENGER_CATEGORIES)
|
|
47
87
|
end
|
|
48
88
|
|
|
49
89
|
# period_evidence values on which the recorded START is not (or not
|
|
@@ -90,10 +130,15 @@ module Vehicles
|
|
|
90
130
|
def format_serial(serial)
|
|
91
131
|
return nil if serial.nil?
|
|
92
132
|
|
|
133
|
+
# A serial whose groups the series' own regex can split more than one
|
|
134
|
+
# way is returned as stored, before anything else: the bare serial
|
|
135
|
+
# cannot say which split was issued ("CD123456" is CD-123-456 or
|
|
136
|
+
# CD-12-3456; the dataset calls CD-999-999 representative), and a
|
|
137
|
+
# display pattern of one shape must not decide it.
|
|
138
|
+
return serial.dup.freeze if ambiguous_split?(serial)
|
|
139
|
+
|
|
93
140
|
# The display pattern is authoritative when the serial fills it
|
|
94
|
-
# exactly
|
|
95
|
-
# `\d+[- ]\d+` is ambiguous to a greedy match, unambiguous to the
|
|
96
|
-
# pattern).
|
|
141
|
+
# exactly and the regex has only one reading of it.
|
|
97
142
|
slots = display_pattern&.each_char&.count { |ch| ![ "-", " ", "·", "." ].include?(ch) }
|
|
98
143
|
if slots && serial.length == slots
|
|
99
144
|
idx = 0
|
|
@@ -274,6 +319,40 @@ module Vehicles
|
|
|
274
319
|
out.gsub(REDUNDANT_CHARACTER_CLASS_REPEAT) { "#{Regexp.last_match(1)}+" }
|
|
275
320
|
end
|
|
276
321
|
|
|
322
|
+
# True when the greedy and lazy readings of the segmented regex split
|
|
323
|
+
# this serial differently.
|
|
324
|
+
def ambiguous_split?(serial)
|
|
325
|
+
greedy = issued_regexp&.match(serial) or return false
|
|
326
|
+
lazy = @issued_lazy_regexp&.match(serial) or return false
|
|
327
|
+
greedy.captures != lazy.captures
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# A regex segment with its quantifiers made lazy (+ *, ?, {m,n}), for
|
|
331
|
+
# the ambiguity check in format_serial. Escapes and character classes
|
|
332
|
+
# are copied untouched.
|
|
333
|
+
def self.lazy(src)
|
|
334
|
+
out = +""
|
|
335
|
+
chars = src.chars
|
|
336
|
+
i = 0
|
|
337
|
+
while i < chars.size
|
|
338
|
+
ch = chars[i]
|
|
339
|
+
if ch == "\\"
|
|
340
|
+
out << ch << chars[i + 1].to_s
|
|
341
|
+
i += 2
|
|
342
|
+
elsif ch == "["
|
|
343
|
+
closing = i + 1
|
|
344
|
+
closing += 1 while closing < chars.size && chars[closing] != "]"
|
|
345
|
+
out << chars[i..closing].join
|
|
346
|
+
i = closing + 1
|
|
347
|
+
else
|
|
348
|
+
out << ch
|
|
349
|
+
out << "?" if [ "+", "*", "}" ].include?(ch) && chars[i + 1] != "?"
|
|
350
|
+
i += 1
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
out
|
|
354
|
+
end
|
|
355
|
+
|
|
277
356
|
def inspect = %(#<Vehicles::Plates::Series #{id} "#{pattern}">)
|
|
278
357
|
|
|
279
358
|
private
|
|
@@ -6,14 +6,14 @@ module Vehicles
|
|
|
6
6
|
module Plates
|
|
7
7
|
# The REFERENCE FILLER for VehiclesDB plate templates — the client half of
|
|
8
8
|
# the privacy-safe template endpoint
|
|
9
|
-
# (GET /plates/:code/:series_id/template.svg, VehiclesDB
|
|
9
|
+
# (GET /plates/:code/:series_id/template.svg, VehiclesDB plate-art spec §9).
|
|
10
10
|
# A client downloads a template once (it carries nothing personal, so it
|
|
11
11
|
# caches publicly) and writes the user's registration into it HERE, on its
|
|
12
12
|
# own machine — a registration is personal data and never travels:
|
|
13
13
|
#
|
|
14
14
|
# svg = Vehicles::Plates::TemplateFill.fill(template_svg, "1234 BCD")
|
|
15
15
|
#
|
|
16
|
-
# Moved here from
|
|
16
|
+
# Moved here from the VehiclesDB web app (app/models/plates/template_fill.rb; same logic,
|
|
17
17
|
# plus shape_of and label:), whose renderer will call `fit_size` /
|
|
18
18
|
# `recentered_baseline` from this gem
|
|
19
19
|
# so the fit formula lives once. Deliberately dependency-free (Ruby stdlib
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Vehicles
|
|
4
4
|
module Plates
|
|
5
5
|
# VehiclesDB's real plate art for a registration, WITHOUT sending the
|
|
6
|
-
# registration anywhere (VehiclesDB
|
|
6
|
+
# registration anywhere (VehiclesDB plate-art spec §9: a plate number is
|
|
7
7
|
# personal data). The plate is fetched as a BLANK template — the series,
|
|
8
8
|
# the registration's shape ("NNNN LLL", character classes only) and an
|
|
9
9
|
# optional region — and filled locally by TemplateFill:
|
|
@@ -79,13 +79,17 @@ module Vehicles
|
|
|
79
79
|
nil
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
# The one series a match's plate is drawn as
|
|
83
|
-
#
|
|
82
|
+
# The one series a match's plate is drawn as. Strict hits outrank
|
|
83
|
+
# recall-only ones (a Spanish "CD-123-456" is the diplomatic series, not
|
|
84
|
+
# the 1900-1971 numeric catch-all it also fits); among them, the only
|
|
85
|
+
# standard-class series, else the only hit. Nil when it is ambiguous.
|
|
84
86
|
def template_series(found)
|
|
85
|
-
|
|
87
|
+
strict = found.series.select(&:strict?)
|
|
88
|
+
candidates = strict.empty? ? found.series : strict
|
|
89
|
+
standard = candidates.select { |series| series.klass == "standard" }
|
|
86
90
|
return standard.first if standard.one?
|
|
87
91
|
|
|
88
|
-
|
|
92
|
+
candidates.first if candidates.one?
|
|
89
93
|
end
|
|
90
94
|
|
|
91
95
|
def fetch_template(code, series, shape, region)
|
data/lib/vehicles/plates.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
|
|
5
5
|
module Vehicles
|
|
6
|
-
# License-plate & registration-mark knowledge (the VehiclesDB
|
|
6
|
+
# License-plate & registration-mark knowledge (the VehiclesDB the plates spec
|
|
7
7
|
# dataset, bundled offline like everything else in this gem). Ask it two
|
|
8
8
|
# kinds of question:
|
|
9
9
|
#
|
|
@@ -67,15 +67,20 @@ module Vehicles
|
|
|
67
67
|
canon = typed.gsub(SEPARATORS, "")
|
|
68
68
|
return Match.new(input: input.to_s, jurisdiction: juris, canon: canon, hits: []) if juris.nil? || canon.empty?
|
|
69
69
|
|
|
70
|
-
hits =
|
|
71
|
-
|
|
70
|
+
hits = []
|
|
71
|
+
near_misses = []
|
|
72
|
+
juris.series.each do |series|
|
|
73
|
+
next unless series.validation_regexp
|
|
72
74
|
|
|
73
75
|
exact = series.validation_regexp.match?(typed)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
if exact || series.lenient_regexp&.match?(canon)
|
|
77
|
+
hits << Match::Hit.new(series: series, exact: exact)
|
|
78
|
+
elsif series.recall_lenient_regexp&.match?(canon)
|
|
79
|
+
near_misses << series
|
|
80
|
+
end
|
|
76
81
|
end
|
|
77
82
|
|
|
78
|
-
Match.new(input: input.to_s, jurisdiction: juris, canon: canon, hits: hits)
|
|
83
|
+
Match.new(input: input.to_s, jurisdiction: juris, canon: canon, hits: hits, near_misses: near_misses)
|
|
79
84
|
end
|
|
80
85
|
|
|
81
86
|
# The answer to "is this a plate, and which series issues it?".
|
|
@@ -84,12 +89,17 @@ module Vehicles
|
|
|
84
89
|
def exact? = exact
|
|
85
90
|
end
|
|
86
91
|
|
|
87
|
-
attr_reader :input, :jurisdiction, :canon, :hits
|
|
92
|
+
attr_reader :input, :jurisdiction, :canon, :hits, :near_misses
|
|
88
93
|
|
|
89
|
-
def initialize(input:, jurisdiction:, canon:, hits:)
|
|
94
|
+
def initialize(input:, jurisdiction:, canon:, hits:, near_misses: [])
|
|
90
95
|
@input = input
|
|
91
96
|
@jurisdiction = jurisdiction
|
|
92
97
|
@canon = canon
|
|
98
|
+
# Series whose SHAPE the plate has but whose issuing ALPHABET it
|
|
99
|
+
# breaks: "1234 ABC" is the Spanish 2000 shape with vowels Spain never
|
|
100
|
+
# issues. Lets a form say WHY ("no vowels, Ñ or Q") instead of only
|
|
101
|
+
# "not a plate". Empty whenever the plate hits a strict series.
|
|
102
|
+
@near_misses = (hits.any? { |h| h.series.strict? } ? [] : near_misses).freeze
|
|
93
103
|
# Exact hits outrank lenient ones, strict-validated series outrank
|
|
94
104
|
# recall-only catch-alls; series order (standard first) breaks ties.
|
|
95
105
|
@hits = hits.sort_by { |h| [ h.exact? ? 0 : 1, h.series.strict? ? 0 : 1 ] }.freeze
|
|
@@ -117,6 +127,42 @@ module Vehicles
|
|
|
117
127
|
def suggestion
|
|
118
128
|
formatted if valid? && !exact?
|
|
119
129
|
end
|
|
130
|
+
|
|
131
|
+
# WHY the plate is (not) valid, as one Symbol — the same codes the
|
|
132
|
+
# VehiclesDB API answers (as strings):
|
|
133
|
+
# :ok a strict series issues it (the authority's real alphabet)
|
|
134
|
+
# :unverifiable only a recall-only series has the shape (an era
|
|
135
|
+
# or catch-all whose alphabet is not published)
|
|
136
|
+
# :excluded_letters a series has this shape but never issues these
|
|
137
|
+
# letters ("1234 ABC": Spain issues no vowels, Ñ or Q)
|
|
138
|
+
# :bad_shape no series has this shape
|
|
139
|
+
# :empty nothing but separators was typed
|
|
140
|
+
# :unknown_jurisdiction no plate data for that code
|
|
141
|
+
# Validity only: whether a passenger vehicle may carry it is `passenger?`.
|
|
142
|
+
def reason
|
|
143
|
+
return :unknown_jurisdiction if jurisdiction.nil?
|
|
144
|
+
return :empty if canon.empty?
|
|
145
|
+
return :ok if strict?
|
|
146
|
+
return :unverifiable if valid?
|
|
147
|
+
|
|
148
|
+
excluded_letters.empty? ? :bad_shape : :excluded_letters
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The letters of the plate that a near-miss series (same shape, an
|
|
152
|
+
# enumerated alphabet) never issues, e.g. ["A", "E"] for "1234 ABE" in
|
|
153
|
+
# Spain. [] when that is not why it failed.
|
|
154
|
+
def excluded_letters
|
|
155
|
+
letters = canon.scan(/[[:alpha:]]/).uniq
|
|
156
|
+
near_misses.select(&:letters).flat_map { |series| letters - series.letters }.uniq.sort
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Does a STRICT hit come from a series issued to passenger-carrying
|
|
160
|
+
# vehicles (Series#passenger?)? false for a valid trailer or tractor
|
|
161
|
+
# plate, and for anything not strictly valid. For apps that move
|
|
162
|
+
# people (ride sharing, rentals).
|
|
163
|
+
def passenger?
|
|
164
|
+
hits.any? { |hit| hit.series.strict? && hit.series.passenger? }
|
|
165
|
+
end
|
|
120
166
|
end
|
|
121
167
|
end
|
|
122
168
|
end
|