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
|
@@ -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
|
|
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
|
-
|
|
40
|
-
headers.each { |name, value|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
rescue
|
|
44
|
-
|
|
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
|
|
44
|
-
|
|
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 (
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
#
|
|
153
|
+
# One record among kind variants of a nameplate: Vehicles::KindRule.
|
|
144
154
|
def pick(matches)
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
197
|
-
#
|
|
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
|