unlocodes 0.1.4 → 0.2.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/README.adoc +40 -26
- data/lib/unlocodes/entry.rb +26 -11
- data/lib/unlocodes/registry.rb +119 -6
- data/lib/unlocodes/version.rb +1 -1
- data/lib/unlocodes.rb +81 -21
- metadata +2 -5
- data/lib/unlocodes/function.rb +0 -72
- data/lib/unlocodes/loader.rb +0 -139
- data/lib/unlocodes/status.rb +0 -74
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c49275437cb1ae79a0be87613f9c19d1fc3e55220eff15960a0e472f2142f706
|
|
4
|
+
data.tar.gz: 687ae112bbf72c14fdc636ed54470360fde096625b645e3b57afc3611bfeea84
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 548af19b13e7fe5d350556cb31f8afe7638327893eb18654a79cdccbde8c5b0dabdfa5f8b872fbfbfefe273fc22f570a83d8f3290e584c63f0f8b37e2a3b6c06
|
|
7
|
+
data.tar.gz: 278fd26249d2af1836f735b0007d57aab354b31f123db08d29d4855406ea0a7deb32b5108ea9c97aac30bf1097eabafe00d42edb6f6c615da83fa033025a0825
|
data/README.adoc
CHANGED
|
@@ -28,40 +28,54 @@ gem install unlocodes
|
|
|
28
28
|
|
|
29
29
|
== Usage
|
|
30
30
|
|
|
31
|
+
Construct a `Unlocodes::Registry` once, hold onto it, and call its query methods. The gem ships a default registry backed by the bundled data file.
|
|
32
|
+
|
|
31
33
|
[source,ruby]
|
|
32
34
|
----
|
|
33
35
|
require 'unlocodes'
|
|
34
36
|
|
|
37
|
+
registry = Unlocodes::Registry.load_default
|
|
38
|
+
|
|
35
39
|
# Lookup by 5-char LOCODE (case-insensitive)
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
registry.find('CNSHA') # => #<Unlocodes::Entry code="CNSHA" name="...">
|
|
41
|
+
registry['NLRTM'].name # => "Rotterdam" ([] is an alias for find)
|
|
38
42
|
|
|
39
43
|
# Filters — single value or array (any-of)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
registry.where(country: 'CN').count # => 1670
|
|
45
|
+
registry.where(country: %w[CN HK]).count # => entries in China OR Hong Kong
|
|
46
|
+
registry.where(function: 'B').count # => 17912 (sea ports)
|
|
47
|
+
registry.where(function: 'A').count # => 9009 (airports)
|
|
48
|
+
registry.where(function: %w[B A]).count # => 25136 (port OR airport)
|
|
49
|
+
registry.where(subdivision: 'CNSH').count # => entries in the Shanghai subdivision
|
|
46
50
|
|
|
47
51
|
# Filters combine
|
|
48
|
-
|
|
52
|
+
registry.where(country: 'CN', function: 'A').count # => CN airports
|
|
49
53
|
|
|
50
54
|
# Name search — Regexp (substring) or String (case-insensitive equality)
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
registry.where(name: /shanghai/i).map(&:code)
|
|
56
|
+
registry.where(name: 'rotterdam').map(&:code)
|
|
53
57
|
|
|
54
58
|
# Iteration
|
|
55
|
-
|
|
59
|
+
registry.each { |e| puts e.code if e.port? }
|
|
56
60
|
|
|
57
61
|
# Country listing
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
registry.countries # => ["AD", "AE", ..., "ZW"] (249 codes)
|
|
63
|
+
registry.counts_by_country.first(5) # => [["US", 20852], ["FR", 14325], ...]
|
|
60
64
|
|
|
61
65
|
# Indexed lookups (faster than `where` for a single value)
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
registry.by_country('CN').size # => 1670
|
|
67
|
+
registry.by_function('B').size # => 17912
|
|
68
|
+
----
|
|
69
|
+
|
|
70
|
+
To load from a different source (custom file, in-memory entries, etc.):
|
|
71
|
+
|
|
72
|
+
[source,ruby]
|
|
64
73
|
----
|
|
74
|
+
registry = Unlocodes::Registry.load_file('path/to/custom.jsonld')
|
|
75
|
+
registry = Unlocodes::Registry.from_entries([Unlocodes::Entry.new(code: 'CNSHA', ...)])
|
|
76
|
+
----
|
|
77
|
+
|
|
78
|
+
NOTE: The module-level shortcuts `Unlocodes.find(...)`, `Unlocodes.where(...)`, etc. still work as of 0.2.0 but emit a deprecation warning and will be removed in 0.3.0. Migrate to holding your own `Registry` instance.
|
|
65
79
|
|
|
66
80
|
=== What's in an Entry
|
|
67
81
|
|
|
@@ -82,15 +96,15 @@ Convenience predicates and value-type accessors:
|
|
|
82
96
|
|
|
83
97
|
[source,ruby]
|
|
84
98
|
----
|
|
85
|
-
entry =
|
|
99
|
+
entry = registry.find('NLRTM')
|
|
86
100
|
|
|
87
101
|
entry.port? # => true (function code B)
|
|
88
102
|
entry.airport? # => true (function code A)
|
|
89
103
|
entry.rail_terminal? # => true (function code R)
|
|
90
104
|
entry.road_terminal? # => true (function code T)
|
|
91
|
-
entry.
|
|
92
|
-
|
|
93
|
-
entry.coordinates
|
|
105
|
+
entry.function_codes # => ["B", "R", "T", "A", "P"]
|
|
106
|
+
Unlocodes::Entry.function_description('B') # => "Port (sea)"
|
|
107
|
+
entry.coordinates # => #<Unlocodes::Coordinates ...>
|
|
94
108
|
----
|
|
95
109
|
|
|
96
110
|
=== Function codes
|
|
@@ -115,18 +129,18 @@ Use the letters in `where(function: ...)`. Passing an array matches any-of (OR),
|
|
|
115
129
|
|
|
116
130
|
[source,ruby]
|
|
117
131
|
----
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
registry.where(function: 'B').count # sea ports
|
|
133
|
+
registry.where(function: %w[B A]).count # port OR airport (union)
|
|
120
134
|
# For AND (entries that are BOTH port AND airport), filter in Ruby:
|
|
121
|
-
|
|
135
|
+
registry.count { |e| e.port? && e.airport? } # => 1785
|
|
122
136
|
----
|
|
123
137
|
|
|
124
138
|
=== Coordinates and distances
|
|
125
139
|
|
|
126
140
|
[source,ruby]
|
|
127
141
|
----
|
|
128
|
-
shanghai =
|
|
129
|
-
rotterdam =
|
|
142
|
+
shanghai = registry.find('CNPDG').coordinates # Shanghai Pudong
|
|
143
|
+
rotterdam = registry.find('NLRTM').coordinates
|
|
130
144
|
|
|
131
145
|
shanghai.to_s # => "31.2333 121.5000"
|
|
132
146
|
shanghai.distance_to(rotterdam) # => 8922.7 (km, great-circle via haversine)
|
|
@@ -192,7 +206,7 @@ bundle exec rspec spec/path/to_spec.rb:42 # one example
|
|
|
192
206
|
bundle exec rake unlocodes:fetch # refresh bundled data
|
|
193
207
|
----
|
|
194
208
|
|
|
195
|
-
The 39 MB dataset at `lib/unlocodes/data/locode.jsonld` is committed so the gem works offline. The first call to `Unlocodes.
|
|
209
|
+
The 39 MB dataset at `lib/unlocodes/data/locode.jsonld` is committed so the gem works offline. The first call to `Unlocodes::Registry.load_default` in a process pays the parse cost (~3-5 s); subsequent calls reuse the same Registry instance if you hold onto it.
|
|
196
210
|
|
|
197
211
|
== License
|
|
198
212
|
|
data/lib/unlocodes/entry.rb
CHANGED
|
@@ -1,24 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'lutaml/model'
|
|
4
|
-
require_relative 'status'
|
|
5
|
-
require_relative 'function'
|
|
6
4
|
require_relative 'coordinates'
|
|
7
5
|
|
|
8
6
|
module Unlocodes
|
|
9
7
|
# A single UN/LOCODE entry.
|
|
10
8
|
#
|
|
11
9
|
# Stores wire-level fields as `lutaml-model` attributes and exposes typed
|
|
12
|
-
# helpers (#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
10
|
+
# helpers (#coordinates, .function_description) for ergonomic queries. The
|
|
11
|
+
# LOCODE itself is the 5-character composite of `country` (ISO 3166-1
|
|
12
|
+
# alpha-2) + the 3-character location alpha. The `code` attribute is the
|
|
13
|
+
# canonical 5-char string.
|
|
16
14
|
#
|
|
17
15
|
# `latitude` and `longitude` are decimal degrees (WGS-84), populated when
|
|
18
|
-
# the source vocabulary provides `geo:lat` / `geo:long`.
|
|
19
|
-
# published a single coordinate string ("DDMMNDDDMMME"); see
|
|
20
|
-
# {Coordinates.parse} when converting from that form.
|
|
16
|
+
# the source vocabulary provides `geo:lat` / `geo:long`.
|
|
21
17
|
class Entry < Lutaml::Model::Serializable
|
|
18
|
+
# UN/LOCODE manual function classifier letters and their human-readable
|
|
19
|
+
# descriptions. Source: UN/LOCODE manual, "Code list for function".
|
|
20
|
+
FUNCTION_DESCRIPTIONS = {
|
|
21
|
+
'B' => 'Port (sea)',
|
|
22
|
+
'R' => 'Rail terminal',
|
|
23
|
+
'T' => 'Road terminal',
|
|
24
|
+
'A' => 'Airport',
|
|
25
|
+
'P' => 'Postal exchange office',
|
|
26
|
+
'I' => 'Inland water transport (river)',
|
|
27
|
+
'F' => 'Ferry port',
|
|
28
|
+
'V' => 'Pipeline',
|
|
29
|
+
'O' => 'Other (border crossing, etc.)',
|
|
30
|
+
'0' => 'Function not known',
|
|
31
|
+
'1' => 'Not provided'
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
22
34
|
attribute :code, :string
|
|
23
35
|
attribute :country, :string
|
|
24
36
|
attribute :subdivision, :string
|
|
@@ -27,8 +39,11 @@ module Unlocodes
|
|
|
27
39
|
attribute :latitude, :float
|
|
28
40
|
attribute :longitude, :float
|
|
29
41
|
|
|
30
|
-
|
|
31
|
-
|
|
42
|
+
# Look up the human-readable description for a function letter.
|
|
43
|
+
# @param letter [String] single-letter function code (case-insensitive)
|
|
44
|
+
# @return [String, nil] description, or nil if the letter is unknown
|
|
45
|
+
def self.function_description(letter)
|
|
46
|
+
FUNCTION_DESCRIPTIONS[letter.to_s.upcase]
|
|
32
47
|
end
|
|
33
48
|
|
|
34
49
|
def function?(letter)
|
data/lib/unlocodes/registry.rb
CHANGED
|
@@ -1,18 +1,42 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'forwardable'
|
|
4
|
-
|
|
4
|
+
require 'json'
|
|
5
|
+
require_relative 'entry'
|
|
5
6
|
|
|
6
7
|
module Unlocodes
|
|
7
8
|
# In-memory, lazily-indexed registry over a set of {Entry} instances.
|
|
8
9
|
#
|
|
9
|
-
# The
|
|
10
|
-
#
|
|
11
|
-
#
|
|
10
|
+
# The Registry owns three concerns end-to-end:
|
|
11
|
+
#
|
|
12
|
+
# 1. Loading — turning the bundled JSON-LD file into Entry instances.
|
|
13
|
+
# 2. Indexing — building fast lookups by code, country, function.
|
|
14
|
+
# 3. Querying — the public find/where/countries surface.
|
|
15
|
+
#
|
|
16
|
+
# Loading and indexing are private; callers (and tests) cross the seam
|
|
17
|
+
# at the query methods. The bundled dataset is parsed once per
|
|
18
|
+
# Registry instance; construct a new Registry from a different source
|
|
19
|
+
# via {.from_entries} or {.load_file}.
|
|
12
20
|
class Registry
|
|
13
21
|
include Enumerable
|
|
14
22
|
extend Forwardable
|
|
15
23
|
|
|
24
|
+
# UN/LOCODE function digit (used in `unlcdf:1`..`unlcdf:9` references
|
|
25
|
+
# in the JSON-LD) → letter per the UN/LOCODE manual.
|
|
26
|
+
FUNCTION_DIGIT_TO_LETTER = {
|
|
27
|
+
'1' => 'B', # sea port
|
|
28
|
+
'2' => 'R', # rail terminal
|
|
29
|
+
'3' => 'T', # road terminal
|
|
30
|
+
'4' => 'A', # airport
|
|
31
|
+
'5' => 'P', # postal exchange office
|
|
32
|
+
'6' => 'I', # inland water transport
|
|
33
|
+
'7' => 'F', # ferry port
|
|
34
|
+
'8' => 'V', # pipeline
|
|
35
|
+
'9' => 'O' # other / border crossing
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
UNLOCODE_TYPE_SUFFIX = 'UNLOCODE'
|
|
39
|
+
|
|
16
40
|
attr_reader :entries
|
|
17
41
|
|
|
18
42
|
def_delegators :@entries, :size, :count, :to_a, :empty?
|
|
@@ -39,14 +63,14 @@ module Unlocodes
|
|
|
39
63
|
# Load the bundled dataset shipped inside the gem.
|
|
40
64
|
# @return [Registry]
|
|
41
65
|
def load_default
|
|
42
|
-
|
|
66
|
+
load_file(default_data_path)
|
|
43
67
|
end
|
|
44
68
|
|
|
45
69
|
# Load a specific JSON-LD file from disk.
|
|
46
70
|
# @param path [String]
|
|
47
71
|
# @return [Registry]
|
|
48
72
|
def load_file(path)
|
|
49
|
-
from_entries(
|
|
73
|
+
from_entries(parse_json(File.read(path)))
|
|
50
74
|
end
|
|
51
75
|
|
|
52
76
|
# Build a registry from an existing list of entries.
|
|
@@ -56,11 +80,100 @@ module Unlocodes
|
|
|
56
80
|
new(entries)
|
|
57
81
|
end
|
|
58
82
|
|
|
83
|
+
# Parse a JSON-LD string into Entry instances.
|
|
84
|
+
# @param json [String]
|
|
85
|
+
# @return [Array<Unlocodes::Entry>]
|
|
86
|
+
def parse_json(json)
|
|
87
|
+
parse_hash(JSON.parse(json, symbolize_names: false))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Parse a pre-parsed JSON-LD hash into Entry instances.
|
|
91
|
+
# @param data [Hash]
|
|
92
|
+
# @return [Array<Unlocodes::Entry>]
|
|
93
|
+
def parse_hash(data)
|
|
94
|
+
extract_graph(data).filter_map { |node| build_entry(node) if unlocode_node?(node) }
|
|
95
|
+
end
|
|
96
|
+
|
|
59
97
|
private
|
|
60
98
|
|
|
61
99
|
def default_data_path
|
|
62
100
|
File.expand_path('data/locode.jsonld', __dir__)
|
|
63
101
|
end
|
|
102
|
+
|
|
103
|
+
def extract_graph(data)
|
|
104
|
+
return [] unless data.is_a?(Hash)
|
|
105
|
+
|
|
106
|
+
graph = data['@graph']
|
|
107
|
+
if graph.is_a?(Array)
|
|
108
|
+
graph
|
|
109
|
+
elsif data.key?('@id') || data.key?('rdf:value')
|
|
110
|
+
[data]
|
|
111
|
+
else
|
|
112
|
+
[]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def unlocode_node?(node)
|
|
117
|
+
return false unless node.is_a?(Hash)
|
|
118
|
+
|
|
119
|
+
types = Array(node['@type']).flat_map { |t| t.to_s.split(/[,\s]+/) }
|
|
120
|
+
types.empty? || types.any? { |t| t.end_with?(UNLOCODE_TYPE_SUFFIX) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def build_entry(node)
|
|
124
|
+
Entry.new(
|
|
125
|
+
code: strip_id(node['rdf:value']) || strip_id(node['@id']),
|
|
126
|
+
country: strip_prefixed_id(node['unlcdv:countryCode']),
|
|
127
|
+
subdivision: strip_prefixed_id(node['unlcdv:countrySubdivision']),
|
|
128
|
+
name: pick_label(node['rdfs:label']) || pick_label(node['rdfs:seeAlso']),
|
|
129
|
+
function_codes: pick_function_codes(node['unlcdv:functions']),
|
|
130
|
+
latitude: pick_float(node['geo:lat']),
|
|
131
|
+
longitude: pick_float(node['geo:long'])
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def strip_prefixed_id(value)
|
|
136
|
+
return nil if value.nil?
|
|
137
|
+
|
|
138
|
+
case value
|
|
139
|
+
when Hash then strip_id(value['@id'])
|
|
140
|
+
when Array then strip_id(value.first&.dig('@id'))
|
|
141
|
+
when String then strip_id(value)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def strip_id(value)
|
|
146
|
+
return nil if value.nil? || value.to_s.empty?
|
|
147
|
+
|
|
148
|
+
value.to_s.split(':').last
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def pick_label(value)
|
|
152
|
+
return value unless value.is_a?(Hash) || value.is_a?(Array)
|
|
153
|
+
|
|
154
|
+
entries = value.is_a?(Array) ? value : [value]
|
|
155
|
+
picked = entries.find { |v| v.is_a?(Hash) && v['@language'] == 'en' } || entries.first
|
|
156
|
+
picked.is_a?(Hash) ? picked['@value'] : picked
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def pick_function_codes(value)
|
|
160
|
+
return [] if value.nil?
|
|
161
|
+
|
|
162
|
+
entries = value.is_a?(Array) ? value : [value]
|
|
163
|
+
entries.filter_map do |entry|
|
|
164
|
+
id = strip_prefixed_id(entry)
|
|
165
|
+
next if id.nil?
|
|
166
|
+
|
|
167
|
+
FUNCTION_DIGIT_TO_LETTER.fetch(id, id)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def pick_float(value)
|
|
172
|
+
return nil if value.nil?
|
|
173
|
+
return value['@value']&.to_f if value.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
value.to_f
|
|
176
|
+
end
|
|
64
177
|
end
|
|
65
178
|
|
|
66
179
|
# Exact-code lookup.
|
data/lib/unlocodes/version.rb
CHANGED
data/lib/unlocodes.rb
CHANGED
|
@@ -10,47 +10,107 @@ require_relative 'unlocodes/version'
|
|
|
10
10
|
#
|
|
11
11
|
# The dataset is sourced from the UNECE/UNCEFACT LOCODE vocabulary published at
|
|
12
12
|
# https://opensource.unicc.org/un/unece/uncefact/vocab-locode and distributed
|
|
13
|
-
# by this gem as a bundled, offline JSON-LD representation.
|
|
14
|
-
# once per process and exposes a typed query API over `Unlocodes::Entry`
|
|
15
|
-
# instances.
|
|
13
|
+
# by this gem as a bundled, offline JSON-LD representation.
|
|
16
14
|
#
|
|
17
|
-
#
|
|
15
|
+
# Each caller should construct and hold its own {Unlocodes::Registry}:
|
|
16
|
+
#
|
|
17
|
+
# registry = Unlocodes::Registry.load_default
|
|
18
|
+
# registry.find('CNSHA')
|
|
19
|
+
#
|
|
20
|
+
# The module-level `Unlocodes.find` / `Unlocodes.where` / etc. shortcuts are
|
|
21
|
+
# deprecated since 0.2.0 (they rely on a process-wide singleton). They still
|
|
22
|
+
# work today but emit a `Kernel#warn` deprecation and will be removed in
|
|
23
|
+
# 0.3.0.
|
|
24
|
+
#
|
|
25
|
+
# Which edition is bundled? See {.data_tag} (read from
|
|
18
26
|
# `lib/unlocodes/data/SOURCE_TAG`).
|
|
19
27
|
module Unlocodes
|
|
20
|
-
|
|
28
|
+
DEPRECATION_SUGGESTION = 'Use Unlocodes::Registry.load_default instead ' \
|
|
29
|
+
'(or construct from a custom data source). ' \
|
|
30
|
+
'Removal targeted for 0.3.0.'
|
|
21
31
|
|
|
22
32
|
SOURCE_TAG_PATH = File.expand_path('unlocodes/data/SOURCE_TAG', __dir__)
|
|
23
33
|
|
|
34
|
+
# The upstream UNCEFACT vocabulary tag bundled with this gem version
|
|
35
|
+
# (e.g. "2025-1"). Read from `lib/unlocodes/data/SOURCE_TAG` at runtime.
|
|
36
|
+
# Pure function over a known file location — does not touch the registry.
|
|
37
|
+
# @return [String, nil]
|
|
38
|
+
def self.data_tag
|
|
39
|
+
return @data_tag if defined?(@data_tag)
|
|
40
|
+
|
|
41
|
+
@data_tag = File.read(SOURCE_TAG_PATH, encoding: 'UTF-8').strip
|
|
42
|
+
rescue Errno::ENOENT
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
24
46
|
class << self
|
|
25
|
-
# @
|
|
47
|
+
# @deprecated Use `Unlocodes::Registry.load_default` and hold the
|
|
48
|
+
# instance yourself. Removal targeted for 0.3.0.
|
|
26
49
|
def registry
|
|
27
|
-
|
|
50
|
+
warn "Unlocodes.registry is deprecated. #{DEPRECATION_SUGGESTION}", category: :deprecated, uplevel: 1
|
|
51
|
+
default_registry
|
|
28
52
|
end
|
|
29
53
|
|
|
30
|
-
#
|
|
54
|
+
# @deprecated Tests that swap the global registry should construct a
|
|
55
|
+
# `Unlocodes::Registry` directly and inject it. Removal targeted for 0.3.0.
|
|
31
56
|
def reset_registry!
|
|
57
|
+
warn "Unlocodes.reset_registry! is deprecated. #{DEPRECATION_SUGGESTION}", category: :deprecated, uplevel: 1
|
|
32
58
|
@registry = nil
|
|
33
59
|
end
|
|
34
60
|
|
|
35
|
-
#
|
|
36
|
-
# (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
61
|
+
# @deprecated Forwarded to the global registry; construct your own and
|
|
62
|
+
# call `registry.find(code)`. Removal targeted for 0.3.0.
|
|
63
|
+
def find(code)
|
|
64
|
+
warn "Unlocodes.find is deprecated. #{DEPRECATION_SUGGESTION}", category: :deprecated, uplevel: 1
|
|
65
|
+
default_registry.find(code)
|
|
66
|
+
end
|
|
40
67
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
68
|
+
# @deprecated Construct your own registry and call `registry.where(filters)`.
|
|
69
|
+
# Removal targeted for 0.3.0.
|
|
70
|
+
def where(filters)
|
|
71
|
+
warn "Unlocodes.where is deprecated. #{DEPRECATION_SUGGESTION}", category: :deprecated, uplevel: 1
|
|
72
|
+
default_registry.where(filters)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @deprecated Construct your own registry and call `registry.each`.
|
|
76
|
+
# Removal targeted for 0.3.0.
|
|
77
|
+
def each(&)
|
|
78
|
+
warn "Unlocodes.each is deprecated. #{DEPRECATION_SUGGESTION}", category: :deprecated, uplevel: 1
|
|
79
|
+
default_registry.each(&)
|
|
44
80
|
end
|
|
45
|
-
end
|
|
46
81
|
|
|
47
|
-
|
|
82
|
+
# @deprecated Construct your own registry and call `registry.size`.
|
|
83
|
+
# Removal targeted for 0.3.0.
|
|
84
|
+
def size
|
|
85
|
+
warn "Unlocodes.size is deprecated. #{DEPRECATION_SUGGESTION}", category: :deprecated, uplevel: 1
|
|
86
|
+
default_registry.size
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @deprecated Construct your own registry and call `registry.count`.
|
|
90
|
+
# Removal targeted for 0.3.0.
|
|
91
|
+
def count
|
|
92
|
+
warn "Unlocodes.count is deprecated. #{DEPRECATION_SUGGESTION}", category: :deprecated, uplevel: 1
|
|
93
|
+
default_registry.count
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @deprecated Construct your own registry and call `registry.countries`.
|
|
97
|
+
# Removal targeted for 0.3.0.
|
|
98
|
+
def countries
|
|
99
|
+
warn "Unlocodes.countries is deprecated. #{DEPRECATION_SUGGESTION}", category: :deprecated, uplevel: 1
|
|
100
|
+
default_registry.countries
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# Internal memoised default registry. Used by the deprecated module-level
|
|
106
|
+
# shortcuts. Direct callers should construct their own Registry instead.
|
|
107
|
+
def default_registry
|
|
108
|
+
@default_registry ||= Registry.load_default
|
|
109
|
+
end
|
|
110
|
+
end
|
|
48
111
|
|
|
49
|
-
autoload :Status, 'unlocodes/status'
|
|
50
|
-
autoload :Function, 'unlocodes/function'
|
|
51
112
|
autoload :Coordinates, 'unlocodes/coordinates'
|
|
52
113
|
autoload :Entry, 'unlocodes/entry'
|
|
53
|
-
autoload :Loader, 'unlocodes/loader'
|
|
54
114
|
autoload :Registry, 'unlocodes/registry'
|
|
55
115
|
autoload :Data, 'unlocodes/data'
|
|
56
116
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: unlocodes
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: json
|
|
@@ -59,10 +59,7 @@ files:
|
|
|
59
59
|
- lib/unlocodes/data/fetcher.rb
|
|
60
60
|
- lib/unlocodes/data/locode.jsonld
|
|
61
61
|
- lib/unlocodes/entry.rb
|
|
62
|
-
- lib/unlocodes/function.rb
|
|
63
|
-
- lib/unlocodes/loader.rb
|
|
64
62
|
- lib/unlocodes/registry.rb
|
|
65
|
-
- lib/unlocodes/status.rb
|
|
66
63
|
- lib/unlocodes/version.rb
|
|
67
64
|
homepage: https://github.com/metanorma/unlocodes
|
|
68
65
|
licenses:
|
data/lib/unlocodes/function.rb
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Unlocodes
|
|
4
|
-
# UN/LOCODE function classifier.
|
|
5
|
-
#
|
|
6
|
-
# Single-letter codes identifying the transport function a LOCODE serves.
|
|
7
|
-
# A single LOCODE entry can carry several function codes simultaneously
|
|
8
|
-
# (e.g. "BA" = sea port and airport).
|
|
9
|
-
# Source: UN/LOCODE manual, "Code list for function".
|
|
10
|
-
#
|
|
11
|
-
# Plain value wrapper rather than a `Lutaml::Model::Type::Value` subclass:
|
|
12
|
-
# the wire-level code letters live on the {Entry} as a `:string` collection,
|
|
13
|
-
# and {Entry#functions} wraps each on demand.
|
|
14
|
-
class Function
|
|
15
|
-
DESCRIPTIONS = {
|
|
16
|
-
'B' => 'Port (sea)',
|
|
17
|
-
'R' => 'Rail terminal',
|
|
18
|
-
'T' => 'Road terminal',
|
|
19
|
-
'A' => 'Airport',
|
|
20
|
-
'P' => 'Postal exchange office',
|
|
21
|
-
'I' => 'Inland water transport (river)',
|
|
22
|
-
'F' => 'Ferry port',
|
|
23
|
-
'V' => 'Pipeline',
|
|
24
|
-
'O' => 'Other (border crossing, etc.)',
|
|
25
|
-
'0' => 'Function not known',
|
|
26
|
-
'1' => 'Not provided'
|
|
27
|
-
}.freeze
|
|
28
|
-
|
|
29
|
-
attr_reader :code
|
|
30
|
-
|
|
31
|
-
def initialize(code)
|
|
32
|
-
@code = code.to_s.upcase
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
class << self
|
|
36
|
-
# @return [Function, nil] nil for nil input
|
|
37
|
-
def cast(value)
|
|
38
|
-
return nil if value.nil?
|
|
39
|
-
return value if value.is_a?(Function)
|
|
40
|
-
|
|
41
|
-
new(value)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def description(code)
|
|
45
|
-
DESCRIPTIONS[code.to_s.upcase]
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def known?(code)
|
|
49
|
-
DESCRIPTIONS.key?(code.to_s.upcase)
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def description
|
|
54
|
-
self.class.description(@code)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def known?
|
|
58
|
-
self.class.known?(@code)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def to_s
|
|
62
|
-
@code
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def ==(other)
|
|
66
|
-
other.is_a?(Function) && code == other.code
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
alias eql? ==
|
|
70
|
-
alias hash code
|
|
71
|
-
end
|
|
72
|
-
end
|
data/lib/unlocodes/loader.rb
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'json'
|
|
4
|
-
require_relative 'entry'
|
|
5
|
-
|
|
6
|
-
module Unlocodes
|
|
7
|
-
# Parses a UN/LOCODE JSON-LD vocabulary file into {Entry} instances.
|
|
8
|
-
#
|
|
9
|
-
# The UNCEFACT vocabulary (`vocab/unlocode.jsonld`) is a single JSON-LD
|
|
10
|
-
# document whose `@graph` array contains one `unlcdv:UNLOCODE` resource per
|
|
11
|
-
# LOCODE. Each resource uses these wire names:
|
|
12
|
-
#
|
|
13
|
-
# @id → "unlcd:CNSHA"
|
|
14
|
-
# @type → "unlcdv:UNLOCODE"
|
|
15
|
-
# rdf:value → "CNSHA" (5-char LOCODE)
|
|
16
|
-
# rdfs:label → {"@language":"en","@value":..} (sometimes an array)
|
|
17
|
-
# unlcdv:countryCode → {"@id":"unlcdc:CN"} (strip prefix)
|
|
18
|
-
# unlcdv:countrySubdivision → {"@id":"unlcds:CNSH"} (strip prefix)
|
|
19
|
-
# unlcdv:functions → {"@id":"unlcdf:4"} or [...] (strip prefix; numeric → letter)
|
|
20
|
-
# geo:lat / geo:long → {"@type":"xsd:float","@value":"42.5"}
|
|
21
|
-
#
|
|
22
|
-
# When UNCEFACT publishes new wire names, update the constants below — the
|
|
23
|
-
# Entry model itself does not change.
|
|
24
|
-
class Loader
|
|
25
|
-
FUNCTION_DIGIT_TO_LETTER = {
|
|
26
|
-
'1' => 'B', # sea port
|
|
27
|
-
'2' => 'R', # rail terminal
|
|
28
|
-
'3' => 'T', # road terminal
|
|
29
|
-
'4' => 'A', # airport
|
|
30
|
-
'5' => 'P', # postal exchange office
|
|
31
|
-
'6' => 'I', # inland water transport
|
|
32
|
-
'7' => 'F', # ferry port
|
|
33
|
-
'8' => 'V', # pipeline
|
|
34
|
-
'9' => 'O' # other / border crossing
|
|
35
|
-
}.freeze
|
|
36
|
-
|
|
37
|
-
UNLOCODE_TYPE_SUFFIX = 'UNLOCODE'
|
|
38
|
-
|
|
39
|
-
class << self
|
|
40
|
-
# Load entries from a file path on disk.
|
|
41
|
-
# @param path [String] absolute or relative path to a JSON-LD file
|
|
42
|
-
# @return [Array<Unlocodes::Entry>]
|
|
43
|
-
def load_file(path)
|
|
44
|
-
load_json(File.read(path))
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Load entries from a JSON-LD string.
|
|
48
|
-
# @param json [String] a JSON-LD document
|
|
49
|
-
# @return [Array<Unlocodes::Entry>]
|
|
50
|
-
def load_json(json)
|
|
51
|
-
parse(JSON.parse(json, symbolize_names: false))
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Load entries from a pre-parsed JSON-LD hash.
|
|
55
|
-
# @param data [Hash] parsed JSON-LD
|
|
56
|
-
# @return [Array<Unlocodes::Entry>]
|
|
57
|
-
def parse(data)
|
|
58
|
-
extract_graph(data).filter_map { |node| build_entry(node) if unlocode_node?(node) }
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
private
|
|
62
|
-
|
|
63
|
-
def extract_graph(data)
|
|
64
|
-
return [] unless data.is_a?(Hash)
|
|
65
|
-
|
|
66
|
-
graph = data['@graph']
|
|
67
|
-
if graph.is_a?(Array)
|
|
68
|
-
graph
|
|
69
|
-
elsif data.key?('@id') || data.key?('rdf:value')
|
|
70
|
-
[data]
|
|
71
|
-
else
|
|
72
|
-
[]
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def unlocode_node?(node)
|
|
77
|
-
return false unless node.is_a?(Hash)
|
|
78
|
-
|
|
79
|
-
types = Array(node['@type']).flat_map { |t| t.to_s.split(/[,\s]+/) }
|
|
80
|
-
types.empty? || types.any? { |t| t.end_with?(UNLOCODE_TYPE_SUFFIX) }
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def build_entry(node)
|
|
84
|
-
Entry.new(
|
|
85
|
-
code: strip_id(node['rdf:value']) || strip_id(node['@id']),
|
|
86
|
-
country: strip_prefixed_id(node['unlcdv:countryCode']),
|
|
87
|
-
subdivision: strip_prefixed_id(node['unlcdv:countrySubdivision']),
|
|
88
|
-
name: pick_label(node['rdfs:label']) || pick_label(node['rdfs:seeAlso']),
|
|
89
|
-
function_codes: pick_function_codes(node['unlcdv:functions']),
|
|
90
|
-
latitude: pick_float(node['geo:lat']),
|
|
91
|
-
longitude: pick_float(node['geo:long'])
|
|
92
|
-
)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def strip_prefixed_id(value)
|
|
96
|
-
return nil if value.nil?
|
|
97
|
-
|
|
98
|
-
case value
|
|
99
|
-
when Hash then strip_id(value['@id'])
|
|
100
|
-
when Array then strip_id(value.first&.dig('@id'))
|
|
101
|
-
when String then strip_id(value)
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def strip_id(value)
|
|
106
|
-
return nil if value.nil? || value.to_s.empty?
|
|
107
|
-
|
|
108
|
-
value.to_s.split(':').last
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def pick_label(value)
|
|
112
|
-
return value unless value.is_a?(Hash) || value.is_a?(Array)
|
|
113
|
-
|
|
114
|
-
entries = value.is_a?(Array) ? value : [value]
|
|
115
|
-
picked = entries.find { |v| v.is_a?(Hash) && v['@language'] == 'en' } || entries.first
|
|
116
|
-
picked.is_a?(Hash) ? picked['@value'] : picked
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def pick_function_codes(value)
|
|
120
|
-
return [] if value.nil?
|
|
121
|
-
|
|
122
|
-
entries = value.is_a?(Array) ? value : [value]
|
|
123
|
-
entries.filter_map do |entry|
|
|
124
|
-
id = strip_prefixed_id(entry)
|
|
125
|
-
next if id.nil?
|
|
126
|
-
|
|
127
|
-
FUNCTION_DIGIT_TO_LETTER.fetch(id, id)
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def pick_float(value)
|
|
132
|
-
return nil if value.nil?
|
|
133
|
-
return value['@value']&.to_f if value.is_a?(Hash)
|
|
134
|
-
|
|
135
|
-
value.to_f
|
|
136
|
-
end
|
|
137
|
-
end
|
|
138
|
-
end
|
|
139
|
-
end
|
data/lib/unlocodes/status.rb
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Unlocodes
|
|
4
|
-
# UN/LOCODE status (change indicator).
|
|
5
|
-
#
|
|
6
|
-
# Two-letter codes documenting how a LOCODE entry was last amended relative
|
|
7
|
-
# to the previous edition. Source: UN/LOCODE manual, "Code list for status".
|
|
8
|
-
#
|
|
9
|
-
# Plain value wrapper rather than a `Lutaml::Model::Type::Value` subclass:
|
|
10
|
-
# the wire-level code string lives on the {Entry} as a `:string` attribute,
|
|
11
|
-
# and {Entry#status} wraps it on demand.
|
|
12
|
-
class Status
|
|
13
|
-
DESCRIPTIONS = {
|
|
14
|
-
'AA' => 'Approved',
|
|
15
|
-
'AC' => 'Added, alternate name',
|
|
16
|
-
'AD' => 'Added, different name',
|
|
17
|
-
'AM' => 'Added, modified name',
|
|
18
|
-
'AQ' => 'Approved, alternate name',
|
|
19
|
-
'AS' => 'Approved, secondary name',
|
|
20
|
-
'RL' => 'Replaced, linked',
|
|
21
|
-
'RN' => 'Replaced, not linked',
|
|
22
|
-
'RS' => 'Replaced, secondary name',
|
|
23
|
-
'RT' => 'Replaced, temporary',
|
|
24
|
-
'UA' => 'Updated, alternate name',
|
|
25
|
-
'UX' => 'Updated, secondary name',
|
|
26
|
-
'XX' => 'Deleted, no replacement',
|
|
27
|
-
'RJ' => 'Rejected',
|
|
28
|
-
'RQ' => 'Requested'
|
|
29
|
-
}.freeze
|
|
30
|
-
|
|
31
|
-
attr_reader :code
|
|
32
|
-
|
|
33
|
-
def initialize(code)
|
|
34
|
-
@code = code.to_s.upcase
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
class << self
|
|
38
|
-
# @return [Status, nil] nil for nil input
|
|
39
|
-
def cast(value)
|
|
40
|
-
return nil if value.nil?
|
|
41
|
-
return value if value.is_a?(Status)
|
|
42
|
-
|
|
43
|
-
new(value)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def description(code)
|
|
47
|
-
DESCRIPTIONS[code.to_s.upcase]
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def known?(code)
|
|
51
|
-
DESCRIPTIONS.key?(code.to_s.upcase)
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def description
|
|
56
|
-
self.class.description(@code)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def known?
|
|
60
|
-
self.class.known?(@code)
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def to_s
|
|
64
|
-
@code
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def ==(other)
|
|
68
|
-
other.is_a?(Status) && code == other.code
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
alias eql? ==
|
|
72
|
-
alias hash code
|
|
73
|
-
end
|
|
74
|
-
end
|