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
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vehicles
4
+ # `Vehicles.dataset_slice`: one scope of the dataset (Spain's cars, say) in
5
+ # the shape of the published `dist/vehicles.json`, for clients that read
6
+ # that file: the make-model-dropdown widget's `data` option, and the JS and
7
+ # Python SDKs' dataset mode. Where `Vehicles.catalog` hands a page
8
+ # `{ make => [names] }`, this hands it the records behind the names (slugs,
9
+ # kinds, aliases, forms, former ids, availability, ranks), so the client
10
+ # resolves stored values and produces the canonical ids
11
+ # (`kind/make-slug/model-slug`) the way it would on the full release.
12
+ #
13
+ # Lean on purpose. Each model keeps what resolution, ordering and scoping
14
+ # read: name, slug, kind, aliases, forms, former_ids, availability,
15
+ # global_decile, mass_decile, country_ranks and registration_years. It drops
16
+ # `regions` (a rollup of availability) and `body_type`. With `country:`,
17
+ # `availability` and `country_ranks` keep only that country: every record
18
+ # in the slice is evidenced there, and the other countries' entries are
19
+ # most of the weight. Spain's cars (data 2026.11.3): ~360 KB of JSON, ~57 KB gzipped.
20
+ module DatasetSlice
21
+ # Model keys copied as published, in dist order. The two per-country keys
22
+ # are projected separately.
23
+ MODEL_KEYS = %w[name slug kind aliases forms former_ids global_decile mass_decile].freeze
24
+
25
+ module_function
26
+
27
+ def build(dataset, kind: nil, kinds: nil, region: nil, country: nil)
28
+ scope = KindRule.scope(kind, kinds)
29
+ code = country&.to_s&.downcase
30
+ makes = dataset.makes(kind: kind, region: region).filter_map do |make|
31
+ models = make.models(region: region).select do |m|
32
+ KindRule.allows?(scope, m) && (code.nil? || m.available_in?(code))
33
+ end
34
+ make_record(make, models, code) unless models.empty?
35
+ end
36
+ header(dataset, makes.flat_map { |m| m["kinds"] }.uniq.sort).merge("makes" => makes)
37
+ end
38
+
39
+ def header(dataset, kinds)
40
+ { "version" => dataset.version, "schema_version" => dataset.schema_version,
41
+ "kinds" => kinds, "license" => dataset.license,
42
+ "attribution" => dataset.attribution }.compact
43
+ end
44
+
45
+ def make_record(make, models, code)
46
+ record = { "name" => make.name, "slug" => make.slug,
47
+ "kinds" => models.map { |m| m.kind.to_s }.uniq.sort }
48
+ record["aliases"] = make.aliases unless make.aliases.empty?
49
+ record["former_slugs"] = make.former_slugs unless make.former_slugs.empty?
50
+ record.merge("models" => models.map { |m| model_record(m, code) })
51
+ end
52
+
53
+ def model_record(model, code)
54
+ record = MODEL_KEYS.each_with_object({}) do |key, out|
55
+ value = case key
56
+ when "slug" then model.model_slug
57
+ when "kind" then model.kind.to_s
58
+ else model.public_send(key)
59
+ end
60
+ out[key] = value unless value.nil? || (value.respond_to?(:empty?) && value.empty?)
61
+ end
62
+ record["availability"] = code ? [code] : model.availability
63
+ ranks = code ? model.country_ranks.slice(code) : model.country_ranks
64
+ record["country_ranks"] = ranks unless ranks.empty?
65
+ years = model.registration_years
66
+ record["registration_years"] = { "first" => years[:first], "last" => years[:last] } if years
67
+ record
68
+ end
69
+ end
70
+
71
+ # Extended onto Vehicles (see the bottom of lib/vehicles.rb).
72
+ module DatasetSliceExport
73
+ # The same scope as `catalog`, as records in the shape of the published
74
+ # `dist/vehicles.json` (a Hash, ready for `to_json`): what the
75
+ # make-model-dropdown widget's `data` option and the JS/Python SDKs'
76
+ # dataset mode read. Embed it in the page and the widget resolves stored
77
+ # values and canonical ids with no request to a CDN or the API.
78
+ # Vehicles.dataset_slice(kind: :car, country: :es)
79
+ # # => { "version" => "2026.11.3", "schema_version" => 2, "kinds" => ["car"],
80
+ # # "license" => "CC-BY-4.0", "attribution" => {...},
81
+ # # "makes" => [{ "name" => "SEAT", "slug" => "seat", "kinds" => ["car"],
82
+ # # "models" => [{ "name" => "Ibiza", "slug" => "ibiza", "kind" => "car", ... }] }] }
83
+ # With `country:`, each record's `availability` and `country_ranks` keep
84
+ # only that country (Spain's cars: ~360 KB of JSON, ~57 KB gzipped).
85
+ # `regions` and `body_type` are left out. See Vehicles::DatasetSlice.
86
+ def dataset_slice(kind: nil, region: nil, country: nil, kinds: nil)
87
+ DatasetSlice.build(dataset, kind: kind, kinds: kinds, region: region || configuration.region, country: country)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vehicles
4
+ # The outcome of one hosted VehiclesDB call, for when `nil` is not enough:
5
+ # WHY there is no image. `Model#image` / `#images` keep answering nil on
6
+ # any failure; `Model#images_result` answers one of these.
7
+ #
8
+ # r = Vehicles.find("seat leon").images_result(color: "red", exact_color: true)
9
+ # r.ok? # => false
10
+ # r.reason # => :payment_required
11
+ # r.error # => "insufficient_credits" (the API's own code, verbatim)
12
+ # r.status # => 402
13
+ # r.url(:md) # => nil (the variant URL when ok?)
14
+ #
15
+ # `reason` is coarse and stable (REASONS); `error` is the API's code and can
16
+ # be finer ("no_image", "unknown_color", "unsupported_filter",
17
+ # "insufficient_credits", "invalid_token", …). Client-side reasons have no
18
+ # status: :no_api_key, :timeout, :network, :invalid_response, and
19
+ # :color_not_available (`exact_color: true` and the model is not available in
20
+ # that color — the API would have served another one).
21
+ class HostedResult
22
+ REASONS = %i[ok no_api_key not_found unauthorized payment_required rate_limited invalid_request
23
+ server_error timeout network invalid_response color_not_available].freeze
24
+
25
+ # Reasons that mean "something is wrong with the integration or the
26
+ # service", reported through `config.on_hosted_error` and the logger. A
27
+ # missing image, an unavailable color or no key are normal answers.
28
+ ALERTS = %i[unauthorized payment_required rate_limited invalid_request server_error timeout network
29
+ invalid_response].freeze
30
+
31
+ attr_reader :reason, :status, :error, :payload, :path
32
+
33
+ def initialize(reason:, status: nil, error: nil, payload: nil, path: nil, cached: false)
34
+ @reason = reason
35
+ @status = status
36
+ @error = error&.to_s
37
+ @payload = payload
38
+ @path = path
39
+ @cached = cached
40
+ freeze
41
+ end
42
+
43
+ # Build from an HTTP status (and the API's JSON error body, if any).
44
+ def self.from_status(status, body_error: nil, path: nil)
45
+ reason =
46
+ case status
47
+ when 200..299 then :ok
48
+ when 401, 403 then :unauthorized
49
+ when 402 then :payment_required
50
+ when 404, 410 then :not_found
51
+ when 400, 422 then :invalid_request
52
+ when 429 then :rate_limited
53
+ when 500..599 then :server_error
54
+ else :invalid_response
55
+ end
56
+ new(reason: reason, status: status, error: body_error, path: path)
57
+ end
58
+
59
+ def ok? = reason == :ok
60
+ def cached? = @cached
61
+ def alert? = ALERTS.include?(reason)
62
+
63
+ # The variant URL for a size (:sm/:md/:lg) when ok?, else nil.
64
+ def url(size = :md)
65
+ ok? ? payload&.dig("variants", size.to_s, "url") : nil
66
+ end
67
+
68
+ def with(**changes)
69
+ self.class.new(reason: reason, status: status, error: error, payload: payload, path: path, cached: @cached,
70
+ **changes)
71
+ end
72
+
73
+ def message
74
+ parts = ["VehiclesDB", status, error || reason].compact
75
+ "#{parts.join(" ")} on #{path}"
76
+ end
77
+
78
+ # An exception object for error trackers (never raised by the gem).
79
+ def to_error
80
+ HostedError.new(message).tap { |e| e.result = self }
81
+ end
82
+
83
+ def to_h
84
+ { reason: reason, status: status, error: error, path: path, cached: cached? }
85
+ end
86
+
87
+ def inspect = %(#<Vehicles::HostedResult #{reason}#{" #{status}" if status}#{" #{error}" if error}>)
88
+ end
89
+
90
+ # Report-only exception carrying a HostedResult (HostedResult#to_error).
91
+ class HostedError < StandardError
92
+ attr_accessor :result
93
+ end
94
+ end
data/lib/vehicles/http.rb CHANGED
@@ -1,11 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vehicles
4
- # The one way this gem talks to a VehiclesDB server: a single GET, capped by
4
+ # The one way this gem talks to a VehiclesDB server: a GET capped by
5
5
  # `config.api_timeout`, never retried, never raising. Shared by the hosted
6
6
  # images provider (keyed, JSON) and the plate templates (public, SVG), so
7
7
  # the latency promise and the no-silent-retry rule live in one place.
8
8
  module HTTP
9
+ # A finished request. `status` is nil when no response arrived; `error`
10
+ # is then :timeout or :network.
11
+ Response = Struct.new(:status, :body, :error, :uri, :location, keyword_init: true) do
12
+ def success? = status.is_a?(Integer) && status.between?(200, 299)
13
+ end
14
+
15
+ # Renamed/merged ids answer a 301 to their successor (RESOLUTION.md §1),
16
+ # and a gem whose data is older or newer than the server's meets them
17
+ # around every release. One hop is the contract; two is slack.
18
+ MAX_REDIRECTS = 2
19
+ REDIRECTS = [301, 302, 303, 307, 308].freeze
20
+
9
21
  module_function
10
22
 
11
23
  # `config.api_base_url` + path + query. Plain concatenation, not
@@ -24,6 +36,22 @@ module Vehicles
24
36
  # The response body of a 2xx, or nil on ANY failure (network, timeout,
25
37
  # non-2xx). Callers treat nil as "fall back".
26
38
  def get(uri, headers = {})
39
+ response = request(uri, headers)
40
+ response.body if response.success?
41
+ end
42
+
43
+ # A Response for a GET, following up to MAX_REDIRECTS redirects that stay
44
+ # on the SAME origin (scheme, host, port) — so the Authorization header
45
+ # never travels to another host. Never raises.
46
+ def request(uri, headers = {}, redirects_left = MAX_REDIRECTS)
47
+ response = once(uri, headers)
48
+ return response unless REDIRECTS.include?(response.status) && redirects_left.positive?
49
+
50
+ target = redirect_target(uri, response)
51
+ target ? request(target, headers, redirects_left - 1) : response
52
+ end
53
+
54
+ def once(uri, headers)
27
55
  require "net/http"
28
56
 
29
57
  config = Vehicles.configuration
@@ -36,12 +64,52 @@ module Vehicles
36
64
  # served image twice, so one request means one request.
37
65
  http.max_retries = 0
38
66
 
39
- request = Net::HTTP::Get.new(uri)
40
- headers.each { |name, value| request[name] = value }
41
- response = http.request(request)
42
- response.body if response.is_a?(Net::HTTPSuccess)
43
- rescue StandardError => e
44
- warn "[vehicles] request to VehiclesDB failed (#{e.class}); falling back" if $DEBUG
67
+ req = Net::HTTP::Get.new(uri)
68
+ headers.each { |name, value| req[name] = value }
69
+ res = http.request(req)
70
+ Response.new(status: res.code.to_i, body: res.body, uri: uri, location: res["location"])
71
+ rescue Net::OpenTimeout, Net::ReadTimeout, Timeout::Error
72
+ Response.new(status: nil, body: nil, error: :timeout, uri: uri)
73
+ rescue StandardError
74
+ Response.new(status: nil, body: nil, error: :network, uri: uri)
75
+ end
76
+
77
+ # The body of a public GET for the dataset refresh (jsDelivr): any
78
+ # redirect is followed (no credentials travel), a longer timeout
79
+ # (`refresh_timeout`), nil on any non-2xx. Labelled UTF-8.
80
+ DOWNLOAD_REDIRECTS = 3
81
+
82
+ def download(url, timeout, redirects_left = DOWNLOAD_REDIRECTS)
83
+ require "net/http"
84
+ require "uri"
85
+
86
+ uri = URI(url)
87
+ http = Net::HTTP.new(uri.host, uri.port)
88
+ http.use_ssl = uri.scheme == "https"
89
+ http.open_timeout = timeout
90
+ http.read_timeout = timeout
91
+
92
+ response = http.get(uri.request_uri, "Accept" => "application/json")
93
+ case response
94
+ when Net::HTTPSuccess
95
+ # Net::HTTP hands back an ASCII-8BIT body (e.g. after gzip decompression);
96
+ # our datasets are UTF-8 JSON, so label it correctly. Otherwise downstream
97
+ # writes/parses can transcode-raise under Encoding.default_internal = UTF-8.
98
+ response.body&.dup&.force_encoding(Encoding::UTF_8)
99
+ when Net::HTTPRedirection
100
+ return nil if redirects_left <= 0
101
+
102
+ download(URI.join(url, response["location"]).to_s, timeout, redirects_left - 1)
103
+ end
104
+ end
105
+
106
+ def redirect_target(uri, response)
107
+ return nil if response.location.to_s.empty?
108
+
109
+ target = URI.join(uri.to_s, response.location)
110
+ same = target.scheme == uri.scheme && target.host == uri.host && target.port == uri.port
111
+ same ? target : nil
112
+ rescue URI::Error
45
113
  nil
46
114
  end
47
115
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vehicles
4
+ # Canonical-id lookups, extended onto Vehicles (`Vehicles.by_id`, …). An id
5
+ # is "kind/make-slug/model-slug" (Model#id, RESOLUTION.md §1): the thing to
6
+ # store. Datasets rename and merge records append-only, listing the old id
7
+ # in the successor's `former_ids`, so a stored id never dead-ends.
8
+ module IdLookup
9
+ # The Model a canonical id names, or nil. With `follow: true` (the
10
+ # default) a RETIRED id answers its successor: the record that lists it in
11
+ # `former_ids`, or — once the dataset publishes make `former_slugs` — the
12
+ # same model under the make's new slug. An id two different records
13
+ # absorbed (a split) answers nil: there is no one successor.
14
+ # Vehicles.by_id("car/volkswagen/golf") # => #<Vehicles::Model "Volkswagen Golf" hatchback>
15
+ # Vehicles.by_id("car/alfa-romeo/159sw") # => the 159 (renamed record)
16
+ # Vehicles.by_id("car/alfa-romeo/159sw", follow: false) # => nil
17
+ # Vehicles.by_id(id, kinds: [:car]) # nil unless the answer is a car
18
+ def by_id(id, follow: true, kinds: nil)
19
+ id = id.to_s.strip
20
+ found = dataset.models_by_id(id).first || (follow ? dataset.successor_of(id) : nil)
21
+ found && KindRule.allows?(KindRule.symbols(kinds), found) ? found : nil
22
+ end
23
+
24
+ # The id to store NOW for a stored id: itself when live, its successor's
25
+ # id when retired, nil when the dataset does not know it. Run it over a
26
+ # column after each refresh to migrate renamed ids.
27
+ # Vehicles.successor("car/alfa-romeo/159sw") # => "car/alfa-romeo/159"
28
+ def successor(id)
29
+ by_id(id, follow: true)&.id
30
+ end
31
+
32
+ # Does this id name a current record (not a retired one)?
33
+ def live?(id)
34
+ !dataset.models_by_id(id.to_s.strip).empty?
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vehicles
4
+ # How a kind narrows a lookup, and the ONE kind-collision rule every
5
+ # VehiclesDB surface shares (vehiclesdb RESOLUTION.md §3; lanes B/C,
6
+ # 2026-11-10).
7
+ #
8
+ # A kind collision is one nameplate (same make + model slug) published as
9
+ # several kinds — Iveco "Daily" is a van, a truck and a bus. Not ambiguity:
10
+ # when the caller gave no kind, the answer is the record with
11
+ # 1. the lowest `mass_decile` (unranked last),
12
+ # 2. then the kind order PRECEDENCE (any other kind after these, by
13
+ # name),
14
+ # 3. then the canonical id.
15
+ # Only among the candidates a rung itself selected: a hit is never widened
16
+ # to sibling kinds that did not match.
17
+ # Backtested on register counts, that picks the kind most registered in 87%
18
+ # of collision groups (the old "car wins" picked it in 43%).
19
+ #
20
+ # `kind:` (one kind) scopes the name rungs; `kinds:` (a list) is a hard
21
+ # filter everywhere, ids included (`kinds: []` allows nothing).
22
+ module KindRule
23
+ PRECEDENCE = %i[van car bus truck moped motorcycle].freeze
24
+
25
+ module_function
26
+
27
+ # `kind:`/`kinds:` → the allowed kinds as Symbols, or nil for "any". With
28
+ # both, only kinds in both count.
29
+ def scope(kind, kinds)
30
+ return nil if kind.nil? && kinds.nil?
31
+
32
+ allowed = kinds.nil? ? nil : symbols(kinds)
33
+ return allowed if kind.nil?
34
+
35
+ allowed ? allowed & symbols(kind) : symbols(kind)
36
+ end
37
+
38
+ # nil (any), one kind or a list → [Symbol] or nil.
39
+ def symbols(kind)
40
+ kind.nil? ? nil : Array(kind).map { |k| k.to_s.to_sym }
41
+ end
42
+
43
+ def allows?(scope, model)
44
+ scope.nil? || scope.include?(model.kind)
45
+ end
46
+
47
+ # One record among kind variants of a nameplate (see above).
48
+ def pick(models)
49
+ models.min_by do |m|
50
+ rank = PRECEDENCE.index(m.kind)
51
+ [m.mass_decile || 99, rank ? 0 : 1, rank || 0, rank ? "" : m.kind.to_s, m.id]
52
+ end
53
+ end
54
+
55
+ # The `via` steps RESOLUTION.md names: `kind_hint:van`, `kinds:car,van`.
56
+ def via(kind, kinds)
57
+ steps = []
58
+ steps << "kind_hint:#{kind}" if kind
59
+ steps << "kinds:#{Array(kinds).join(",")}" if kinds
60
+ steps
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vehicles
4
+ # Display labels for the gem's closed vocabularies — colors, kinds, body
5
+ # types — in English and Spanish, so an app does not hand-maintain the same
6
+ # tables. Model and make NAMES are never translated: they are proper nouns
7
+ # (real-world spellings such as "Clase A" arrive as curated `forms`).
8
+ #
9
+ # Vehicles.color_options(locale: :es) # => [["Blanco", "white"], ...]
10
+ # Vehicles.color("rojo") # => #<Vehicles::Color red ...>
11
+ # Vehicles.kind_label(:van, locale: :es) # => "Furgoneta"
12
+ # Vehicles.kind_options(locale: :es) # => [["Autobús", "bus"], ["Coche", "car"], ...]
13
+ # Vehicles.body_type_label(:wagon, locale: :es) # => "Familiar"
14
+ #
15
+ # Locales: :en, :es ("es-MX", :es_ES … fold to :es). Anything else falls
16
+ # back to English, never raises. You store the slug; the label is display.
17
+ module Labels
18
+ LOCALES = %i[en es].freeze
19
+
20
+ COLORS = {
21
+ en: { "white" => "White", "black" => "Black", "grey" => "Grey", "silver" => "Silver", "blue" => "Blue",
22
+ "red" => "Red", "green" => "Green", "brown" => "Brown", "beige" => "Beige", "orange" => "Orange",
23
+ "yellow" => "Yellow", "gold" => "Gold", "purple" => "Purple", "other" => "Other" },
24
+ es: { "white" => "Blanco", "black" => "Negro", "grey" => "Gris", "silver" => "Plata", "blue" => "Azul",
25
+ "red" => "Rojo", "green" => "Verde", "brown" => "Marrón", "beige" => "Beige", "orange" => "Naranja",
26
+ "yellow" => "Amarillo", "gold" => "Dorado", "purple" => "Morado", "other" => "Otro" }
27
+ }.freeze
28
+
29
+ # What people type for a color besides its label, per locale (the
30
+ # cross-surface list, DECISIONS D2 — the SDKs carry the same one).
31
+ # Matched after Vehicles.normalize, so accents never matter ("marrón").
32
+ COLOR_SYNONYMS = {
33
+ en: { "gray" => "grey", "silvery" => "silver", "tan" => "beige", "cream" => "beige",
34
+ "burgundy" => "red", "navy" => "blue" },
35
+ es: { "blanca" => "white", "negra" => "black", "plateado" => "silver", "plateada" => "silver",
36
+ "gris" => "grey", "dorada" => "gold", "amarilla" => "yellow", "morada" => "purple",
37
+ "violeta" => "purple", "granate" => "red", "azul marino" => "blue", "crema" => "beige",
38
+ "otra" => "other" }
39
+ }.freeze
40
+
41
+ KINDS = {
42
+ en: { "car" => "Car", "motorcycle" => "Motorcycle", "moped" => "Moped", "van" => "Van",
43
+ "truck" => "Truck", "bus" => "Bus", "pickup" => "Pickup", "trailer" => "Trailer",
44
+ "quad" => "Quad", "trike" => "Trike" },
45
+ es: { "car" => "Coche", "motorcycle" => "Moto", "moped" => "Ciclomotor", "van" => "Furgoneta",
46
+ "truck" => "Camión", "bus" => "Autobús", "pickup" => "Pick-up", "trailer" => "Remolque",
47
+ "quad" => "Quad", "trike" => "Triciclo" }
48
+ }.freeze
49
+
50
+ BODY_TYPES = {
51
+ en: { "hatchback" => "Hatchback", "sedan" => "Sedan", "wagon" => "Wagon", "suv" => "SUV",
52
+ "mpv" => "MPV", "coupe" => "Coupe", "convertible" => "Convertible", "roadster" => "Roadster",
53
+ "pickup" => "Pickup", "van" => "Van" },
54
+ es: { "hatchback" => "Compacto", "sedan" => "Berlina", "wagon" => "Familiar", "suv" => "SUV",
55
+ "mpv" => "Monovolumen", "coupe" => "Cupé", "convertible" => "Descapotable",
56
+ "roadster" => "Roadster", "pickup" => "Pick-up", "van" => "Furgoneta" }
57
+ }.freeze
58
+
59
+ module_function
60
+
61
+ # :en/:es from anything ("es-MX", :es_ES, "ES"); English otherwise.
62
+ def locale(value)
63
+ base = value.to_s.downcase[/\A[a-z]+/]&.to_sym
64
+ LOCALES.include?(base) ? base : :en
65
+ end
66
+
67
+ def color(slug, locale = :en) = lookup(COLORS, slug, locale)
68
+ def kind(kind, locale = :en) = lookup(KINDS, kind, locale)
69
+ def body_type(type, locale = :en) = lookup(BODY_TYPES, type, locale)
70
+
71
+ # The table entry in that locale, else English, else nil.
72
+ def lookup(table, key, locale)
73
+ key = key.to_s
74
+ table[locale(locale)][key] || table[:en][key]
75
+ end
76
+ end
77
+
78
+ # The palette and label helpers extended onto Vehicles
79
+ # (`Vehicles.colors`, `Vehicles.color`, `Vehicles.kind_label`, …).
80
+ module LabelOptions
81
+ # The canonical color palette, frequency-ordered. => [Vehicles::Color, ...]
82
+ # With `locale:`, each Color's `name` is the label in that locale.
83
+ def colors(locale: nil)
84
+ return Colors::ALL if locale.nil? || Labels.locale(locale) == :en
85
+
86
+ Colors::ALL.map { |c| Color.new(c.slug, c.label(locale), c.hex) }
87
+ end
88
+
89
+ # [[label, slug], ...] for a Rails `select`, in palette order. The slug
90
+ # is the stable value you store; the label is in `locale` (:en default,
91
+ # :es; anything else falls back to English).
92
+ # Vehicles.color_options(locale: :es) # => [["Blanco", "white"], ["Negro", "black"], ...]
93
+ def color_options(locale: :en)
94
+ Colors::ALL.map { |c| [c.label(locale), c.slug] }
95
+ end
96
+
97
+ # Resolve a color by slug, or by name in any supported language
98
+ # (forgiving: case, diacritics, synonyms — "gray"→grey, "navy"→blue,
99
+ # "Rojo"→red, "marrón"→brown, "plateada"→silver). A Vehicles::Color, or nil.
100
+ # `locale:` is accepted for parity with the SDKs; every locale's names are read anyway.
101
+ def color(query, locale: nil)
102
+ q = normalize(query)
103
+ return nil if q.empty?
104
+
105
+ Colors::BY_SLUG[q] || Colors::LOOKUP[q]
106
+ end
107
+
108
+ # "Furgoneta" for (:van, locale: :es); English for unknown locales; nil
109
+ # for an unknown kind.
110
+ def kind_label(kind, locale: :en)
111
+ Labels.kind(kind, locale)
112
+ end
113
+
114
+ # [[label, kind], ...] for a kind <select>, sorted by label. Defaults to
115
+ # the kinds the dataset publishes.
116
+ # Vehicles.kind_options(locale: :es) # => [["Autobús", "bus"], ["Camión", "truck"], ...]
117
+ def kind_options(locale: :en, kinds: self.kinds)
118
+ kinds.map { |k| [kind_label(k, locale: locale) || k.to_s, k.to_s] }.sort_by { |label, _| normalize(label) }
119
+ end
120
+
121
+ # "Familiar" for (:wagon, locale: :es); nil for an unknown body type.
122
+ def body_type_label(body_type, locale: :en)
123
+ Labels.body_type(body_type, locale)
124
+ end
125
+
126
+ # [[label, body_type], ...] in Model::BODY_TYPES order.
127
+ def body_type_options(locale: :en)
128
+ Model::BODY_TYPES.map { |t| [body_type_label(t, locale: locale), t.to_s] }
129
+ end
130
+ end
131
+ end
data/lib/vehicles/make.rb CHANGED
@@ -17,13 +17,17 @@ module Vehicles
17
17
  # (normalized key => models in dataset order); see #build_lookup_index.
18
18
  TABLES = %i[name name_squeezed slug alias alias_squeezed alias_raw form form_squeezed].freeze
19
19
 
20
- attr_reader :name, :slug, :aliases, :kinds
20
+ attr_reader :name, :slug, :aliases, :kinds, :former_slugs
21
21
 
22
22
  def initialize(attrs)
23
23
  @name = attrs["name"]
24
24
  @slug = attrs["slug"]
25
25
  @aliases = Array(attrs["aliases"]).freeze
26
26
  @kinds = Array(attrs["kinds"]).map(&:to_sym).freeze
27
+ # Slugs this make was published under before a rename or merge
28
+ # (dataset key `former_slugs`, when the dataset publishes it; [] until
29
+ # then). Vehicles.make and Vehicles.by_id follow them.
30
+ @former_slugs = Array(attrs["former_slugs"]).map(&:to_s).freeze
27
31
  @raw_models = attrs["models"] || []
28
32
  end
29
33
 
@@ -35,14 +39,13 @@ module Vehicles
35
39
  # models(kind: :car, region: :eu) # European cars only
36
40
  # models(rarity: :common) # just the well-known ones
37
41
  # models(max_decile: 3) # top-30% by popularity
38
- def models(kind: nil, body_type: nil, region: nil, rarity: nil, max_decile: nil)
42
+ def models(kind: nil, body_type: nil, region: nil, rarity: nil, max_decile: nil, country: nil)
39
43
  list = (@models ||= @raw_models.map { |m| Model.new(m, make: name, make_slug: slug) }.freeze)
40
44
  list = list.select { |m| m.kind == kind.to_sym } if kind
41
45
  list = list.select { |m| m.body_type == body_type.to_sym } if body_type
42
46
  list = list.select { |m| m.available_in_region?(region) } if region
43
- list = list.select { |m| m.rarity == rarity.to_sym } if rarity
44
- list = list.select { |m| m.global_decile && m.global_decile <= max_decile } if max_decile
45
- list
47
+ list = by_popularity(list, rarity, max_decile)
48
+ country ? by_country(list, country) : list
46
49
  end
47
50
 
48
51
  # Continents this make is evidenced in (union of its models' regions).
@@ -56,6 +59,11 @@ module Vehicles
56
59
  continents.include?(region.to_sym)
57
60
  end
58
61
 
62
+ # Does any of its models (of `kind`) have evidence in this country?
63
+ def available_in?(country, kind: nil)
64
+ models(kind: kind).any? { |m| m.available_in?(country) }
65
+ end
66
+
59
67
  # Model display names — what you drop into a dropdown. => ["A3", "A4", ...]
60
68
  def model_names(**filters)
61
69
  models(**filters).map(&:name)
@@ -73,13 +81,15 @@ module Vehicles
73
81
  # vehicle_model validator relies on this). Fuzzy/partial matching lives in
74
82
  # `Vehicles.search`.
75
83
  #
76
- # Some names exist as several kinds (Toyota "Land Cruiser" is a car, a
77
- # van, a truck and a bus). Without `kind:` the car wins — it's what people
78
- # mean — and pass `kind:` to pick another:
79
- # toyota.model("Land Cruiser") # => the car
80
- # toyota.model("Land Cruiser", kind: :bus) # => the bus
81
- def model(query, kind: nil)
82
- matches, how = strict_lookup(Vehicles.normalize(query), kind: kind)
84
+ # Some names exist as several kinds (Iveco "Daily" is a van, a truck and
85
+ # a bus). Without a kind the one collision rule decides (Vehicles::KindRule;
86
+ # until 0.9.1 the car always won). Pass `kind:` to pick
87
+ # one, or `kinds:` to allow only some:
88
+ # iveco.model("Daily") # => the van
89
+ # iveco.model("Daily", kind: :bus) # => the bus
90
+ # iveco.model("Daily", kinds: %i[car]) # => nil (no car Daily)
91
+ def model(query, kind: nil, kinds: nil)
92
+ matches, how = strict_lookup(Vehicles.normalize(query), kind: KindRule.scope(kind, kinds))
83
93
  matches && how != :ambiguous ? pick(matches) : nil
84
94
  end
85
95
 
@@ -140,9 +150,9 @@ module Vehicles
140
150
  idx[:name].key?(key) || idx[:alias].key?(key) || idx[:slug].key?(key)
141
151
  end
142
152
 
143
- # Car first, like #model: it is what people mean by a bare name.
153
+ # One record among kind variants of a nameplate: Vehicles::KindRule.
144
154
  def pick(matches)
145
- matches.find(&:car?) || matches.first
155
+ KindRule.pick(matches)
146
156
  end
147
157
 
148
158
  def to_h
@@ -183,19 +193,35 @@ module Vehicles
183
193
 
184
194
  loose = of_kind(idx[:name_squeezed][query.delete(" ")], kind)
185
195
  return [nil, nil] if loose.empty?
186
- return [loose, :spacing] if loose.map { |m| idx[:normalized][m] }.uniq.one?
196
+ # One NAMEPLATE (model slug) in several kinds is a kind collision, not
197
+ # ambiguity, even when a release styles the kinds' names differently.
198
+ return [loose, :spacing] if loose.map(&:model_slug).uniq.one?
187
199
 
188
200
  [loose, :ambiguous]
189
201
  end
190
202
 
203
+ # `kind` is nil (any), one kind, or a list of allowed kinds.
191
204
  def of_kind(list, kind)
192
205
  list ||= []
193
- kind ? list.select { |m| m.kind == kind.to_sym } : list
206
+ allowed = KindRule.symbols(kind)
207
+ allowed ? list.select { |m| allowed.include?(m.kind) } : list
208
+ end
209
+
210
+ def by_popularity(list, rarity, max_decile)
211
+ list = list.select { |m| m.rarity == rarity.to_sym } if rarity
212
+ list = list.select { |m| m.global_decile && m.global_decile <= max_decile } if max_decile
213
+ list
214
+ end
215
+
216
+ # A country's own ranking first (Model#rank_in), then the make's order.
217
+ def by_country(list, country)
218
+ c = country.to_s.downcase
219
+ list.select { |m| m.availability.include?(c) }
220
+ .each_with_index.sort_by { |m, i| [m.rank_in(c) ? 0 : 1, m.rank_in(c) || 0, i] }.map(&:first)
194
221
  end
195
222
 
196
- # Union of two index hits, deduplicated by IDENTITY (Model#== compares
197
- # slugs, so a car and a van of the same nameplate are "equal") and put
198
- # back in dataset order.
223
+ # Union of two index hits, deduplicated by identity and put back in
224
+ # dataset order.
199
225
  def in_order(first, second)
200
226
  return first || [] unless second
201
227
  return second unless first