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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a6607eb57e2219a161a11a3a5504d8b5ba3ba727b6a89c27d2a0de65b20dc36
4
- data.tar.gz: 43fc8e683ac3af63d1f2a126313c6a9ec935ff6bacd03ecaa3fda39909bcef13
3
+ metadata.gz: c49275437cb1ae79a0be87613f9c19d1fc3e55220eff15960a0e472f2142f706
4
+ data.tar.gz: 687ae112bbf72c14fdc636ed54470360fde096625b645e3b57afc3611bfeea84
5
5
  SHA512:
6
- metadata.gz: 4e6da102b2aea85d965a601c2539400a4d978ac04723cbc989f8bd867628352ce26d03fa26b181a66ccd652d618f68e13ee5d668a3dac41971058fcacdca25d8
7
- data.tar.gz: 9fa6a655a4c2ed962975e2311920264ba3657311163156843be590e639592fa480715493bfee7e7c9f59019d8ccee60c4c29f95cae65e8fec7828b17ef53f7be
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
- Unlocodes.find('CNSHA') # => #<Unlocodes::Entry code="CNSHA" name="Shanghai Hongqiao International Apt">
37
- Unlocodes['NLRTM'].name # => "Rotterdam" ([] is an alias for find)
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
- Unlocodes.where(country: 'CN').count # => 1670
41
- Unlocodes.where(country: %w[CN HK]).count # => entries in China OR Hong Kong
42
- Unlocodes.where(function: 'B').count # => 17912 (sea ports)
43
- Unlocodes.where(function: 'A').count # => 9009 (airports)
44
- Unlocodes.where(function: %w[B A]).count # => 25136 (port OR airport)
45
- Unlocodes.where(subdivision: 'CNSH').count # => entries in the Shanghai subdivision
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
- Unlocodes.where(country: 'CN', function: 'A').count # => CN airports
52
+ registry.where(country: 'CN', function: 'A').count # => CN airports
49
53
 
50
54
  # Name search — Regexp (substring) or String (case-insensitive equality)
51
- Unlocodes.where(name: /shanghai/i).map(&:code)
52
- Unlocodes.where(name: 'rotterdam').map(&:code)
55
+ registry.where(name: /shanghai/i).map(&:code)
56
+ registry.where(name: 'rotterdam').map(&:code)
53
57
 
54
58
  # Iteration
55
- Unlocodes.each { |e| puts e.code if e.port? }
59
+ registry.each { |e| puts e.code if e.port? }
56
60
 
57
61
  # Country listing
58
- Unlocodes.countries # => ["AD", "AE", ..., "ZW"] (249 codes)
59
- Unlocodes.counts_by_country.first(5) # => [["US", 20852], ["FR", 14325], ...]
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
- Unlocodes.registry.by_country('CN').size # => 1670
63
- Unlocodes.registry.by_function('B').size # => 17912
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 = Unlocodes.find('NLRTM')
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.functions.map(&:code) # => ["B", "R", "T", "A", "P"]
92
- entry.functions.first.description # => "Port (sea)"
93
- entry.coordinates # => #<Unlocodes::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
- Unlocodes.where(function: 'B').count # sea ports
119
- Unlocodes.where(function: %w[B A]).count # port OR airport (union)
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
- Unlocodes.entries.count { |e| e.port? && e.airport? } # => 1785
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 = Unlocodes.find('CNPDG').coordinates # Shanghai Pudong
129
- rotterdam = Unlocodes.find('NLRTM').coordinates
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.registry` in a process pays the parse cost (~3-5 s); subsequent calls are cached.
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
 
@@ -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 (#functions, #coordinates) that convert to {Function} and
13
- # {Coordinates} value types on demand. The LOCODE itself is the 5-character
14
- # composite of `country` (ISO 3166-1 alpha-2) + the 3-character location
15
- # alpha. The `code` attribute is the canonical 5-char string.
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`. Older editions
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
- def functions
31
- (function_codes || []).map { |code| Function.cast(code) }
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)
@@ -1,18 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'forwardable'
4
- require_relative 'loader'
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 default registry is loaded from the vendored dataset bundled with the
10
- # gem (see {.load_default}). Callers can also construct a registry from any
11
- # other source via {.from_entries} or {.load_file}.
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
- from_entries(Loader.load_file(default_data_path))
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(Loader.load_file(path))
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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Unlocodes
4
- VERSION = '0.1.4'
4
+ VERSION = '0.2.0'
5
5
  end
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. The registry loads
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
- # Which edition is bundled? See {Unlocodes.data_tag} (read from
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
- extend SingleForwardable
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
- # @return [Unlocodes::Registry] the process-wide registry, loaded lazily
47
+ # @deprecated Use `Unlocodes::Registry.load_default` and hold the
48
+ # instance yourself. Removal targeted for 0.3.0.
26
49
  def registry
27
- @registry ||= Registry.load_default
50
+ warn "Unlocodes.registry is deprecated. #{DEPRECATION_SUGGESTION}", category: :deprecated, uplevel: 1
51
+ default_registry
28
52
  end
29
53
 
30
- # Reset the process-wide registry. Used by specs to swap fixtures.
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
- # The upstream UNCEFACT vocabulary tag bundled with this gem version
36
- # (e.g. "2025-1"). Read from `lib/unlocodes/data/SOURCE_TAG` at runtime.
37
- # @return [String, nil]
38
- def data_tag
39
- return @data_tag if defined?(@data_tag)
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
- @data_tag = File.read(SOURCE_TAG_PATH, encoding: 'UTF-8').strip
42
- rescue Errno::ENOENT
43
- nil
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
- def_delegators :registry, :find, :where, :each, :size, :count, :countries
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.1.4
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-02 00:00:00.000000000 Z
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:
@@ -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
@@ -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
@@ -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