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
|
@@ -6,16 +6,21 @@
|
|
|
6
6
|
# dataset, so it works with zero configuration. Uncomment what you need.
|
|
7
7
|
|
|
8
8
|
Vehicles.configure do |config|
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
9
|
+
# Optional default CONTINENT filter (:eu/:na/:as/:sa/:oc/:af). The bundled
|
|
10
|
+
# data is global; nil (the default) means every market. For one COUNTRY,
|
|
11
|
+
# pass `country:` where you build pickers: Vehicles.catalog(kind: :car, country: :es).
|
|
12
12
|
# config.region = :eu
|
|
13
13
|
|
|
14
|
-
# Optional: a VehiclesDB API key unlocks
|
|
15
|
-
# model
|
|
16
|
-
#
|
|
17
|
-
# works on the bundled data; those richer fields just return nil.
|
|
14
|
+
# Optional: a VehiclesDB API key unlocks studio model images
|
|
15
|
+
# (`model.image(color: "red")`) on the same Vehicles::Model objects you already
|
|
16
|
+
# use. Without a key, everything still works on the bundled data; images are nil.
|
|
18
17
|
# config.api_key = ENV["VEHICLESDB_API_KEY"]
|
|
18
|
+
#
|
|
19
|
+
# Image payloads are cached (in-process by default); share them across
|
|
20
|
+
# processes with Rails.cache. Hosted failures (bad key, out of credits, rate
|
|
21
|
+
# limited, timeouts) are never raised: report them here.
|
|
22
|
+
# config.image_cache = Rails.cache
|
|
23
|
+
# config.on_hosted_error = ->(result) { Rails.error.report(result.to_error, handled: true) }
|
|
19
24
|
|
|
20
25
|
# Optional: your own make aliases, merged over the built-ins. Matched
|
|
21
26
|
# forgivingly (case/diacritics-insensitive).
|
|
@@ -30,11 +35,15 @@ Vehicles.configure do |config|
|
|
|
30
35
|
|
|
31
36
|
# --- Data refresh (optional) ----------------------------------------------
|
|
32
37
|
# The gem works offline with its bundled snapshot. To get data fixes and new
|
|
33
|
-
# makes WITHOUT upgrading the gem, schedule VehiclesRefreshJob (see app/jobs)
|
|
34
|
-
#
|
|
35
|
-
#
|
|
38
|
+
# makes WITHOUT upgrading the gem, schedule VehiclesRefreshJob (see app/jobs).
|
|
39
|
+
# It writes a checked release to `cache_path`; every process serves it once
|
|
40
|
+
# it is newer than the bundled data, re-checking the file every few seconds.
|
|
41
|
+
#
|
|
42
|
+
# Web and jobs in separate containers (Kamal, Docker)? Point both at ONE
|
|
43
|
+
# shared volume so a refresh run by the job container reaches the web one:
|
|
44
|
+
# config.cache_dir = Rails.root.join("storage", "vehicles") # or ENV VEHICLES_CACHE_DIR
|
|
36
45
|
#
|
|
37
|
-
# config.data_url = "https://cdn.jsdelivr.net/gh/vehiclesdb/vehiclesdb@latest/dist/vehicles.json"
|
|
38
|
-
# config.cache_path = Rails.root.join("tmp", "cache", "vehicles", "vehicles.json")
|
|
39
46
|
# config.use_cache = true # set false to always use the bundled snapshot (fully offline/deterministic)
|
|
47
|
+
# config.refresh_min_ratio = 0.9 # refuse a release that shrinks a kind or a country below 90%
|
|
48
|
+
# config.refresh_guard = ->(new_data, current) { true } # your own last check (Vehicles::Dataset objects)
|
|
40
49
|
end
|
|
@@ -8,13 +8,17 @@
|
|
|
8
8
|
# class: VehiclesRefreshJob
|
|
9
9
|
# schedule: every day at 3am
|
|
10
10
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
11
|
+
# Safe to run anytime and from ONE place: it reads the release manifest first
|
|
12
|
+
# (nothing is downloaded when you are current), never downgrades, never writes
|
|
13
|
+
# a release your gem version cannot read or one that lost a chunk of your data,
|
|
14
|
+
# and never raises. Every process (web workers, other containers sharing
|
|
15
|
+
# `config.cache_dir`) picks the new file up on its own within a few seconds.
|
|
14
16
|
class VehiclesRefreshJob < ApplicationJob
|
|
15
17
|
queue_as :default
|
|
16
18
|
|
|
17
19
|
def perform
|
|
18
|
-
Vehicles.refresh!
|
|
20
|
+
# Vehicles.refresh! (true/false) works too; the result says why.
|
|
21
|
+
result = Vehicles.refresh
|
|
22
|
+
Rails.logger.warn("[vehicles] #{result.message}") unless result.ok?
|
|
19
23
|
end
|
|
20
24
|
end
|
data/lib/vehicles/color.rb
CHANGED
|
@@ -5,7 +5,8 @@ module Vehicles
|
|
|
5
5
|
# palette every consumer (and, in time, the hosted VehiclesDB image API) can
|
|
6
6
|
# share, so "red" means the same thing — and maps to the same image variant —
|
|
7
7
|
# everywhere. Display names are English; localize in your app (the slug is the
|
|
8
|
-
# stable, locale-independent key you store
|
|
8
|
+
# stable, locale-independent key you store; `label(:es)` gives the Spanish
|
|
9
|
+
# one). `hex` is a representative swatch
|
|
9
10
|
# for UI chips / future color-accurate imagery.
|
|
10
11
|
class Color
|
|
11
12
|
attr_reader :slug, :name, :hex
|
|
@@ -17,6 +18,12 @@ module Vehicles
|
|
|
17
18
|
freeze
|
|
18
19
|
end
|
|
19
20
|
|
|
21
|
+
# The display label in a locale (:en, :es; others fall back to English).
|
|
22
|
+
# Vehicles.color("red").label(:es) # => "Rojo"
|
|
23
|
+
def label(locale = :en)
|
|
24
|
+
Labels.color(slug, locale) || name
|
|
25
|
+
end
|
|
26
|
+
|
|
20
27
|
def to_h
|
|
21
28
|
{ slug: slug, name: name, hex: hex }
|
|
22
29
|
end
|
data/lib/vehicles/colors.rb
CHANGED
|
@@ -24,18 +24,22 @@ module Vehicles
|
|
|
24
24
|
Color.new("other", "Other", "#9AA0A6")
|
|
25
25
|
].freeze
|
|
26
26
|
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
SYNONYMS =
|
|
30
|
-
"gray" => "grey",
|
|
31
|
-
"silvery" => "silver",
|
|
32
|
-
"tan" => "beige",
|
|
33
|
-
"cream" => "beige",
|
|
34
|
-
"burgundy" => "red",
|
|
35
|
-
"navy" => "blue"
|
|
36
|
-
}.freeze
|
|
27
|
+
# English spellings/synonyms (normalized keys → canonical slug). Every
|
|
28
|
+
# locale's synonyms live in Labels::COLOR_SYNONYMS.
|
|
29
|
+
SYNONYMS = Labels::COLOR_SYNONYMS[:en]
|
|
37
30
|
|
|
38
31
|
BY_SLUG = ALL.to_h { |c| [c.slug, c] }.freeze
|
|
39
32
|
BY_NAME = ALL.to_h { |c| [Vehicles.normalize(c.name), c] }.freeze
|
|
33
|
+
|
|
34
|
+
# Every normalized label and synonym in every locale → Color, so "Rojo",
|
|
35
|
+
# "gris", "marrón", "plateada" and "navy" all resolve (Vehicles.color).
|
|
36
|
+
LOOKUP = begin
|
|
37
|
+
table = {}
|
|
38
|
+
Labels::LOCALES.each do |locale|
|
|
39
|
+
Labels::COLORS[locale].each { |slug, label| table[Vehicles.normalize(label)] ||= BY_SLUG[slug] }
|
|
40
|
+
Labels::COLOR_SYNONYMS[locale].each { |key, slug| table[Vehicles.normalize(key)] ||= BY_SLUG[slug] }
|
|
41
|
+
end
|
|
42
|
+
table.freeze
|
|
43
|
+
end
|
|
40
44
|
end
|
|
41
45
|
end
|
|
@@ -38,7 +38,7 @@ module Vehicles
|
|
|
38
38
|
# carry nothing personal, so a long TTL is safe; the server re-renders
|
|
39
39
|
# them only when VehiclesDB's plate art changes.
|
|
40
40
|
attr_writer :plate_template_cache
|
|
41
|
-
attr_accessor :plate_template_ttl
|
|
41
|
+
attr_accessor :plate_template_ttl, :image_cache_ttl, :image_miss_ttl
|
|
42
42
|
|
|
43
43
|
def plate_template_cache
|
|
44
44
|
@plate_template_cache ||= Plates::TemplateCache.new
|
|
@@ -64,10 +64,36 @@ module Vehicles
|
|
|
64
64
|
# VehiclesDB data repo via jsDelivr's CDN (always-latest release tag).
|
|
65
65
|
attr_accessor :data_url
|
|
66
66
|
|
|
67
|
+
# The release manifest `refresh` reads FIRST (version, schema, per-kind
|
|
68
|
+
# counts, checksum when published): a few KB that decide whether the
|
|
69
|
+
# 8 MB payload is worth downloading at all. Derived from `data_url`
|
|
70
|
+
# (".../dist/vehicles.json" → ".../manifest.json") until you set it; set
|
|
71
|
+
# nil for a self-hosted payload that has no manifest.
|
|
72
|
+
attr_writer :manifest_url
|
|
73
|
+
|
|
74
|
+
def manifest_url
|
|
75
|
+
return @manifest_url unless @manifest_url == :derived
|
|
76
|
+
|
|
77
|
+
url = data_url.to_s
|
|
78
|
+
url.match?(%r{/dist/[^/]+\.json\z}) ? url.sub(%r{/dist/[^/]+\.json\z}, "/manifest.json") : nil
|
|
79
|
+
end
|
|
80
|
+
|
|
67
81
|
# Where a refreshed dataset is cached on disk. Defaults to the app's cache dir
|
|
68
|
-
# (Rails) or the system temp dir
|
|
82
|
+
# (Rails) or the system temp dir — or to $VEHICLES_CACHE_DIR when set. The
|
|
83
|
+
# refresh writes here; every process serves it when it is usable (see
|
|
84
|
+
# Vehicles::DataSource). Several containers (web + jobs) share ONE refresh
|
|
85
|
+
# by pointing this at a shared volume: `config.cache_dir = "/rails/storage/vehicles"`.
|
|
69
86
|
attr_accessor :cache_path
|
|
70
87
|
|
|
88
|
+
# Directory form of `cache_path` (the file is <dir>/vehicles.json).
|
|
89
|
+
def cache_dir=(dir)
|
|
90
|
+
@cache_path = File.join(dir.to_s, "vehicles.json")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def cache_dir
|
|
94
|
+
File.dirname(cache_path.to_s)
|
|
95
|
+
end
|
|
96
|
+
|
|
71
97
|
# Prefer a refreshed (cached) dataset over the bundled one when present.
|
|
72
98
|
# Set false to always use the bundled snapshot (fully offline/deterministic).
|
|
73
99
|
attr_accessor :use_cache
|
|
@@ -75,6 +101,48 @@ module Vehicles
|
|
|
75
101
|
# Network timeout (seconds) for a refresh download.
|
|
76
102
|
attr_accessor :refresh_timeout
|
|
77
103
|
|
|
104
|
+
# How often (seconds) a process checks whether the dataset file changed
|
|
105
|
+
# (one File.stat), so a refresh made by ANY process or container reaches
|
|
106
|
+
# it without a restart. nil = never (only `Vehicles.reload!`).
|
|
107
|
+
attr_accessor :reload_check_interval
|
|
108
|
+
|
|
109
|
+
# A release is refused when any kind, or any (kind, country) slice with at
|
|
110
|
+
# least 25 models, would shrink below this share of the data in use.
|
|
111
|
+
# nil disables the check.
|
|
112
|
+
attr_accessor :refresh_min_ratio
|
|
113
|
+
|
|
114
|
+
# Optional last word on a release: ->(candidate, current) { … } with two
|
|
115
|
+
# Vehicles::Dataset objects; falsy refuses it. Runs after every built-in
|
|
116
|
+
# check, e.g. "Spain's cars must not shrink":
|
|
117
|
+
# config.refresh_guard = ->(new, old) {
|
|
118
|
+
# new.top_models(kind: :car, country: :es, limit: nil).size >=
|
|
119
|
+
# old.top_models(kind: :car, country: :es, limit: nil).size
|
|
120
|
+
# }
|
|
121
|
+
attr_accessor :refresh_guard
|
|
122
|
+
|
|
123
|
+
# --- hosted API (optional) -----------------------------------------------
|
|
124
|
+
|
|
125
|
+
# Called with a Vehicles::HostedResult whenever a hosted call fails
|
|
126
|
+
# (401/402/403/429, 5xx, timeout, network…; not for a plain "no image"
|
|
127
|
+
# 404). Use it to report to your error tracker. Never allowed to raise
|
|
128
|
+
# into your page: its own errors are swallowed.
|
|
129
|
+
# config.on_hosted_error = ->(r) { Rails.error.report(r.to_error, handled: true) }
|
|
130
|
+
attr_accessor :on_hosted_error
|
|
131
|
+
|
|
132
|
+
# Image payloads are cached so a page view does not pay per image: any
|
|
133
|
+
# store with read(key) / write(key, value, expires_in:) (Rails.cache
|
|
134
|
+
# shares it across processes). In-process by default; false disables.
|
|
135
|
+
# A hit is kept `image_cache_ttl` seconds (capped by the payload's
|
|
136
|
+
# `expires_at` when the API sends one), a definitive miss (404)
|
|
137
|
+
# `image_miss_ttl`. Errors (401/402/429/timeouts) are never cached.
|
|
138
|
+
attr_writer :image_cache
|
|
139
|
+
|
|
140
|
+
def image_cache
|
|
141
|
+
return nil if @image_cache == false
|
|
142
|
+
|
|
143
|
+
@image_cache ||= Plates::TemplateCache.new
|
|
144
|
+
end
|
|
145
|
+
|
|
78
146
|
def initialize
|
|
79
147
|
@region = nil # no continent filter by default (global dataset)
|
|
80
148
|
@api_key = nil
|
|
@@ -86,6 +154,13 @@ module Vehicles
|
|
|
86
154
|
@cache_path = default_cache_path
|
|
87
155
|
@use_cache = true
|
|
88
156
|
@refresh_timeout = 5
|
|
157
|
+
@manifest_url = :derived
|
|
158
|
+
@reload_check_interval = 5
|
|
159
|
+
@refresh_min_ratio = 0.9
|
|
160
|
+
@refresh_guard = nil
|
|
161
|
+
@on_hosted_error = nil
|
|
162
|
+
@image_cache_ttl = 86_400 # a day; variant URLs live ~30 days
|
|
163
|
+
@image_miss_ttl = 600
|
|
89
164
|
@plate_template_ttl = 86_400 # a day, the server's own shared-cache lifetime
|
|
90
165
|
end
|
|
91
166
|
|
|
@@ -98,9 +173,13 @@ module Vehicles
|
|
|
98
173
|
|
|
99
174
|
private
|
|
100
175
|
|
|
101
|
-
#
|
|
176
|
+
# $VEHICLES_CACHE_DIR when set (a shared volume), else tmp/cache/vehicles
|
|
177
|
+
# under a Rails app, else a stable spot in the system temp
|
|
102
178
|
# dir. Refreshable data, so a cache-style location is appropriate.
|
|
103
179
|
def default_cache_path
|
|
180
|
+
shared = ENV["VEHICLES_CACHE_DIR"].to_s
|
|
181
|
+
return File.join(shared, "vehicles.json") unless shared.empty?
|
|
182
|
+
|
|
104
183
|
base =
|
|
105
184
|
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
106
185
|
Rails.root.join("tmp", "cache", "vehicles")
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Vehicles
|
|
6
|
+
# Which dataset file this process serves, and when it switches to another.
|
|
7
|
+
# Behind Vehicles.dataset; not called directly.
|
|
8
|
+
#
|
|
9
|
+
# The rules (0.9.2):
|
|
10
|
+
# - an explicit `Vehicles.data_path=` always wins;
|
|
11
|
+
# - otherwise a refreshed cache (`config.cache_path`) is served only when
|
|
12
|
+
# it is USABLE: a schema this gem reads, and not older than the data
|
|
13
|
+
# bundled in the gem. A cache an older gem left behind loses to a newer
|
|
14
|
+
# gem's bundled data instead of silently downgrading it;
|
|
15
|
+
# - every process notices a new file: at most every
|
|
16
|
+
# `config.reload_check_interval` seconds (default 5) a call stats the
|
|
17
|
+
# file in effect and the cache, and reloads when either changed. So a
|
|
18
|
+
# refresh run by a job, another worker or another container (cache on a
|
|
19
|
+
# shared volume) reaches every web worker without a restart or a hook;
|
|
20
|
+
# - a file that cannot be read or parsed never replaces the dataset in
|
|
21
|
+
# memory: the process keeps serving what it has.
|
|
22
|
+
module DataSource
|
|
23
|
+
class Error < StandardError; end
|
|
24
|
+
|
|
25
|
+
# Dataset `schema_version`s this gem reads. A payload or cache with any
|
|
26
|
+
# other schema is never loaded or promoted.
|
|
27
|
+
SCHEMA_VERSIONS = [2].freeze
|
|
28
|
+
|
|
29
|
+
# How much of a file's start holds "version"/"schema_version" (both open
|
|
30
|
+
# every dist/vehicles.json), so checking a file never parses 8 MB.
|
|
31
|
+
HEADER_BYTES = 4096
|
|
32
|
+
|
|
33
|
+
Snapshot = Struct.new(:path, :stamp, :dataset, :checked_at)
|
|
34
|
+
|
|
35
|
+
LOCK = Mutex.new
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# The Dataset to serve now. Lock-free on the hot path; the stat check and
|
|
40
|
+
# any reload happen under LOCK, once, whoever gets there first.
|
|
41
|
+
def dataset
|
|
42
|
+
snap = @snapshot
|
|
43
|
+
return snap.dataset if snap && !due?(snap)
|
|
44
|
+
|
|
45
|
+
LOCK.synchronize { sync }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def reset!
|
|
49
|
+
LOCK.synchronize do
|
|
50
|
+
@snapshot = nil
|
|
51
|
+
@headers = nil
|
|
52
|
+
@unloadable = nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The file Vehicles.dataset loads (see the rules above).
|
|
57
|
+
def active_path
|
|
58
|
+
return Vehicles.data_path_override if Vehicles.data_path_override
|
|
59
|
+
|
|
60
|
+
cache = Vehicles.configuration.cache_path.to_s
|
|
61
|
+
return cache if Vehicles.configuration.use_cache && usable_cache?(cache)
|
|
62
|
+
|
|
63
|
+
Vehicles::DATA_PATH
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A cache file the gem may serve: present, a supported schema, a valid
|
|
67
|
+
# version, and not older than the bundled data.
|
|
68
|
+
def usable_cache?(path)
|
|
69
|
+
return false if path.empty? || !File.file?(path)
|
|
70
|
+
|
|
71
|
+
head = header(path)
|
|
72
|
+
bundled = header(Vehicles::DATA_PATH)
|
|
73
|
+
compatible?(head) && head[:version] >= bundled[:version]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# { version: Gem::Version or nil, schema_version: Integer or nil } of a
|
|
77
|
+
# dataset file, memoized per file stamp (a changed file is re-read).
|
|
78
|
+
def header(path)
|
|
79
|
+
stamp = stamp(path)
|
|
80
|
+
cached = (@headers ||= {})[path]
|
|
81
|
+
return cached[1] if cached && cached[0] == stamp
|
|
82
|
+
|
|
83
|
+
parsed = read_header(path)
|
|
84
|
+
@headers[path] = [stamp, parsed]
|
|
85
|
+
parsed
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def compatible?(head)
|
|
89
|
+
SCHEMA_VERSIONS.include?(head[:schema_version]) && !head[:version].nil?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# "2026.11.2" → Gem::Version, or nil when it is not a version.
|
|
93
|
+
def version(str)
|
|
94
|
+
str = str.to_s
|
|
95
|
+
Gem::Version.correct?(str) && !str.empty? ? Gem::Version.new(str) : nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# [mtime, size, inode] — a rename onto the path changes the inode, so a
|
|
99
|
+
# promoted file is noticed even within one mtime tick. nil when missing.
|
|
100
|
+
def stamp(path)
|
|
101
|
+
st = File.stat(path)
|
|
102
|
+
[st.mtime, st.size, st.ino]
|
|
103
|
+
rescue SystemCallError
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The newest dataset this app already has: the bundled one, or a usable
|
|
108
|
+
# cache (whatever `use_cache` says — a refresh never writes an older file).
|
|
109
|
+
# (and, unless `data_path=` pins a file, the dataset this process keeps
|
|
110
|
+
# in memory: it never goes back to older data on its own).
|
|
111
|
+
def newest_version
|
|
112
|
+
bundled = header(Vehicles::DATA_PATH)[:version]
|
|
113
|
+
cache = Vehicles.configuration.cache_path.to_s
|
|
114
|
+
cached = usable_cache?(cache) ? header(cache)[:version] : nil
|
|
115
|
+
memory = Vehicles.data_path_override ? nil : version(Vehicles.dataset.version)
|
|
116
|
+
[bundled, cached, memory].compact.max
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The cache file when another process promoted something newer than what
|
|
120
|
+
# this process has loaded, else the dataset in memory.
|
|
121
|
+
def newest_dataset(path)
|
|
122
|
+
current = Vehicles.dataset
|
|
123
|
+
loaded = version(current.version)
|
|
124
|
+
return current unless usable_cache?(path)
|
|
125
|
+
return current if loaded && header(path)[:version] <= loaded
|
|
126
|
+
|
|
127
|
+
build(path)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# --- internals -----------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def due?(snap, now = clock)
|
|
133
|
+
interval = Vehicles.configuration.reload_check_interval
|
|
134
|
+
!interval.nil? && now - snap.checked_at >= interval.to_f
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def sync
|
|
138
|
+
snap = @snapshot
|
|
139
|
+
now = clock
|
|
140
|
+
return snap.dataset if snap && !due?(snap, now)
|
|
141
|
+
|
|
142
|
+
path = active_path
|
|
143
|
+
stamp = stamp(path)
|
|
144
|
+
# Unchanged, the very file that already failed to load, or older than
|
|
145
|
+
# what is in memory (a cache deleted or replaced by an older/unreadable
|
|
146
|
+
# file): keep serving. Only `reload!` goes back to older data.
|
|
147
|
+
if snap && ((snap.path == path && snap.stamp == stamp) || @unloadable == [path, stamp] || older?(path, snap))
|
|
148
|
+
snap.checked_at = now
|
|
149
|
+
return snap.dataset
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
load_snapshot(path, stamp, now, snap)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def older?(path, snap)
|
|
156
|
+
candidate = header(path)[:version]
|
|
157
|
+
current = version(snap.dataset.version)
|
|
158
|
+
current && (candidate.nil? || candidate < current)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Parse and fully build a dataset file, or raise: a file that parses but
|
|
162
|
+
# is not a whole dataset (no makes, a malformed model) must fail HERE,
|
|
163
|
+
# not later inside a request.
|
|
164
|
+
def build(path, previous = nil)
|
|
165
|
+
raw = JSON.parse(File.read(path))
|
|
166
|
+
raise Error, "#{path}: no makes" unless raw.is_a?(Hash) && raw["makes"].is_a?(Array) && raw["makes"].any?
|
|
167
|
+
|
|
168
|
+
# The header was checked on an earlier read; another process may have
|
|
169
|
+
# replaced the file since. Check what was actually parsed.
|
|
170
|
+
unless path == Vehicles::DATA_PATH || Vehicles.data_path_override
|
|
171
|
+
head = { version: version(raw["version"]), schema_version: raw["schema_version"] }
|
|
172
|
+
raise Error, "#{path}: unusable" unless compatible?(head) &&
|
|
173
|
+
head[:version] >= header(Vehicles::DATA_PATH)[:version]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
if previous && !Vehicles.data_path_override && version(raw["version"]) < version(previous.version)
|
|
177
|
+
raise Error, "#{path}: older than the dataset in memory"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
Dataset.new(raw).tap(&:all_models)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def load_snapshot(path, stamp, now, previous)
|
|
184
|
+
dataset = build(path, previous&.dataset)
|
|
185
|
+
|
|
186
|
+
if previous && previous.dataset.version != dataset.version
|
|
187
|
+
Vehicles.logger&.info("[vehicles] dataset #{previous.dataset.version} → #{dataset.version} (#{path})")
|
|
188
|
+
end
|
|
189
|
+
@snapshot = Snapshot.new(path, stamp, dataset, now)
|
|
190
|
+
dataset
|
|
191
|
+
rescue StandardError => e
|
|
192
|
+
raise if path == Vehicles::DATA_PATH && previous.nil? # the gem's own data: a packaging bug, say so
|
|
193
|
+
|
|
194
|
+
Vehicles.logger&.warn("[vehicles] could not load #{path} (#{e.class}); keeping the dataset in use")
|
|
195
|
+
@unloadable = [path, stamp]
|
|
196
|
+
if previous
|
|
197
|
+
previous.checked_at = now
|
|
198
|
+
previous.dataset
|
|
199
|
+
else
|
|
200
|
+
load_snapshot(Vehicles::DATA_PATH, stamp(Vehicles::DATA_PATH), now, nil)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def read_header(path)
|
|
205
|
+
head = File.open(path, "rb") { |f| f.read(HEADER_BYTES) }.to_s.dup.force_encoding(Encoding::UTF_8).scrub
|
|
206
|
+
version = head[/"version"\s*:\s*"([^"]*)"/, 1]
|
|
207
|
+
schema = head[/"schema_version"\s*:\s*(\d+)/, 1]
|
|
208
|
+
if version.nil? || schema.nil? # not at the top: read it the slow way
|
|
209
|
+
raw = JSON.parse(File.read(path))
|
|
210
|
+
version = raw["version"] if raw.is_a?(Hash)
|
|
211
|
+
schema = raw["schema_version"] if raw.is_a?(Hash)
|
|
212
|
+
end
|
|
213
|
+
{ version: version(version), schema_version: schema&.to_i }
|
|
214
|
+
rescue StandardError
|
|
215
|
+
{ version: nil, schema_version: nil }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
219
|
+
end
|
|
220
|
+
end
|
data/lib/vehicles/dataset.rb
CHANGED
|
@@ -37,12 +37,16 @@ module Vehicles
|
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
attr_reader :version, :schema_version, :region
|
|
40
|
+
attr_reader :version, :schema_version, :region, :license, :attribution
|
|
41
41
|
|
|
42
42
|
def initialize(raw)
|
|
43
43
|
@version = raw["version"]
|
|
44
44
|
@schema_version = raw["schema_version"]
|
|
45
45
|
@region = raw["region"]
|
|
46
|
+
# The licence id and the credit block the release publishes (CC-BY
|
|
47
|
+
# 4.0), carried into Vehicles.dataset_slice so a slice keeps them.
|
|
48
|
+
@license = raw["license"]
|
|
49
|
+
@attribution = raw["attribution"]
|
|
46
50
|
|
|
47
51
|
# Name-alphabetical, diacritic-insensitive — the dataset file arrives
|
|
48
52
|
# slug-sorted, which differs subtly ("Austin-Healey" vs "Austin Morris").
|
|
@@ -61,6 +65,12 @@ module Vehicles
|
|
|
61
65
|
@exact[a.downcase] ||= make
|
|
62
66
|
end
|
|
63
67
|
end
|
|
68
|
+
index_former_slugs
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The make that now carries a retired make slug ("mg-roewe" → MG), or nil.
|
|
72
|
+
def make_by_former_slug(slug)
|
|
73
|
+
@by_former_slug[slug.to_s]
|
|
64
74
|
end
|
|
65
75
|
|
|
66
76
|
# All makes, optionally filtered by kind and/or continent. `region:` is a
|
|
@@ -69,10 +79,11 @@ module Vehicles
|
|
|
69
79
|
# this?"; on today's global snapshot both readings return European makes
|
|
70
80
|
# for `region: :eu`, so existing callers are unaffected.) An unmapped
|
|
71
81
|
# continent honestly returns [].
|
|
72
|
-
def makes(kind: nil, region: nil)
|
|
82
|
+
def makes(kind: nil, region: nil, country: nil)
|
|
73
83
|
list = @makes
|
|
74
84
|
list = list.select { |m| m.kinds.include?(kind.to_sym) } if kind
|
|
75
85
|
list = list.select { |m| m.in_region?(region) } if region
|
|
86
|
+
list = list.select { |m| m.available_in?(country, kind: kind) } if country
|
|
76
87
|
list
|
|
77
88
|
end
|
|
78
89
|
|
|
@@ -152,6 +163,11 @@ module Vehicles
|
|
|
152
163
|
@all_models ||= @makes.flat_map(&:models).freeze
|
|
153
164
|
end
|
|
154
165
|
|
|
166
|
+
# Continent codes present in the snapshot. => [:af, :as, :eu, ...]
|
|
167
|
+
def regions
|
|
168
|
+
@regions ||= all_models.flat_map(&:regions).uniq.sort.freeze
|
|
169
|
+
end
|
|
170
|
+
|
|
155
171
|
# Every kind present in the snapshot. => [:bus, :car, :moped, ...]
|
|
156
172
|
def kinds
|
|
157
173
|
@kinds ||= @makes.flat_map(&:kinds).uniq.sort.freeze
|
|
@@ -178,6 +194,7 @@ module Vehicles
|
|
|
178
194
|
# chosen decile only breaks ties among models ranked equally there.
|
|
179
195
|
# top_models(kind: :car, by: :mass_decile, limit: 10)
|
|
180
196
|
# top_models(kind: :motorcycle, region: :as, limit: 10) # by continent
|
|
197
|
+
# `limit: nil` returns every match.
|
|
181
198
|
def top_models(kind: nil, country: nil, region: nil, limit: 20, by: :global_decile)
|
|
182
199
|
raise ArgumentError, "by: must be :global_decile or :mass_decile" unless RANKINGS.include?(by)
|
|
183
200
|
|
|
@@ -186,14 +203,17 @@ module Vehicles
|
|
|
186
203
|
list = list.select { |m| m.kind == kind.to_sym } if kind
|
|
187
204
|
list = list.select { |m| m.availability.include?(c) } if c
|
|
188
205
|
list = list.select { |m| m.available_in_region?(region) } if region
|
|
189
|
-
list.sort_by
|
|
190
|
-
|
|
191
|
-
[rank ? 0 : 1, rank || 0, m.public_send(by), -m.availability.size, m.name]
|
|
192
|
-
end.first(limit)
|
|
206
|
+
sorted = list.sort_by { |m| popularity_key(m, c, by) }
|
|
207
|
+
limit ? sorted.first(limit) : sorted
|
|
193
208
|
end
|
|
194
209
|
|
|
195
210
|
RANKINGS = %i[global_decile mass_decile].freeze
|
|
196
211
|
|
|
212
|
+
def popularity_key(model, country, by)
|
|
213
|
+
rank = country && model.rank_in(country)
|
|
214
|
+
[rank ? 0 : 1, rank || 0, model.public_send(by), -model.availability.size, model.name]
|
|
215
|
+
end
|
|
216
|
+
|
|
197
217
|
# Every model matching optional kind/region/rarity filters, ranked by
|
|
198
218
|
# popularity. The "give me a sensible slice" entry point.
|
|
199
219
|
def all_models_filtered(kind: nil, region: nil, rarity: nil, max_decile: nil)
|
|
@@ -214,6 +234,18 @@ module Vehicles
|
|
|
214
234
|
|
|
215
235
|
private
|
|
216
236
|
|
|
237
|
+
# Former make slugs (a renamed or merged make) answer last: a current
|
|
238
|
+
# name, slug or alias always wins over a retired slug.
|
|
239
|
+
def index_former_slugs
|
|
240
|
+
@by_former_slug = {}
|
|
241
|
+
@makes.each do |make|
|
|
242
|
+
make.former_slugs.each do |old|
|
|
243
|
+
@by_former_slug[old] ||= make
|
|
244
|
+
index(old, make)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
217
249
|
def index(key, make)
|
|
218
250
|
@index[Vehicles.normalize(key)] ||= make
|
|
219
251
|
end
|
|
@@ -21,12 +21,48 @@ module Vehicles
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
# Records ("make/model" slugs, any make) NAMED this (normalized).
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
# `kinds` (a list, or nil for any) limits which records count.
|
|
25
|
+
def records_named(key, kinds = nil)
|
|
26
|
+
(resolution_index[:name][key] || []).select { |m| KindRule.allows?(kinds, m) }
|
|
27
|
+
.map { |m| "#{m.make_slug}/#{m.model_slug}" }.uniq
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The `via` step for a make reached through something other than its own
|
|
31
|
+
# name or slug: a retired make slug (`former_make_slug:old→new`,
|
|
32
|
+
# RESOLUTION.md) or an alias (`make_alias:key→slug`).
|
|
33
|
+
def make_step(make, key)
|
|
34
|
+
old = key.tr(" ", "-")
|
|
35
|
+
return "former_make_slug:#{old}→#{make.slug}" if make_by_former_slug(old).equal?(make)
|
|
36
|
+
|
|
37
|
+
"make_alias:#{key}→#{make.slug}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The live Model a retired id now answers to (Vehicles.by_id), or nil:
|
|
41
|
+
# 1. the record that lists the id in `former_ids` (one record; its kind
|
|
42
|
+
# variant of the id's kind, else the collision rule);
|
|
43
|
+
# 2. failing that, the make's new slug (make `former_slugs`): the same
|
|
44
|
+
# model slug under it, exactly or through ITS former ids.
|
|
45
|
+
def successor_of(id)
|
|
46
|
+
found = absorbed(id)
|
|
47
|
+
return found if found
|
|
48
|
+
|
|
49
|
+
kind, make_slug, model_slug = id.to_s.split("/", 3)
|
|
50
|
+
return nil unless model_slug && (make = make_by_former_slug(make_slug))
|
|
51
|
+
|
|
52
|
+
moved = "#{kind}/#{make.slug}/#{model_slug}"
|
|
53
|
+
models_by_id(moved).first || absorbed(moved)
|
|
26
54
|
end
|
|
27
55
|
|
|
28
56
|
private
|
|
29
57
|
|
|
58
|
+
def absorbed(id)
|
|
59
|
+
hits = models_by_former_id(id)
|
|
60
|
+
return nil if hits.empty? || hits.map { |m| [m.make_slug, m.model_slug] }.uniq.size > 1
|
|
61
|
+
|
|
62
|
+
kind = id.to_s.split("/", 2).first
|
|
63
|
+
hits.find { |m| m.kind.to_s == kind } || KindRule.pick(hits)
|
|
64
|
+
end
|
|
65
|
+
|
|
30
66
|
# Built once on first use; arrays keep dataset order.
|
|
31
67
|
def resolution_index
|
|
32
68
|
@resolution_index ||= begin
|
|
@@ -44,7 +80,7 @@ module Vehicles
|
|
|
44
80
|
key = Vehicles.normalize(a)
|
|
45
81
|
(idx[:alias][key] ||= []) << model unless key.empty?
|
|
46
82
|
end
|
|
47
|
-
(idx[:name][Vehicles.normalize(model.name)] ||= []) <<
|
|
83
|
+
(idx[:name][Vehicles.normalize(model.name)] ||= []) << model
|
|
48
84
|
end
|
|
49
85
|
end
|
|
50
86
|
end
|