vehicles 0.4.1 → 0.8.0
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/.rubocop_todo.yml +164 -0
- data/CHANGELOG.md +321 -0
- data/README.md +88 -15
- data/data/PROVENANCE-vehicles.md +12 -0
- data/data/plates/PROVENANCE.md +10 -0
- data/data/plates/_decode/at-districts.yml +287 -0
- data/data/plates/_decode/ch-cantons.yml +110 -0
- data/data/plates/_decode/cn-issuing-offices.yml +463 -0
- data/data/plates/_decode/cn-provinces.yml +614 -0
- data/data/plates/_decode/cz-kraj.yml +159 -0
- data/data/plates/_decode/dk-serial-blocks.yml +327 -0
- data/data/plates/_decode/es-provinces.yml +116 -0
- data/data/plates/_decode/fr-departements.yml +191 -0
- data/data/plates/_decode/gb-age-identifiers.yml +170 -0
- data/data/plates/_decode/gb-memory-tags.yml +202 -0
- data/data/plates/_decode/gr-prefectures.yml +1408 -0
- data/data/plates/_decode/hr-cities.yml +315 -0
- data/data/plates/_decode/ie-counties.yml +227 -0
- data/data/plates/_decode/in-state-codes.yml +169 -0
- data/data/plates/_decode/is-districts-1938.yml +116 -0
- data/data/plates/_decode/it-provinces.yml +348 -0
- data/data/plates/_decode/jp-office-marks.yml +960 -0
- data/data/plates/_decode/jp-use-marks.yml +205 -0
- data/data/plates/_decode/kr-authority-marks.yml +146 -0
- data/data/plates/_decode/kr-use-syllables.yml +194 -0
- data/data/plates/_decode/lu-series-order.yml +638 -0
- data/data/plates/_decode/mx-entity-series.yml +380 -0
- data/data/plates/_decode/my-index-marks.yml +271 -0
- data/data/plates/_decode/no-counties-1913.yml +148 -0
- data/data/plates/_decode/no-districts.yml +901 -0
- data/data/plates/_decode/pl-powiats.yml +567 -0
- data/data/plates/_decode/pt-trailer-services.yml +96 -0
- data/data/plates/_decode/ro-counties.yml +213 -0
- data/data/plates/_decode/se-diplomatic.yml +220 -0
- data/data/plates/_decode/si-regions.yml +123 -0
- data/data/plates/_decode/sk-districts.yml +314 -0
- data/data/plates/_decode/th-registration-elements.yml +208 -0
- data/data/plates/_decode/ua-regions.yml +393 -0
- data/data/plates/_decode/za-licence-marks.yml +215 -0
- data/data/plates/_meta/classes.yml +26 -0
- data/data/plates/_meta/separators.yml +102 -0
- data/data/plates/ad.yml +961 -0
- data/data/plates/ar.yml +762 -0
- data/data/plates/at.yml +1424 -0
- data/data/plates/au-act.yml +472 -0
- data/data/plates/au-nsw.yml +282 -0
- data/data/plates/au-nt.yml +431 -0
- data/data/plates/au-qld.yml +272 -0
- data/data/plates/au-sa.yml +248 -0
- data/data/plates/au-tas.yml +302 -0
- data/data/plates/au-vic.yml +215 -0
- data/data/plates/au-wa.yml +309 -0
- data/data/plates/be.yml +1840 -0
- data/data/plates/br.yml +1152 -0
- data/data/plates/ca-ab.yml +430 -0
- data/data/plates/ca-bc.yml +697 -0
- data/data/plates/ca-mb.yml +499 -0
- data/data/plates/ca-nb.yml +537 -0
- data/data/plates/ca-nl.yml +743 -0
- data/data/plates/ca-ns.yml +808 -0
- data/data/plates/ca-nt.yml +610 -0
- data/data/plates/ca-nu.yml +280 -0
- data/data/plates/ca-on.yml +881 -0
- data/data/plates/ca-pe.yml +592 -0
- data/data/plates/ca-qc.yml +1036 -0
- data/data/plates/ca-sk.yml +256 -0
- data/data/plates/ca-yt.yml +656 -0
- data/data/plates/ch.yml +1273 -0
- data/data/plates/cn.yml +1431 -0
- data/data/plates/cy.yml +688 -0
- data/data/plates/cz.yml +1179 -0
- data/data/plates/de.yml +1107 -0
- data/data/plates/dk.yml +1647 -0
- data/data/plates/ee.yml +1275 -0
- data/data/plates/es.yml +888 -0
- data/data/plates/fi.yml +1271 -0
- data/data/plates/fr.yml +1265 -0
- data/data/plates/gb.yml +1188 -0
- data/data/plates/gr.yml +721 -0
- data/data/plates/hr.yml +860 -0
- data/data/plates/hu.yml +959 -0
- data/data/plates/ie.yml +775 -0
- data/data/plates/in.yml +1172 -0
- data/data/plates/is.yml +667 -0
- data/data/plates/it.yml +1317 -0
- data/data/plates/jp.yml +3650 -0
- data/data/plates/kr.yml +1375 -0
- data/data/plates/li.yml +1010 -0
- data/data/plates/lt.yml +879 -0
- data/data/plates/lu.yml +842 -0
- data/data/plates/lv.yml +924 -0
- data/data/plates/mc.yml +1024 -0
- data/data/plates/mt.yml +854 -0
- data/data/plates/mx.yml +1372 -0
- data/data/plates/my.yml +685 -0
- data/data/plates/nl.yml +1016 -0
- data/data/plates/no.yml +1018 -0
- data/data/plates/nz.yml +1699 -0
- data/data/plates/pl.yml +1296 -0
- data/data/plates/pt.yml +847 -0
- data/data/plates/ro.yml +785 -0
- data/data/plates/se.yml +1091 -0
- data/data/plates/si.yml +1105 -0
- data/data/plates/sk.yml +1439 -0
- data/data/plates/sm.yml +1119 -0
- data/data/plates/th.yml +1150 -0
- data/data/plates/ua.yml +1672 -0
- data/data/plates/us-ak.yml +763 -0
- data/data/plates/us-al.yml +457 -0
- data/data/plates/us-ar.yml +427 -0
- data/data/plates/us-as.yml +454 -0
- data/data/plates/us-az.yml +430 -0
- data/data/plates/us-ca.yml +892 -0
- data/data/plates/us-co.yml +452 -0
- data/data/plates/us-ct.yml +427 -0
- data/data/plates/us-dc.yml +614 -0
- data/data/plates/us-de.yml +551 -0
- data/data/plates/us-fl.yml +741 -0
- data/data/plates/us-ga.yml +644 -0
- data/data/plates/us-gu.yml +658 -0
- data/data/plates/us-hi.yml +585 -0
- data/data/plates/us-ia.yml +438 -0
- data/data/plates/us-id.yml +531 -0
- data/data/plates/us-il.yml +516 -0
- data/data/plates/us-in.yml +324 -0
- data/data/plates/us-ks.yml +351 -0
- data/data/plates/us-ky.yml +770 -0
- data/data/plates/us-la.yml +568 -0
- data/data/plates/us-ma.yml +379 -0
- data/data/plates/us-md.yml +536 -0
- data/data/plates/us-me.yml +445 -0
- data/data/plates/us-mi.yml +408 -0
- data/data/plates/us-mn.yml +411 -0
- data/data/plates/us-mo.yml +356 -0
- data/data/plates/us-mp.yml +402 -0
- data/data/plates/us-ms.yml +432 -0
- data/data/plates/us-mt.yml +619 -0
- data/data/plates/us-nc.yml +602 -0
- data/data/plates/us-nd.yml +419 -0
- data/data/plates/us-ne.yml +483 -0
- data/data/plates/us-nh.yml +394 -0
- data/data/plates/us-nj.yml +547 -0
- data/data/plates/us-nm.yml +574 -0
- data/data/plates/us-nv.yml +655 -0
- data/data/plates/us-ny.yml +592 -0
- data/data/plates/us-oh.yml +404 -0
- data/data/plates/us-ok.yml +513 -0
- data/data/plates/us-or.yml +392 -0
- data/data/plates/us-pa.yml +506 -0
- data/data/plates/us-pr.yml +507 -0
- data/data/plates/us-ri.yml +445 -0
- data/data/plates/us-sc.yml +605 -0
- data/data/plates/us-sd.yml +428 -0
- data/data/plates/us-tn.yml +471 -0
- data/data/plates/us-tx.yml +535 -0
- data/data/plates/us-ut.yml +384 -0
- data/data/plates/us-va.yml +567 -0
- data/data/plates/us-vi.yml +489 -0
- data/data/plates/us-vt.yml +458 -0
- data/data/plates/us-wa.yml +484 -0
- data/data/plates/us-wi.yml +509 -0
- data/data/plates/us-wv.yml +446 -0
- data/data/plates/us-wy.yml +523 -0
- data/data/plates/va.yml +604 -0
- data/data/plates/za.yml +1141 -0
- data/data/vehicles.json +160653 -105090
- data/lib/vehicles/configuration.rb +18 -2
- data/lib/vehicles/dataset.rb +73 -18
- data/lib/vehicles/fuzzy.rb +115 -0
- data/lib/vehicles/http.rb +48 -0
- data/lib/vehicles/make.rb +36 -6
- data/lib/vehicles/mcp_server.rb +5 -5
- data/lib/vehicles/model.rb +42 -6
- data/lib/vehicles/plates/jurisdiction.rb +32 -0
- data/lib/vehicles/plates/series.rb +291 -0
- data/lib/vehicles/plates/template_fill.rb +188 -0
- data/lib/vehicles/plates/templates.rb +162 -0
- data/lib/vehicles/plates.rb +122 -0
- data/lib/vehicles/providers/hosted_provider.rb +49 -58
- data/lib/vehicles/providers/local_provider.rb +4 -3
- data/lib/vehicles/validators/vehicle_make_validator.rb +3 -1
- data/lib/vehicles/validators/vehicle_model_validator.rb +1 -1
- data/lib/vehicles/version.rb +1 -1
- data/lib/vehicles.rb +53 -16
- data/rakelib/data_refresh.rake +109 -0
- data/rakelib/plates_refresh.rake +29 -0
- data/sig/vehicles.rbs +18 -0
- metadata +174 -2
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vehicles
|
|
4
|
+
module Plates
|
|
5
|
+
# One registration series: a pattern identity with a period, a class
|
|
6
|
+
# (standard/taxi/diplomatic/…), categories, sourced design facts, and the
|
|
7
|
+
# two regexes of the dataset's regex policy — `regex` (recall: the
|
|
8
|
+
# pattern's own alphabet) and `regex_strict` (validation: the alphabet
|
|
9
|
+
# the authority actually issues). Validation prefers strict.
|
|
10
|
+
class Series
|
|
11
|
+
attr_reader :id, :klass, :categories, :period, :period_evidence,
|
|
12
|
+
:pattern, :display_pattern, :regex, :regex_strict, :design,
|
|
13
|
+
:variants, :sources, :notes, :validation_regexp, :lenient_regexp,
|
|
14
|
+
:issued_regexp, :issued_separators
|
|
15
|
+
|
|
16
|
+
def initialize(entry)
|
|
17
|
+
@id = entry["id"]
|
|
18
|
+
@klass = entry["class"] || "standard"
|
|
19
|
+
@categories = Array(entry["categories"]).freeze
|
|
20
|
+
@period = (entry["period"] || {}).freeze
|
|
21
|
+
@period_evidence = entry["period_evidence"]
|
|
22
|
+
@pattern = entry.dig("format", "pattern")
|
|
23
|
+
# "\L" escapes a LITERAL L (vs the any-letter placeholder) —
|
|
24
|
+
# ca-nb-truck-2003 introduced it; resolved for display and walks.
|
|
25
|
+
@display_pattern = @pattern&.gsub(/\\(.)/) { $1 }
|
|
26
|
+
@regex = entry.dig("format", "regex")
|
|
27
|
+
@regex_strict = entry.dig("format", "regex_strict")
|
|
28
|
+
@design = (entry["design"] || {}).freeze
|
|
29
|
+
@variants = Array(entry["variants"]).freeze
|
|
30
|
+
@sources = Array(entry["sources"]).freeze
|
|
31
|
+
@notes = entry["notes"]
|
|
32
|
+
# Compiled eagerly — the object is frozen, so no lazy memoization.
|
|
33
|
+
@validation_regexp = compile(@regex_strict || @regex)
|
|
34
|
+
@lenient_regexp = compile(self.class.strip_separators(@regex_strict || @regex))
|
|
35
|
+
segments, separators = self.class.segment_separators(@regex_strict || @regex)
|
|
36
|
+
@issued_separators = (separators || []).freeze
|
|
37
|
+
@issued_regexp = segments && compile("\\A(#{segments.join(')(')})\\z")
|
|
38
|
+
freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A strict-validated series has an authority-alphabet regex twin; a
|
|
42
|
+
# recall-only series (export/dark-blue catch-alls that accept any
|
|
43
|
+
# current shape) matches loosely by design. Consumers validating user
|
|
44
|
+
# input should weight strict hits above recall-only ones — Match does.
|
|
45
|
+
def strict?
|
|
46
|
+
!regex_strict.nil?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# period_evidence values on which the recorded START is not (or not
|
|
50
|
+
# provenly) the series' birth — an instrument/consolidation date, an
|
|
51
|
+
# upper bound, or an unverified secondary (the dataset's own honesty
|
|
52
|
+
# discipline). The label must not present these as the era. Values
|
|
53
|
+
# like enabling-instrument / statutory-creation-date /
|
|
54
|
+
# instrument-dated-both-ends ARE true starts and stay unmarked.
|
|
55
|
+
INSTRUMENT_DATED_EVIDENCE = %w[
|
|
56
|
+
instrument-in-force instrument-window
|
|
57
|
+
instrument-in-force-upper-bound secondary-locator-unverified
|
|
58
|
+
].freeze
|
|
59
|
+
|
|
60
|
+
# "2000 → today" (open, still issued), "1971–2000", or — when the
|
|
61
|
+
# start is only an instrument date — "~1999–2000" / "~2000 → today":
|
|
62
|
+
# the format is documented FROM that year and may be older.
|
|
63
|
+
# es-provincial's 1999–2000 instrument window over a format that ran
|
|
64
|
+
# for decades is the case this guards. approximate_start? tells a
|
|
65
|
+
# consumer to explain the "~".
|
|
66
|
+
def period_label
|
|
67
|
+
start = period["start"]
|
|
68
|
+
start = "~#{start}" if approximate_start?
|
|
69
|
+
finish = period["end"]
|
|
70
|
+
finish ? "#{start}–#{finish}" : "#{start} → today"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# True when the recorded start is an instrument date, not the series'
|
|
74
|
+
# birth — the display should read it as "documented from", not "began in".
|
|
75
|
+
def approximate_start?
|
|
76
|
+
INSTRUMENT_DATED_EVIDENCE.include?(period_evidence)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Re-print a separator-less serial the way this series formats it.
|
|
80
|
+
# Primary path: match the serial against the regex SEGMENTED at its
|
|
81
|
+
# separators (the §2.6/§2.8 separator contract makes segmentation
|
|
82
|
+
# mechanical) and rejoin the captured groups with the separators the
|
|
83
|
+
# series actually prints — correct even when groups are
|
|
84
|
+
# variable-length (ES provincial: M-1234-LA and SE-1234-AB both live
|
|
85
|
+
# in [A-Z]{1,2}-\d{4}-[A-Z]{1,2}; the old pattern walk sliced
|
|
86
|
+
# "M1234LA" into "M1-234L-A"). Fallback: the pattern walk, but ONLY
|
|
87
|
+
# when the serial exactly fills the pattern's slots; otherwise the
|
|
88
|
+
# serial comes back untouched — unformatted is honest, misplaced
|
|
89
|
+
# separators are not.
|
|
90
|
+
def format_serial(serial)
|
|
91
|
+
return nil if serial.nil?
|
|
92
|
+
|
|
93
|
+
# The display pattern is authoritative when the serial fills it
|
|
94
|
+
# exactly — it knows splits the regex cannot (CC-999-999 over
|
|
95
|
+
# `\d+[- ]\d+` is ambiguous to a greedy match, unambiguous to the
|
|
96
|
+
# pattern).
|
|
97
|
+
slots = display_pattern&.each_char&.count { |ch| ![ "-", " ", "·", "." ].include?(ch) }
|
|
98
|
+
if slots && serial.length == slots
|
|
99
|
+
idx = 0
|
|
100
|
+
out = display_pattern.each_char.with_object(+"") do |ch, str|
|
|
101
|
+
if [ "-", " ", "·", "." ].include?(ch)
|
|
102
|
+
str << ch
|
|
103
|
+
else
|
|
104
|
+
str << (serial[idx] || "")
|
|
105
|
+
idx += 1
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
return out.freeze
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Length mismatch: the pattern is one shape of a variable family —
|
|
112
|
+
# reconstruct from the regex segmented at its separators instead
|
|
113
|
+
# (ES provincial: "M1234LA" is M-1234-LA, which the fixed-slot walk
|
|
114
|
+
# sliced into "M1-234L-A").
|
|
115
|
+
if issued_regexp && (m = issued_regexp.match(serial)) &&
|
|
116
|
+
m.captures.size == issued_separators.size + 1
|
|
117
|
+
return m.captures.each_with_index
|
|
118
|
+
.map { |part, i| i.zero? ? part : "#{issued_separators[i - 1]}#{part}" }
|
|
119
|
+
.join.freeze
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Neither path can know — unformatted is honest, misplaced
|
|
123
|
+
# separators are not.
|
|
124
|
+
serial.dup.freeze
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Tokens that read as "a separator" when they make up a whole
|
|
128
|
+
# character class — `[- ]` in the ES consular series is dash-or-space,
|
|
129
|
+
# i.e. a separator spelled as a class.
|
|
130
|
+
SEPARATOR_TOKENS = [ " ", "-", "·", ".", '\s', '\-', '\.', '\ ' ].freeze
|
|
131
|
+
|
|
132
|
+
# Removing a separator can join two repetitions of the same character
|
|
133
|
+
# class: `[A-Z0-9]+(?:-[A-Z0-9]+)*` becomes the redundant
|
|
134
|
+
# `[A-Z0-9]+(?:[A-Z0-9]+)*`. Ruby (3.1+) warns while compiling that shape.
|
|
135
|
+
# Its language is exactly `[A-Z0-9]+`, whether the trailing group is
|
|
136
|
+
# optional or repeated, so canonicalize it after the separator walk.
|
|
137
|
+
REDUNDANT_CHARACTER_CLASS_REPEAT = /(\[[^\]]+\])\+\(\?:\1\+\)[*?]/
|
|
138
|
+
|
|
139
|
+
# Split a regex source into its serial SEGMENTS and the separator
|
|
140
|
+
# character printed between each pair — the reconstruction data behind
|
|
141
|
+
# format_serial. Same scan as strip_separators (same class-vs-range
|
|
142
|
+
# discipline; a dropped separator's dangling quantifier goes with it),
|
|
143
|
+
# but instead of discarding separators it records what each one PRINTS
|
|
144
|
+
# (`\s` and "\ " print a space; a separator-only class prints its
|
|
145
|
+
# first token). Returns [segments, separators] with
|
|
146
|
+
# segments.length == separators.length + 1, or [nil, nil] when the
|
|
147
|
+
# source is nil, has no separators, or segments in a shape the
|
|
148
|
+
# reconstruction cannot trust (empty segment, capturing groups that
|
|
149
|
+
# would shift the positional captures).
|
|
150
|
+
def self.segment_separators(src)
|
|
151
|
+
return [ nil, nil ] if src.nil?
|
|
152
|
+
|
|
153
|
+
body = src.sub('\A', "").sub('\z', "")
|
|
154
|
+
segments = [ +"" ]
|
|
155
|
+
separators = []
|
|
156
|
+
emit = lambda do |printed|
|
|
157
|
+
if segments.last.empty? && separators.any?
|
|
158
|
+
# a run of separators prints once — keep the first
|
|
159
|
+
else
|
|
160
|
+
separators << printed
|
|
161
|
+
segments << +""
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
chars = body.chars
|
|
166
|
+
i = 0
|
|
167
|
+
while i < chars.size
|
|
168
|
+
ch = chars[i]
|
|
169
|
+
|
|
170
|
+
if ch == "\\" && i + 1 < chars.size
|
|
171
|
+
nxt = chars[i + 1]
|
|
172
|
+
if [ "s", " " ].include?(nxt)
|
|
173
|
+
i += 2
|
|
174
|
+
i += 1 if [ "?", "*" ].include?(chars[i])
|
|
175
|
+
emit.call(" ")
|
|
176
|
+
elsif [ "-", "." ].include?(nxt)
|
|
177
|
+
i += 2
|
|
178
|
+
i += 1 if [ "?", "*" ].include?(chars[i])
|
|
179
|
+
emit.call(nxt)
|
|
180
|
+
else
|
|
181
|
+
segments.last << ch << nxt
|
|
182
|
+
i += 2
|
|
183
|
+
end
|
|
184
|
+
next
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
if ch == "["
|
|
188
|
+
closing = i + 1
|
|
189
|
+
closing += 1 while closing < chars.size && chars[closing] != "]"
|
|
190
|
+
content = chars[(i + 1)...closing].join
|
|
191
|
+
tokens = content.scan(/\\.|./m)
|
|
192
|
+
if tokens.any? && tokens.all? { |t| SEPARATOR_TOKENS.include?(t) }
|
|
193
|
+
first = tokens.first
|
|
194
|
+
printed = first.start_with?("\\") ? (first[1] == "s" ? " " : first[1]) : first
|
|
195
|
+
i = closing + 1
|
|
196
|
+
i += 1 if [ "?", "*" ].include?(chars[i])
|
|
197
|
+
emit.call(printed)
|
|
198
|
+
else
|
|
199
|
+
segments.last << chars[i..closing].join
|
|
200
|
+
i = closing + 1
|
|
201
|
+
end
|
|
202
|
+
next
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
if [ "-", " ", "·" ].include?(ch)
|
|
206
|
+
i += 1
|
|
207
|
+
i += 1 if [ "?", "*" ].include?(chars[i])
|
|
208
|
+
emit.call(ch)
|
|
209
|
+
else
|
|
210
|
+
segments.last << ch
|
|
211
|
+
i += 1
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
return [ nil, nil ] if separators.empty?
|
|
216
|
+
return [ nil, nil ] if segments.any?(&:empty?)
|
|
217
|
+
# A capturing group inside a segment would shift the positional
|
|
218
|
+
# captures the reconstruction indexes by — bail to the fallback.
|
|
219
|
+
return [ nil, nil ] if segments.any? { |s| s.match?(/\((?!\?)/) }
|
|
220
|
+
|
|
221
|
+
[ segments, separators ]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Remove separator LITERALS from a regex source, respecting character
|
|
225
|
+
# classes (the "-" inside [A-Z] is a range, not a separator; a class
|
|
226
|
+
# composed ONLY of separators, like `[- ]`, is a separator and goes).
|
|
227
|
+
# A quantifier left dangling by a removed separator ("-?", "[- ]?") is
|
|
228
|
+
# swallowed with it. Linear scan — regexes in this dataset are simple.
|
|
229
|
+
def self.strip_separators(src)
|
|
230
|
+
return nil if src.nil?
|
|
231
|
+
|
|
232
|
+
out = +""
|
|
233
|
+
chars = src.chars
|
|
234
|
+
i = 0
|
|
235
|
+
while i < chars.size
|
|
236
|
+
ch = chars[i]
|
|
237
|
+
|
|
238
|
+
if ch == "\\" && i + 1 < chars.size
|
|
239
|
+
nxt = chars[i + 1]
|
|
240
|
+
if [ "s", "-", ".", " " ].include?(nxt)
|
|
241
|
+
i += 2
|
|
242
|
+
i += 1 if [ "?", "*" ].include?(chars[i]) # optional separator: drop its quantifier too
|
|
243
|
+
else
|
|
244
|
+
out << ch << nxt
|
|
245
|
+
i += 2
|
|
246
|
+
end
|
|
247
|
+
next
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
if ch == "["
|
|
251
|
+
# Copy or drop the WHOLE class span in one decision.
|
|
252
|
+
closing = i + 1
|
|
253
|
+
closing += 1 while closing < chars.size && chars[closing] != "]"
|
|
254
|
+
content = chars[(i + 1)...closing].join
|
|
255
|
+
tokens = content.scan(/\\.|./m)
|
|
256
|
+
if tokens.any? && tokens.all? { |t| SEPARATOR_TOKENS.include?(t) }
|
|
257
|
+
i = closing + 1
|
|
258
|
+
i += 1 if [ "?", "*" ].include?(chars[i])
|
|
259
|
+
else
|
|
260
|
+
out << chars[i..closing].join
|
|
261
|
+
i = closing + 1
|
|
262
|
+
end
|
|
263
|
+
next
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
if [ "-", " ", "·" ].include?(ch)
|
|
267
|
+
i += 1
|
|
268
|
+
i += 1 if [ "?", "*" ].include?(chars[i])
|
|
269
|
+
else
|
|
270
|
+
out << ch
|
|
271
|
+
i += 1
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
out.gsub(REDUNDANT_CHARACTER_CLASS_REPEAT) { "#{Regexp.last_match(1)}+" }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def inspect = %(#<Vehicles::Plates::Series #{id} "#{pattern}">)
|
|
278
|
+
|
|
279
|
+
private
|
|
280
|
+
|
|
281
|
+
# Bad regex in data must degrade like every other lookup: nil, not raise.
|
|
282
|
+
def compile(source)
|
|
283
|
+
return nil if source.nil?
|
|
284
|
+
|
|
285
|
+
Regexp.new(source)
|
|
286
|
+
rescue RegexpError
|
|
287
|
+
nil
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Vehicles
|
|
6
|
+
module Plates
|
|
7
|
+
# The REFERENCE FILLER for VehiclesDB plate templates — the client half of
|
|
8
|
+
# the privacy-safe template endpoint
|
|
9
|
+
# (GET /plates/:code/:series_id/template.svg, VehiclesDB PRD-PLATE-ART §9).
|
|
10
|
+
# A client downloads a template once (it carries nothing personal, so it
|
|
11
|
+
# caches publicly) and writes the user's registration into it HERE, on its
|
|
12
|
+
# own machine — a registration is personal data and never travels:
|
|
13
|
+
#
|
|
14
|
+
# svg = Vehicles::Plates::TemplateFill.fill(template_svg, "1234 BCD")
|
|
15
|
+
#
|
|
16
|
+
# Moved here from vehiclesdb-web (app/models/plates/template_fill.rb; same logic,
|
|
17
|
+
# plus shape_of and label:), whose renderer will call `fit_size` /
|
|
18
|
+
# `recentered_baseline` from this gem
|
|
19
|
+
# so the fit formula lives once. Deliberately dependency-free (Ruby stdlib
|
|
20
|
+
# `json` only — no Rails, no ActiveSupport, no XML library). Everything it
|
|
21
|
+
# needs is in the template's own <metadata id="vdb-template"> block, written
|
|
22
|
+
# by the VehiclesDB renderer from the same layout pass that renders real
|
|
23
|
+
# serials:
|
|
24
|
+
# slots[] one per character group: shape slice, compact start offset,
|
|
25
|
+
# centre x, baseline, font size, nominal width, max width
|
|
26
|
+
# advance how the renderer prices glyph widths (em at size 1) — so a
|
|
27
|
+
# filled group gets the same textLength a server render would
|
|
28
|
+
#
|
|
29
|
+
# It VALIDATES before it writes: the registration must have exactly the
|
|
30
|
+
# template's shape (N = digit, L = letter; separators are free), so a
|
|
31
|
+
# filled plate never shows characters its layout was not computed for.
|
|
32
|
+
module TemplateFill
|
|
33
|
+
class Error < StandardError; end
|
|
34
|
+
|
|
35
|
+
# The template metadata schema this filler understands
|
|
36
|
+
# (`"version"` in the vdb-template block).
|
|
37
|
+
SCHEMA_VERSION = 1
|
|
38
|
+
SEPARATORS = "- .·"
|
|
39
|
+
CLASSES = { "N" => /\A[0-9]\z/, "L" => /\A[A-ZÄÖÜ]\z/ }.freeze
|
|
40
|
+
METADATA = %r{<metadata id="vdb-template"><!\[CDATA\[(.*?)\]\]></metadata>}m
|
|
41
|
+
|
|
42
|
+
# A filled template is meant to be inlined into a host page, i.e. trusted as
|
|
43
|
+
# HTML. Real templates are inert: shapes, text, and data: URIs for the face
|
|
44
|
+
# and region art. Anything active or external is refused, here at the fill
|
|
45
|
+
# boundary as well as wherever a template is fetched. data: payloads are
|
|
46
|
+
# blanked before the scan (base64 may spell "/onxy=" by chance).
|
|
47
|
+
ACTIVE_CONTENT = Regexp.union(
|
|
48
|
+
/<\s*(?:script|foreignObject|iframe|object|embed)\b/i,
|
|
49
|
+
%r{[\s/"']on[a-z]+\s*=}i, # also <svg/onload=…>, which HTML parsers accept
|
|
50
|
+
/javascript:/i, /@import/i,
|
|
51
|
+
/href\s*+=\s*+(?!["']?\s*(?:data:|#))/i, /url\(\s*+(?!["']?\s*(?:data:|#))/i
|
|
52
|
+
)
|
|
53
|
+
# Only a data: URI's own alphabet (base64 / percent-encoding) is blanked, so
|
|
54
|
+
# markup after a bare "data:" is still scanned.
|
|
55
|
+
DATA_URI = %r{data:[a-z]+/[a-z0-9.+-]+(?:;[a-z0-9=.+-]+)*,[A-Za-z0-9+/=%._~-]*}i
|
|
56
|
+
|
|
57
|
+
module_function
|
|
58
|
+
|
|
59
|
+
# True when the SVG carries nothing a browser would run or fetch.
|
|
60
|
+
def inert?(svg)
|
|
61
|
+
!svg.to_s.gsub(DATA_URI, "data:").match?(ACTIVE_CONTENT)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The filled SVG document. Raises Error when the template is not one or
|
|
65
|
+
# the registration does not fit its shape. `label:` replaces the whole
|
|
66
|
+
# accessible name (default: "<Country> plate <REGISTRATION>"), e.g.
|
|
67
|
+
# "Matrícula 1234 BCD" for a Spanish-language page.
|
|
68
|
+
def fill(svg, registration, label: nil) # rubocop:disable Metrics/AbcSize -- same logic as the VehiclesDB copy
|
|
69
|
+
inert?(svg) or raise Error, "template carries active or external content; refusing to fill it"
|
|
70
|
+
meta = metadata(svg)
|
|
71
|
+
out = svg.sub(METADATA, "")
|
|
72
|
+
groups(meta, registration).each do |g|
|
|
73
|
+
slot = g[:slot]
|
|
74
|
+
attrs = %( textLength="#{g[:text_length]}")
|
|
75
|
+
pattern = %r{(<text id="#{Regexp.escape(slot["id"])}"[^>]*?)( lengthAdjust="[^"]*")?></text>}
|
|
76
|
+
out.match?(pattern) or raise Error, "placeholder #{slot["id"]} missing from the template"
|
|
77
|
+
out = out.sub(pattern) do
|
|
78
|
+
head = Regexp.last_match(1)
|
|
79
|
+
adjust = g[:squeezed] ? %( lengthAdjust="spacingAndGlyphs") : Regexp.last_match(2).to_s
|
|
80
|
+
head = resized(head, slot, g[:font_size], g[:baseline]) if g[:font_size] != slot["font_size"]
|
|
81
|
+
"#{head}#{adjust}#{attrs}>#{escape(g[:text])}</text>"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
return out.sub(/aria-label="[^"]*?"/) { %(aria-label="#{escape(label.to_s)}") } if label
|
|
85
|
+
|
|
86
|
+
out.sub(/(aria-label="[^"]*?) plate template"/) do
|
|
87
|
+
%(#{Regexp.last_match(1)} plate #{escape(normalize(registration))}")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The parsed <metadata> block of a template SVG.
|
|
92
|
+
def metadata(svg)
|
|
93
|
+
json = svg.to_s[METADATA, 1] or raise Error, "not a VehiclesDB plate template (no vdb-template metadata)"
|
|
94
|
+
JSON.parse(json)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The SHAPE of a serial-like text, as the template endpoint takes it:
|
|
98
|
+
# digits → N, letters → L, separators kept ("1234 BCD" → "NNNN LLL").
|
|
99
|
+
# Nil when a character fits no class. A shape carries no characters, so
|
|
100
|
+
# it is the only thing about a registration that may leave the machine.
|
|
101
|
+
# Pattern escapes ("\9") classify their literal (VehiclesDB's
|
|
102
|
+
# Plates::Template.shape_of, same rules).
|
|
103
|
+
def shape_of(text)
|
|
104
|
+
out = +""
|
|
105
|
+
chars = text.to_s.upcase.chars
|
|
106
|
+
while (ch = chars.shift)
|
|
107
|
+
ch = chars.shift.to_s if ch == "\\"
|
|
108
|
+
out << case ch
|
|
109
|
+
when /\A[0-9]\z/ then "N"
|
|
110
|
+
when /\A[A-ZÄÖÜ]\z/ then "L"
|
|
111
|
+
when /\A[- .·]\z/ then ch
|
|
112
|
+
else return nil
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
out.squeeze(SEPARATORS)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Per slot: the characters it prints and the width they need. Usable on
|
|
119
|
+
# its own by a client that draws text itself (the JSON template carries
|
|
120
|
+
# the same metadata).
|
|
121
|
+
def groups(meta, registration) # rubocop:disable Metrics/AbcSize -- same logic as the VehiclesDB copy
|
|
122
|
+
chars = compact(normalize(registration)).chars
|
|
123
|
+
shape = compact(meta.fetch("shape"))
|
|
124
|
+
unless chars.length == shape.length && chars.each_with_index.all? { |ch, i| CLASSES.fetch(shape[i]).match?(ch) }
|
|
125
|
+
raise Error, "registration does not have the template's shape #{meta["shape"].inspect} " \
|
|
126
|
+
"(N = digit, L = letter); fetch the template for its shape"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
meta.fetch("slots").map do |slot|
|
|
130
|
+
source = chars[slot["start"], slot["max_chars"]].dup
|
|
131
|
+
text = slot["shape"].gsub(/[NL]/) { source.shift }
|
|
132
|
+
size = slot["font_size"]
|
|
133
|
+
baseline = slot["baseline"]
|
|
134
|
+
# A lone run (nothing printed beside it) refits exactly as the
|
|
135
|
+
# renderer fits a real serial: from its natural size, shrunk only
|
|
136
|
+
# when the real characters overflow the room.
|
|
137
|
+
if (refit = slot["refit"])
|
|
138
|
+
size = refit["size"]
|
|
139
|
+
natural = text_width(text, size, meta.fetch("advance"))
|
|
140
|
+
size = fit_size(size, refit["avail"], natural) if natural > refit["avail"]
|
|
141
|
+
rule = refit["baseline"]
|
|
142
|
+
baseline = recentered_baseline(rule["origin"], size, rule["per_size"]) if rule
|
|
143
|
+
end
|
|
144
|
+
width = text_width(text, size, meta.fetch("advance"))
|
|
145
|
+
squeezed = width > slot["max_width"]
|
|
146
|
+
{ slot: slot, text: text, squeezed: squeezed, font_size: size, baseline: baseline,
|
|
147
|
+
text_length: (squeezed ? slot["max_width"] : width).round(1) }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# THE fit rule, shared with VehiclesDB's Plates::SvgRenderer: a run
|
|
152
|
+
# wider than its room shrinks to 98% of the exact fit.
|
|
153
|
+
def fit_size(size, room, natural_width) = (size * room / natural_width * 0.98).round(2)
|
|
154
|
+
|
|
155
|
+
# The re-centred baseline of a lone run, shared with the renderer.
|
|
156
|
+
def recentered_baseline(origin, size, per_size) = (origin + (size * per_size)).round(1)
|
|
157
|
+
|
|
158
|
+
# A placeholder's size-proportional attributes at a refitted size.
|
|
159
|
+
def resized(head, slot, size, baseline)
|
|
160
|
+
head = head.sub(/ y="[^"]*"/, %( y="#{baseline}")).sub(/ font-size="[^"]*"/, %( font-size="#{size}"))
|
|
161
|
+
if slot["letter_spacing_em"].to_f.positive?
|
|
162
|
+
head = head.sub(/ letter-spacing="[^"]*"/, %( letter-spacing="#{size * slot["letter_spacing_em"]}"))
|
|
163
|
+
end
|
|
164
|
+
if slot["stroke_width_em"]
|
|
165
|
+
head = head.sub(/ stroke-width="[^"]*"/, %( stroke-width="#{(size * slot["stroke_width_em"]).round(2)}"))
|
|
166
|
+
end
|
|
167
|
+
head
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The renderer's own width model (SvgRenderer#row_metrics): a uniform
|
|
171
|
+
# statutory pitch, or per-glyph measured advances with a fallback.
|
|
172
|
+
def text_width(text, size, advance)
|
|
173
|
+
return text.length * advance["uniform_em"] * size if advance["uniform_em"]
|
|
174
|
+
|
|
175
|
+
table = advance.fetch("em")
|
|
176
|
+
text.chars.sum { |ch| (table[ch] || advance.fetch("fallback_em")) * size }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def normalize(registration) = registration.to_s.strip.upcase
|
|
180
|
+
|
|
181
|
+
def compact(text) = text.delete(SEPARATORS)
|
|
182
|
+
|
|
183
|
+
def escape(text)
|
|
184
|
+
text.gsub(/[&<>"']/, "&" => "&", "<" => "<", ">" => ">", '"' => """, "'" => "'")
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vehicles
|
|
4
|
+
module Plates
|
|
5
|
+
# VehiclesDB's real plate art for a registration, WITHOUT sending the
|
|
6
|
+
# registration anywhere (VehiclesDB PRD-PLATE-ART §9: a plate number is
|
|
7
|
+
# personal data). The plate is fetched as a BLANK template — the series,
|
|
8
|
+
# the registration's shape ("NNNN LLL", character classes only) and an
|
|
9
|
+
# optional region — and filled locally by TemplateFill:
|
|
10
|
+
#
|
|
11
|
+
# Vehicles::Plates.render("1234 BCD", jurisdiction: :es)
|
|
12
|
+
# # => "<svg … aria-label=\"Spain plate 1234 BCD\">…</svg>", or nil
|
|
13
|
+
#
|
|
14
|
+
# Vehicles::Plates.template(code: :es, series: "es-national-2000", shape: "NNNN LLL")
|
|
15
|
+
# # => the blank template SVG, or nil
|
|
16
|
+
#
|
|
17
|
+
# The template route is public: no API key is needed, and none is sent.
|
|
18
|
+
# Templates are cached (in-process by default; `config.plate_template_cache`
|
|
19
|
+
# takes any store with read(key) / write(key, value, expires_in:), e.g.
|
|
20
|
+
# Rails.cache), so a page view costs at most one request per (series,
|
|
21
|
+
# shape, region) and TTL. Never raises: nil on any failure, so a caller
|
|
22
|
+
# keeps whatever it drew before.
|
|
23
|
+
TEMPLATE_PATH = "/plates/%<code>s/%<series>s/template.svg"
|
|
24
|
+
# A failed fetch is remembered briefly, so an unreachable server costs
|
|
25
|
+
# one timeout per key per window, not one per page view.
|
|
26
|
+
TEMPLATE_MISS_TTL = 300
|
|
27
|
+
|
|
28
|
+
# Client-side fences, so nothing but a template request can leave: a
|
|
29
|
+
# shape is N/L classes and separators ("_" is the URL-friendly space),
|
|
30
|
+
# never characters, and the other inputs are identifiers.
|
|
31
|
+
TEMPLATE_CODE = /\A[a-z]{2}(?:-[a-z]{2,3})?\z/
|
|
32
|
+
TEMPLATE_SERIES = /\A[a-z0-9-]+\z/
|
|
33
|
+
TEMPLATE_SHAPE = /\A[NL]+(?:[- .·_][NL]+)*\z/
|
|
34
|
+
# A region is a subdivision KEY, never free text, so a registration
|
|
35
|
+
# passed by mistake cannot leave in it: FR département (75, 2A, 971,
|
|
36
|
+
# 44-bzh), DE Land / CH canton (two letters), AT Land (one digit).
|
|
37
|
+
TEMPLATE_REGION = /\A(?:[1-9]|[0-9]{2}|97[1-6]|2[AB]|[A-Z]{2}|[0-9]{2}-[A-Z]{3})\z/i
|
|
38
|
+
# Inert-template fence: TemplateFill::ACTIVE_CONTENT (also enforced by
|
|
39
|
+
# TemplateFill.fill itself).
|
|
40
|
+
TEMPLATE_ACTIVE_CONTENT = TemplateFill::ACTIVE_CONTENT
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# The blank template SVG for a series, or nil. `shape:` defaults to the
|
|
45
|
+
# series' canonical pattern on the server; `region:` picks region art
|
|
46
|
+
# (FR département, DE/AT Land, CH canton).
|
|
47
|
+
def template(code:, series:, shape: nil, region: nil)
|
|
48
|
+
code = code.to_s.downcase
|
|
49
|
+
series = series.to_s
|
|
50
|
+
shape = shape&.to_s&.tr(" ", "_")
|
|
51
|
+
region = region&.to_s
|
|
52
|
+
return nil unless code.match?(TEMPLATE_CODE) && series.match?(TEMPLATE_SERIES)
|
|
53
|
+
return nil unless (shape.nil? || shape.match?(TEMPLATE_SHAPE)) && (region.nil? || region.match?(TEMPLATE_REGION))
|
|
54
|
+
|
|
55
|
+
key = template_cache_key(code, series, shape, region)
|
|
56
|
+
cached = cache_read(key)
|
|
57
|
+
return cached.empty? ? nil : cached if cached.is_a?(String)
|
|
58
|
+
|
|
59
|
+
svg = fetch_template(code, series, shape, region)
|
|
60
|
+
cache_write(key, svg || "", svg ? Vehicles.configuration.plate_template_ttl : TEMPLATE_MISS_TTL)
|
|
61
|
+
svg
|
|
62
|
+
rescue StandardError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# `registration` drawn on its series' real plate, filled locally, or nil
|
|
67
|
+
# when it cannot be: not a plate of that jurisdiction, more than one
|
|
68
|
+
# standard series it could be (German districts, Italian eras: we would
|
|
69
|
+
# rather draw nothing than the wrong plate), or no template. Only the
|
|
70
|
+
# series, the shape and `region:` are sent. `label:` is the accessible
|
|
71
|
+
# name (TemplateFill.fill).
|
|
72
|
+
def render(registration, jurisdiction:, region: nil, label: nil)
|
|
73
|
+
found = match(registration, jurisdiction: jurisdiction)
|
|
74
|
+
series = template_series(found) or return nil
|
|
75
|
+
shape = TemplateFill.shape_of(series.format_serial(found.canon)) or return nil
|
|
76
|
+
svg = template(code: found.jurisdiction.code, series: series.id, shape: shape, region: region) or return nil
|
|
77
|
+
TemplateFill.fill(svg, registration, label: label)
|
|
78
|
+
rescue StandardError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The one series a match's plate is drawn as: the only standard-class
|
|
83
|
+
# series among the hits, else the only hit. Nil when it is ambiguous.
|
|
84
|
+
def template_series(found)
|
|
85
|
+
standard = found.series.select { |series| series.klass == "standard" }
|
|
86
|
+
return standard.first if standard.one?
|
|
87
|
+
|
|
88
|
+
found.series.first if found.series.one?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def fetch_template(code, series, shape, region)
|
|
92
|
+
params = { shape: shape, region: region }.compact
|
|
93
|
+
body = HTTP.get(HTTP.url(format(TEMPLATE_PATH, code: code, series: series), params),
|
|
94
|
+
"Accept" => "image/svg+xml")
|
|
95
|
+
return nil unless body
|
|
96
|
+
|
|
97
|
+
# Net::HTTP hands back binary; the template is UTF-8 ("·" separators).
|
|
98
|
+
svg = body.dup.force_encoding(Encoding::UTF_8)
|
|
99
|
+
return nil unless svg.valid_encoding?
|
|
100
|
+
return nil unless TemplateFill.inert?(svg)
|
|
101
|
+
|
|
102
|
+
svg if TemplateFill.metadata(svg)["version"] == TemplateFill::SCHEMA_VERSION
|
|
103
|
+
rescue TemplateFill::Error, JSON::ParserError
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The filler's schema and the gem version are in the key, so an upgrade
|
|
108
|
+
# never fills a template it was not written for; the base URL too, so a
|
|
109
|
+
# development server's templates never answer for production's.
|
|
110
|
+
def template_cache_key(code, series, shape, region)
|
|
111
|
+
["vehicles/plate-template", "v#{TemplateFill::SCHEMA_VERSION}", Vehicles::VERSION,
|
|
112
|
+
Vehicles.configuration.api_base_url, code, series, shape, region].join("/")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def cache_read(key)
|
|
116
|
+
Vehicles.configuration.plate_template_cache.read(key)
|
|
117
|
+
rescue StandardError
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def cache_write(key, value, ttl)
|
|
122
|
+
Vehicles.configuration.plate_template_cache.write(key, value, expires_in: ttl)
|
|
123
|
+
rescue StandardError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The default plate_template_cache: in-process, thread-safe, bounded.
|
|
128
|
+
class TemplateCache
|
|
129
|
+
MAX_ENTRIES = 256
|
|
130
|
+
|
|
131
|
+
def initialize
|
|
132
|
+
@entries = {}
|
|
133
|
+
@lock = Mutex.new
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def read(key)
|
|
137
|
+
@lock.synchronize do
|
|
138
|
+
value, expires_at = @entries[key]
|
|
139
|
+
next value if expires_at.nil? || expires_at > now
|
|
140
|
+
|
|
141
|
+
@entries.delete(key)
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def write(key, value, expires_in: nil)
|
|
147
|
+
@lock.synchronize do
|
|
148
|
+
@entries.delete(key)
|
|
149
|
+
@entries.shift while @entries.size >= MAX_ENTRIES
|
|
150
|
+
@entries[key] = [value, expires_in && (now + expires_in.to_f)]
|
|
151
|
+
end
|
|
152
|
+
value
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def clear = @lock.synchronize { @entries.clear }
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|