vehicles 0.4.1 → 0.6.1
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 +47 -0
- data/README.md +33 -5
- data/data/plates/PROVENANCE.md +9 -0
- data/data/plates/_decode/es-provinces.yml +116 -0
- data/data/plates/_meta/classes.yml +25 -0
- data/data/plates/de.yml +672 -0
- data/data/plates/es.yml +692 -0
- data/data/plates/nl.yml +972 -0
- data/data/plates/us-fl.yml +518 -0
- data/lib/vehicles/configuration.rb +4 -2
- data/lib/vehicles/model.rb +23 -6
- data/lib/vehicles/plates/jurisdiction.rb +32 -0
- data/lib/vehicles/plates/series.rb +137 -0
- data/lib/vehicles/plates.rb +110 -0
- data/lib/vehicles/providers/hosted_provider.rb +26 -8
- data/lib/vehicles/providers/local_provider.rb +4 -3
- data/lib/vehicles/version.rb +1 -1
- data/lib/vehicles.rb +21 -0
- metadata +12 -2
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vehicles
|
|
4
|
+
module Plates
|
|
5
|
+
# One registration series: a pattern identity with a period, a class
|
|
6
|
+
# (standard/taxi/diplomatic/…), categories, sourced design facts, and the
|
|
7
|
+
# two regexes of the dataset's regex policy — `regex` (recall: the
|
|
8
|
+
# pattern's own alphabet) and `regex_strict` (validation: the alphabet
|
|
9
|
+
# the authority actually issues). Validation prefers strict.
|
|
10
|
+
class Series
|
|
11
|
+
attr_reader :id, :klass, :categories, :period, :pattern, :regex,
|
|
12
|
+
:regex_strict, :design, :variants, :sources, :notes,
|
|
13
|
+
:validation_regexp, :lenient_regexp
|
|
14
|
+
|
|
15
|
+
def initialize(entry)
|
|
16
|
+
@id = entry["id"]
|
|
17
|
+
@klass = entry["class"] || "standard"
|
|
18
|
+
@categories = Array(entry["categories"]).freeze
|
|
19
|
+
@period = (entry["period"] || {}).freeze
|
|
20
|
+
@pattern = entry.dig("format", "pattern")
|
|
21
|
+
@regex = entry.dig("format", "regex")
|
|
22
|
+
@regex_strict = entry.dig("format", "regex_strict")
|
|
23
|
+
@design = (entry["design"] || {}).freeze
|
|
24
|
+
@variants = Array(entry["variants"]).freeze
|
|
25
|
+
@sources = Array(entry["sources"]).freeze
|
|
26
|
+
@notes = entry["notes"]
|
|
27
|
+
# Compiled eagerly — the object is frozen, so no lazy memoization.
|
|
28
|
+
@validation_regexp = compile(@regex_strict || @regex)
|
|
29
|
+
@lenient_regexp = compile(self.class.strip_separators(@regex_strict || @regex))
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A strict-validated series has an authority-alphabet regex twin; a
|
|
34
|
+
# recall-only series (export/dark-blue catch-alls that accept any
|
|
35
|
+
# current shape) matches loosely by design. Consumers validating user
|
|
36
|
+
# input should weight strict hits above recall-only ones — Match does.
|
|
37
|
+
def strict?
|
|
38
|
+
!regex_strict.nil?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# "2000 →" (open, runs until exhausted) or "1971–2000".
|
|
42
|
+
def period_label
|
|
43
|
+
finish = period["end"]
|
|
44
|
+
finish ? "#{period["start"]}–#{finish}" : "#{period["start"]} →"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Re-print a separator-less serial the way this series formats it:
|
|
48
|
+
# walk the pattern; 9/L and literal serial characters consume one input
|
|
49
|
+
# character, separator characters re-emerge from the pattern itself.
|
|
50
|
+
def format_serial(serial)
|
|
51
|
+
return nil if serial.nil? || pattern.nil?
|
|
52
|
+
|
|
53
|
+
idx = 0
|
|
54
|
+
out = pattern.each_char.with_object(+"") do |ch, str|
|
|
55
|
+
if [ "-", " ", "·", "." ].include?(ch)
|
|
56
|
+
str << ch
|
|
57
|
+
else
|
|
58
|
+
str << (serial[idx] || "")
|
|
59
|
+
idx += 1
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
out.freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Tokens that read as "a separator" when they make up a whole
|
|
66
|
+
# character class — `[- ]` in the ES consular series is dash-or-space,
|
|
67
|
+
# i.e. a separator spelled as a class.
|
|
68
|
+
SEPARATOR_TOKENS = [ " ", "-", "·", ".", '\s', '\-', '\.', '\ ' ].freeze
|
|
69
|
+
|
|
70
|
+
# Remove separator LITERALS from a regex source, respecting character
|
|
71
|
+
# classes (the "-" inside [A-Z] is a range, not a separator; a class
|
|
72
|
+
# composed ONLY of separators, like `[- ]`, is a separator and goes).
|
|
73
|
+
# A quantifier left dangling by a removed separator ("-?", "[- ]?") is
|
|
74
|
+
# swallowed with it. Linear scan — regexes in this dataset are simple.
|
|
75
|
+
def self.strip_separators(src)
|
|
76
|
+
return nil if src.nil?
|
|
77
|
+
|
|
78
|
+
out = +""
|
|
79
|
+
chars = src.chars
|
|
80
|
+
i = 0
|
|
81
|
+
while i < chars.size
|
|
82
|
+
ch = chars[i]
|
|
83
|
+
|
|
84
|
+
if ch == "\\" && i + 1 < chars.size
|
|
85
|
+
nxt = chars[i + 1]
|
|
86
|
+
if [ "s", "-", ".", " " ].include?(nxt)
|
|
87
|
+
i += 2
|
|
88
|
+
i += 1 if [ "?", "*" ].include?(chars[i]) # optional separator: drop its quantifier too
|
|
89
|
+
else
|
|
90
|
+
out << ch << nxt
|
|
91
|
+
i += 2
|
|
92
|
+
end
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if ch == "["
|
|
97
|
+
# Copy or drop the WHOLE class span in one decision.
|
|
98
|
+
closing = i + 1
|
|
99
|
+
closing += 1 while closing < chars.size && chars[closing] != "]"
|
|
100
|
+
content = chars[(i + 1)...closing].join
|
|
101
|
+
tokens = content.scan(/\\.|./m)
|
|
102
|
+
if tokens.any? && tokens.all? { |t| SEPARATOR_TOKENS.include?(t) }
|
|
103
|
+
i = closing + 1
|
|
104
|
+
i += 1 if [ "?", "*" ].include?(chars[i])
|
|
105
|
+
else
|
|
106
|
+
out << chars[i..closing].join
|
|
107
|
+
i = closing + 1
|
|
108
|
+
end
|
|
109
|
+
next
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
if [ "-", " ", "·" ].include?(ch)
|
|
113
|
+
i += 1
|
|
114
|
+
i += 1 if [ "?", "*" ].include?(chars[i])
|
|
115
|
+
else
|
|
116
|
+
out << ch
|
|
117
|
+
i += 1
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
out
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def inspect = %(#<Vehicles::Plates::Series #{id} "#{pattern}">)
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
# Bad regex in data must degrade like every other lookup: nil, not raise.
|
|
128
|
+
def compile(source)
|
|
129
|
+
return nil if source.nil?
|
|
130
|
+
|
|
131
|
+
Regexp.new(source)
|
|
132
|
+
rescue RegexpError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
# The matcher behind Vehicles.plate. Unknown jurisdiction => empty Match.
|
|
52
|
+
def match(input, jurisdiction:)
|
|
53
|
+
juris = jurisdiction(jurisdiction)
|
|
54
|
+
typed = input.to_s.upcase.strip
|
|
55
|
+
canon = typed.gsub(SEPARATORS, "")
|
|
56
|
+
return Match.new(input: input.to_s, jurisdiction: juris, canon: canon, hits: []) if juris.nil? || canon.empty?
|
|
57
|
+
|
|
58
|
+
hits = juris.series.filter_map do |series|
|
|
59
|
+
next nil unless series.validation_regexp
|
|
60
|
+
|
|
61
|
+
exact = series.validation_regexp.match?(typed)
|
|
62
|
+
lenient = exact || series.lenient_regexp&.match?(canon)
|
|
63
|
+
Match::Hit.new(series: series, exact: exact) if lenient
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
Match.new(input: input.to_s, jurisdiction: juris, canon: canon, hits: hits)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The answer to "is this a plate, and which series issues it?".
|
|
70
|
+
class Match
|
|
71
|
+
Hit = Struct.new(:series, :exact, keyword_init: true) do
|
|
72
|
+
def exact? = exact
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
attr_reader :input, :jurisdiction, :canon, :hits
|
|
76
|
+
|
|
77
|
+
def initialize(input:, jurisdiction:, canon:, hits:)
|
|
78
|
+
@input = input
|
|
79
|
+
@jurisdiction = jurisdiction
|
|
80
|
+
@canon = canon
|
|
81
|
+
# Exact hits outrank lenient ones, strict-validated series outrank
|
|
82
|
+
# recall-only catch-alls; series order (standard first) breaks ties.
|
|
83
|
+
@hits = hits.sort_by { |h| [ h.exact? ? 0 : 1, h.series.strict? ? 0 : 1 ] }.freeze
|
|
84
|
+
freeze
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def valid? = hits.any?
|
|
88
|
+
def exact? = hits.any?(&:exact?)
|
|
89
|
+
|
|
90
|
+
# True when at least one hit is a strict-validated series — the strong
|
|
91
|
+
# signal ("the authority issues this alphabet"), vs a catch-all shape
|
|
92
|
+
# union like an export plate. Prefer this for user-input validation.
|
|
93
|
+
def strict? = hits.any? { |h| h.series.strict? }
|
|
94
|
+
|
|
95
|
+
def series = hits.map(&:series)
|
|
96
|
+
def best = hits.first&.series
|
|
97
|
+
|
|
98
|
+
# The serial formatted the way the best-matching series prints it on
|
|
99
|
+
# the physical plate — "1234XYZ" typed, "1234 XYZ" issued.
|
|
100
|
+
def formatted
|
|
101
|
+
best&.format_serial(canon)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Only when the user's punctuation was off: the "did you mean" string.
|
|
105
|
+
def suggestion
|
|
106
|
+
formatted if valid? && !exact?
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -11,10 +11,11 @@ module Vehicles
|
|
|
11
11
|
# resolver falls back to the local data. Tracking/enrichment must never break
|
|
12
12
|
# the host app, so this 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
|
+
# The enrichment endpoints (years/segment) are still the wired-up seam —
|
|
18
|
+
# they safely return nil until the service ships them.
|
|
18
19
|
module HostedProvider
|
|
19
20
|
module_function
|
|
20
21
|
|
|
@@ -33,11 +34,28 @@ module Vehicles
|
|
|
33
34
|
fetch(model)&.dig("segment")&.to_sym
|
|
34
35
|
end
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
# One variant URL — the common "just give me an <img src>" case.
|
|
38
|
+
# `size` picks from the API's rendered variants (:sm 320×180, :md
|
|
39
|
+
# 640×360, :lg 1280×720; webp).
|
|
40
|
+
def image(model, year: nil, color: nil, size: :md)
|
|
41
|
+
images(model, color: color)&.dig("variants", size.to_s, "url")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The full images payload for a model — palette, every variant with
|
|
45
|
+
# dimensions, provenance, and the honest color fallback: `color` is what
|
|
46
|
+
# the API actually served, `requested_color` what you asked for (a color
|
|
47
|
+
# that isn't rendered yet falls back rather than 404ing).
|
|
48
|
+
#
|
|
49
|
+
# GET /v1/vehicles/:kind/:make_slug/:model_slug/images?color=<slug>
|
|
50
|
+
#
|
|
51
|
+
# `year`/`trim` filters are RESERVED server-side today (the API 422s on
|
|
52
|
+
# them by contract, so callers can't silently build on unimplemented
|
|
53
|
+
# semantics) — which is why `image` accepts `year:` but never sends it:
|
|
54
|
+
# serving the current rendering beats an error until the filter ships.
|
|
55
|
+
def images(model, color: nil)
|
|
37
56
|
params = {}
|
|
38
|
-
params[:
|
|
39
|
-
params
|
|
40
|
-
get("/v1/models/#{model.slug}/image", params)&.dig("url")
|
|
57
|
+
params[:color] = color.to_s unless color.to_s.empty?
|
|
58
|
+
get("/v1/vehicles/#{model.kind}/#{model.make_slug}/#{model.model_slug}/images", params)
|
|
41
59
|
end
|
|
42
60
|
|
|
43
61
|
# --- internals -----------------------------------------------------------
|
|
@@ -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
|
data/lib/vehicles/version.rb
CHANGED
data/lib/vehicles.rb
CHANGED
|
@@ -10,6 +10,9 @@ require_relative "vehicles/refresher"
|
|
|
10
10
|
require_relative "vehicles/providers/local_provider"
|
|
11
11
|
require_relative "vehicles/providers/hosted_provider"
|
|
12
12
|
require_relative "vehicles/other_option"
|
|
13
|
+
require_relative "vehicles/plates"
|
|
14
|
+
require_relative "vehicles/plates/jurisdiction"
|
|
15
|
+
require_relative "vehicles/plates/series"
|
|
13
16
|
|
|
14
17
|
# Car makes & models for your Rails app — dropdowns, search, validation. Bundled
|
|
15
18
|
# data, zero config, no network calls. Standalone first; an SDK for the hosted
|
|
@@ -135,6 +138,24 @@ module Vehicles
|
|
|
135
138
|
found&.model(model_name)
|
|
136
139
|
end
|
|
137
140
|
|
|
141
|
+
# License plates (the bundled PRD-PLATES dataset). No argument: every
|
|
142
|
+
# jurisdiction. With a code: that jurisdiction (or nil — forgiving).
|
|
143
|
+
# Vehicles.plates # => [#<Jurisdiction nl …>, …]
|
|
144
|
+
# Vehicles.plates(:nl) # => the Netherlands entry (33 series)
|
|
145
|
+
def plates(code = nil)
|
|
146
|
+
code.nil? ? Plates.jurisdictions : Plates.jurisdiction(code)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Validate a typed registration against a jurisdiction's real series.
|
|
150
|
+
# Two-tier: exact as-issued, else separator-forgiving with a formatting
|
|
151
|
+
# suggestion. Never raises; unknown jurisdictions yield an empty match.
|
|
152
|
+
# Vehicles.plate("12-GB-BD", jurisdiction: :nl).valid? # => true (exact)
|
|
153
|
+
# Vehicles.plate("1234XYZ", jurisdiction: :es).suggestion # => "1234 XYZ"
|
|
154
|
+
# Vehicles.plate("12-AB-CD", jurisdiction: :nl).valid? # => false (vowel purge)
|
|
155
|
+
def plate(input, jurisdiction:)
|
|
156
|
+
Plates.match(input, jurisdiction: jurisdiction)
|
|
157
|
+
end
|
|
158
|
+
|
|
138
159
|
# Every model matching a query, ranked. => [Vehicles::Model, ...]
|
|
139
160
|
def search(query)
|
|
140
161
|
dataset.search(query)
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: vehicles
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-08-01 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
12
12
|
description: vehicles ships a curated, bundled dataset of car makes and models (with
|
|
13
13
|
kind and body type) and a delightful, Rails-friendly API for vehicle dropdowns,
|
|
@@ -24,6 +24,13 @@ files:
|
|
|
24
24
|
- CHANGELOG.md
|
|
25
25
|
- LICENSE.txt
|
|
26
26
|
- README.md
|
|
27
|
+
- data/plates/PROVENANCE.md
|
|
28
|
+
- data/plates/_decode/es-provinces.yml
|
|
29
|
+
- data/plates/_meta/classes.yml
|
|
30
|
+
- data/plates/de.yml
|
|
31
|
+
- data/plates/es.yml
|
|
32
|
+
- data/plates/nl.yml
|
|
33
|
+
- data/plates/us-fl.yml
|
|
27
34
|
- data/vehicles.json
|
|
28
35
|
- exe/vehicles-mcp
|
|
29
36
|
- lib/generators/vehicles/install_generator.rb
|
|
@@ -38,6 +45,9 @@ files:
|
|
|
38
45
|
- lib/vehicles/mcp_server.rb
|
|
39
46
|
- lib/vehicles/model.rb
|
|
40
47
|
- lib/vehicles/other_option.rb
|
|
48
|
+
- lib/vehicles/plates.rb
|
|
49
|
+
- lib/vehicles/plates/jurisdiction.rb
|
|
50
|
+
- lib/vehicles/plates/series.rb
|
|
41
51
|
- lib/vehicles/providers/hosted_provider.rb
|
|
42
52
|
- lib/vehicles/providers/local_provider.rb
|
|
43
53
|
- lib/vehicles/railtie.rb
|