swiss-netex 1.0.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 +7 -0
- data/CHANGELOG.md +77 -0
- data/LICENSE +21 -0
- data/README.md +237 -0
- data/exe/swiss-netex +6 -0
- data/lib/swiss_netex/catalog.rb +41 -0
- data/lib/swiss_netex/cli.rb +546 -0
- data/lib/swiss_netex/dataset_page.rb +90 -0
- data/lib/swiss_netex/download.rb +239 -0
- data/lib/swiss_netex/error.rb +5 -0
- data/lib/swiss_netex/extract.rb +56 -0
- data/lib/swiss_netex/filter/common_frame.rb +194 -0
- data/lib/swiss_netex/filter/frame_worker.rb +188 -0
- data/lib/swiss_netex/filter/frames.rb +126 -0
- data/lib/swiss_netex/filter/refs.rb +106 -0
- data/lib/swiss_netex/filter/service_lines.rb +139 -0
- data/lib/swiss_netex/filter/timetable.rb +387 -0
- data/lib/swiss_netex/filter/timetable_batch.rb +330 -0
- data/lib/swiss_netex/filter/xml_sieve.rb +189 -0
- data/lib/swiss_netex/filter.rb +346 -0
- data/lib/swiss_netex/http.rb +130 -0
- data/lib/swiss_netex/lines.rb +161 -0
- data/lib/swiss_netex/operators.rb +254 -0
- data/lib/swiss_netex/package.rb +145 -0
- data/lib/swiss_netex/package_source.rb +30 -0
- data/lib/swiss_netex/resource_frame.rb +73 -0
- data/lib/swiss_netex/tsv.rb +53 -0
- data/lib/swiss_netex/version.rb +5 -0
- data/lib/swiss_netex.rb +20 -0
- data/swiss-netex.gemspec +40 -0
- metadata +104 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
module SwissNetex
|
|
7
|
+
# Streaming SERVICE `Line` inventory for discovery (`lines` CLI).
|
|
8
|
+
#
|
|
9
|
+
# Optional `--operator` / `--line` aliases narrow the list (same alias rules as
|
|
10
|
+
# filter). Does not load TIMETABLE shards.
|
|
11
|
+
class Lines
|
|
12
|
+
HEADERS = %w[line_id public_code slnid operator_ref name].freeze
|
|
13
|
+
|
|
14
|
+
Entry = Data.define(
|
|
15
|
+
:id, :public_code, :short_name, :slnid, :operator_ref, :name
|
|
16
|
+
) do
|
|
17
|
+
# Discovery columns (short_name is match-only, not exported).
|
|
18
|
+
def to_row
|
|
19
|
+
[id, public_code, slnid, operator_ref, name]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.load(path, operators: [], lines: [])
|
|
24
|
+
new(path, operators: operators, lines: lines).call
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(path, operators: [], lines: [])
|
|
28
|
+
@path = path.to_s
|
|
29
|
+
@operator_aliases = Array(operators)
|
|
30
|
+
@line_aliases = normalize_aliases(lines)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call
|
|
34
|
+
raise Error, "Missing --from package path" if @path.empty?
|
|
35
|
+
raise Error, "Package path not found: #{@path}" unless File.exist?(@path)
|
|
36
|
+
|
|
37
|
+
operator_ids = resolve_operator_ids
|
|
38
|
+
entries = []
|
|
39
|
+
Package.open(@path) do |pkg|
|
|
40
|
+
pkg.open_one(:service) do |io|
|
|
41
|
+
each_line(io) do |entry|
|
|
42
|
+
next unless keep?(entry, operator_ids)
|
|
43
|
+
|
|
44
|
+
entries << entry
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
sort_entries(entries)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def keep?(entry, operator_ids)
|
|
54
|
+
return false if operator_ids && !operator_ids.key?(entry.operator_ref)
|
|
55
|
+
return true if @line_aliases.empty?
|
|
56
|
+
|
|
57
|
+
line_matches_alias?(entry)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def line_matches_alias?(entry)
|
|
61
|
+
[
|
|
62
|
+
entry.id,
|
|
63
|
+
entry.public_code,
|
|
64
|
+
entry.short_name,
|
|
65
|
+
entry.slnid,
|
|
66
|
+
entry.name
|
|
67
|
+
].compact.map { |value| normalize(value) }.any? { |key| @line_aliases.key?(key) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def resolve_operator_ids
|
|
71
|
+
return nil if @operator_aliases.empty?
|
|
72
|
+
|
|
73
|
+
ids = Operators.load(@path).resolve(@operator_aliases)
|
|
74
|
+
ids.to_h { |id| [id, true] }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def each_line(source)
|
|
78
|
+
reader = Nokogiri::XML::Reader(to_io(source))
|
|
79
|
+
while reader.read
|
|
80
|
+
next unless line_element?(reader)
|
|
81
|
+
|
|
82
|
+
entry = parse_line(reader.outer_xml)
|
|
83
|
+
yield entry if entry
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def line_element?(reader)
|
|
88
|
+
reader.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT &&
|
|
89
|
+
reader.local_name == "Line"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parse_line(xml)
|
|
93
|
+
root = Nokogiri::XML(xml).root
|
|
94
|
+
return unless root
|
|
95
|
+
|
|
96
|
+
id = root["id"].to_s.strip
|
|
97
|
+
return if id.empty?
|
|
98
|
+
|
|
99
|
+
Entry.new(
|
|
100
|
+
id: id,
|
|
101
|
+
public_code: text_at(root, "PublicCode"),
|
|
102
|
+
short_name: text_at(root, "ShortName"),
|
|
103
|
+
slnid: key_value(root, "SLNID"),
|
|
104
|
+
operator_ref: attr_ref(root, "OperatorRef"),
|
|
105
|
+
name: text_at(root, "Name")
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def sort_entries(entries)
|
|
110
|
+
entries.sort_by do |entry|
|
|
111
|
+
[
|
|
112
|
+
entry.operator_ref.to_s,
|
|
113
|
+
entry.public_code.to_s,
|
|
114
|
+
entry.id
|
|
115
|
+
]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def attr_ref(node, element_name)
|
|
120
|
+
child = node.at_xpath(".//*[local-name()=#{element_name.inspect}]")
|
|
121
|
+
blank_to_nil(child&.[]("ref")&.strip)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def text_at(node, element_name)
|
|
125
|
+
child = node.at_xpath(".//*[local-name()=#{element_name.inspect}]")
|
|
126
|
+
blank_to_nil(child&.text&.strip)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def key_value(node, key_name)
|
|
130
|
+
node.xpath(".//*[local-name()='KeyValue']").each do |kv|
|
|
131
|
+
key = kv.at_xpath(".//*[local-name()='Key']")&.text&.strip
|
|
132
|
+
next unless key&.casecmp?(key_name)
|
|
133
|
+
|
|
134
|
+
return blank_to_nil(kv.at_xpath(".//*[local-name()='Value']")&.text&.strip)
|
|
135
|
+
end
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def blank_to_nil(text)
|
|
140
|
+
text.nil? || text.empty? ? nil : text
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def normalize_aliases(aliases)
|
|
144
|
+
Array(aliases).each_with_object({}) do |value, hash|
|
|
145
|
+
key = normalize(value)
|
|
146
|
+
hash[key] = true unless key.empty?
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def normalize(value)
|
|
151
|
+
value.to_s.strip.downcase
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def to_io(source)
|
|
155
|
+
return StringIO.new(source) if source.is_a?(String)
|
|
156
|
+
return source if source.respond_to?(:read)
|
|
157
|
+
|
|
158
|
+
raise Error, "Lines expects a String or IO-like object"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
module SwissNetex
|
|
7
|
+
# Streaming index of NeTEx `Operator` entries from a RESOURCE frame.
|
|
8
|
+
#
|
|
9
|
+
# Resolves CLI-style aliases (GO, zero-padded GO, `ch:1:Operator:…`, SBOID,
|
|
10
|
+
# ShortName) to canonical Operator `@id` values.
|
|
11
|
+
class Operators
|
|
12
|
+
OPERATOR_ID_RE = /\Ach:1:Operator:/i
|
|
13
|
+
SBOID_RE = /\Ach:1:sboid:/i
|
|
14
|
+
GO_RE = /\A0*\d+\z/
|
|
15
|
+
|
|
16
|
+
HEADERS = %w[go operator_id sboid short_name name].freeze
|
|
17
|
+
Entry = Data.define(:id, :private_code, :didok, :sboid, :short_name, :name)
|
|
18
|
+
|
|
19
|
+
def self.parse(source)
|
|
20
|
+
new.parse(source)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.load(path)
|
|
24
|
+
ResourceFrame.open(path) { |io| parse(io) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@entries = []
|
|
29
|
+
@by_id = {}
|
|
30
|
+
@alias_to_id = {}
|
|
31
|
+
@ambiguous = {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse(source)
|
|
35
|
+
each_operator_xml(source) { |xml| index_operator(xml) }
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def entries
|
|
40
|
+
@entries.dup
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def size
|
|
44
|
+
@entries.size
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def empty?
|
|
48
|
+
@entries.empty?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Resolve one or more aliases to unique Operator `@id` values (input order).
|
|
52
|
+
# Raises SwissNetex::Error when any alias is missing or ambiguous.
|
|
53
|
+
def resolve(aliases)
|
|
54
|
+
list = alias_list(aliases)
|
|
55
|
+
ids, unknown, ambiguous = collect_resolutions(list)
|
|
56
|
+
raise_resolve_errors!(unknown, ambiguous)
|
|
57
|
+
ids
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def lookup(alias_name)
|
|
61
|
+
key = normalize_alias(alias_name)
|
|
62
|
+
return nil if key.nil? || key.empty? || @ambiguous.key?(key)
|
|
63
|
+
|
|
64
|
+
@alias_to_id[key]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def include_id?(operator_id)
|
|
68
|
+
@by_id.key?(operator_id.to_s)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def fetch_entry(operator_id)
|
|
72
|
+
@by_id[operator_id.to_s]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Stable ordered rows for `operators` discovery TSV.
|
|
76
|
+
def rows
|
|
77
|
+
entries
|
|
78
|
+
.sort_by { |entry| [go_for(entry).to_s, entry.id] }
|
|
79
|
+
.map { |entry| row_for(entry) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def row_for(entry)
|
|
85
|
+
[go_for(entry), entry.id, entry.sboid, entry.short_name, entry.name]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def go_for(entry)
|
|
89
|
+
entry.private_code || entry.didok
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def alias_list(aliases)
|
|
93
|
+
list = Array(aliases).map { |value| value.to_s.strip }.reject(&:empty?)
|
|
94
|
+
raise Error, "No operator aliases given" if list.empty?
|
|
95
|
+
|
|
96
|
+
list
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def collect_resolutions(list)
|
|
100
|
+
ids = []
|
|
101
|
+
unknown = []
|
|
102
|
+
ambiguous = []
|
|
103
|
+
seen = {}
|
|
104
|
+
|
|
105
|
+
list.each do |alias_name|
|
|
106
|
+
id = lookup(alias_name)
|
|
107
|
+
if id.nil?
|
|
108
|
+
bucket = ambiguous_alias?(alias_name) ? ambiguous : unknown
|
|
109
|
+
bucket << alias_name
|
|
110
|
+
next
|
|
111
|
+
end
|
|
112
|
+
next if seen[id]
|
|
113
|
+
|
|
114
|
+
seen[id] = true
|
|
115
|
+
ids << id
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
[ids, unknown, ambiguous]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def raise_resolve_errors!(unknown, ambiguous)
|
|
122
|
+
parts = []
|
|
123
|
+
parts << "Unknown operator alias(es): #{unknown.join(", ")}" if unknown.any?
|
|
124
|
+
parts << "Ambiguous operator alias(es): #{ambiguous.join(", ")}" if ambiguous.any?
|
|
125
|
+
raise Error, parts.join("; ") if parts.any?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def each_operator_xml(source)
|
|
129
|
+
reader = Nokogiri::XML::Reader(to_io(source))
|
|
130
|
+
while reader.read
|
|
131
|
+
next unless operator_element?(reader)
|
|
132
|
+
|
|
133
|
+
xml = reader.outer_xml
|
|
134
|
+
yield xml if xml && !xml.empty?
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def operator_element?(reader)
|
|
139
|
+
reader.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT &&
|
|
140
|
+
local_name(reader.name) == "Operator"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def to_io(source)
|
|
144
|
+
return StringIO.new(source) if source.is_a?(String)
|
|
145
|
+
return source if source.respond_to?(:read)
|
|
146
|
+
|
|
147
|
+
raise Error, "Operators.parse expects a String or IO-like object"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def local_name(name)
|
|
151
|
+
name.to_s.split(":", 2).last
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def index_operator(xml)
|
|
155
|
+
entry = entry_from_xml(xml)
|
|
156
|
+
return unless entry
|
|
157
|
+
|
|
158
|
+
store_entry(entry)
|
|
159
|
+
register_entry_aliases(entry)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def entry_from_xml(xml)
|
|
163
|
+
root = Nokogiri::XML(xml).root
|
|
164
|
+
return unless root
|
|
165
|
+
|
|
166
|
+
id = root["id"].to_s.strip
|
|
167
|
+
return if id.empty?
|
|
168
|
+
|
|
169
|
+
Entry.new(
|
|
170
|
+
id: id,
|
|
171
|
+
private_code: text_at(root, "PrivateCode"),
|
|
172
|
+
didok: key_value(root, "DIDOK"),
|
|
173
|
+
sboid: key_value(root, "SBOID"),
|
|
174
|
+
short_name: text_at(root, "ShortName"),
|
|
175
|
+
name: text_at(root, "Name")
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def store_entry(entry)
|
|
180
|
+
@entries << entry
|
|
181
|
+
@by_id[entry.id] = entry
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def register_entry_aliases(entry)
|
|
185
|
+
register_alias(entry.id, entry.id)
|
|
186
|
+
register_go(entry.id, entry.private_code)
|
|
187
|
+
register_go(entry.id, entry.didok)
|
|
188
|
+
register_alias(entry.id, entry.sboid) if entry.sboid
|
|
189
|
+
register_alias(entry.id, entry.short_name) if entry.short_name
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def text_at(node, element_name)
|
|
193
|
+
child = node.at_xpath(".//*[local-name()=#{element_name.inspect}]")
|
|
194
|
+
blank_to_nil(child&.text&.strip)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def key_value(node, key_name)
|
|
198
|
+
node.xpath(".//*[local-name()='KeyValue']").each do |kv|
|
|
199
|
+
key = kv.at_xpath(".//*[local-name()='Key']")&.text&.strip
|
|
200
|
+
next unless key&.casecmp?(key_name)
|
|
201
|
+
|
|
202
|
+
return blank_to_nil(kv.at_xpath(".//*[local-name()='Value']")&.text&.strip)
|
|
203
|
+
end
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def blank_to_nil(text)
|
|
208
|
+
text.nil? || text.empty? ? nil : text
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def register_go(id, go_code)
|
|
212
|
+
return if go_code.nil? || go_code.empty? || !go_code.match?(GO_RE)
|
|
213
|
+
|
|
214
|
+
# normalize_alias collapses 000151 / 151 to the same key.
|
|
215
|
+
register_alias(id, go_code)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def ambiguous_alias?(alias_name)
|
|
219
|
+
key = normalize_alias(alias_name)
|
|
220
|
+
!key.nil? && !key.empty? && @ambiguous.key?(key)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def register_alias(id, raw_key)
|
|
224
|
+
key = normalize_alias(raw_key)
|
|
225
|
+
return if key.nil? || key.empty?
|
|
226
|
+
|
|
227
|
+
existing = @alias_to_id[key]
|
|
228
|
+
if existing.nil?
|
|
229
|
+
@alias_to_id[key] = id
|
|
230
|
+
elsif existing != id
|
|
231
|
+
mark_ambiguous(key)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def mark_ambiguous(key)
|
|
236
|
+
@alias_to_id.delete(key)
|
|
237
|
+
@ambiguous[key] = true
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def normalize_alias(value)
|
|
241
|
+
text = value.to_s.strip
|
|
242
|
+
return nil if text.empty?
|
|
243
|
+
return text.downcase if text.match?(OPERATOR_ID_RE) || text.match?(SBOID_RE)
|
|
244
|
+
return strip_leading_zeros(text) if text.match?(GO_RE)
|
|
245
|
+
|
|
246
|
+
text.downcase
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def strip_leading_zeros(text)
|
|
250
|
+
stripped = text.sub(/\A0+/, "")
|
|
251
|
+
stripped.empty? ? "0" : stripped
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zip"
|
|
4
|
+
|
|
5
|
+
module SwissNetex
|
|
6
|
+
# Read-only access to a Swiss NeTEx package (zip or extracted directory).
|
|
7
|
+
class Package
|
|
8
|
+
Member = Data.define(:name, :kind)
|
|
9
|
+
|
|
10
|
+
def self.open(path, &)
|
|
11
|
+
new(path).open(&)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.frame_kind(name)
|
|
15
|
+
return :resource if name.match?(/RESOURCE/i)
|
|
16
|
+
return :service_calendar if name.match?(/SERVICECALENDAR|SERVICE_CALENDAR/i)
|
|
17
|
+
return :service if name.match?(/SERVICE/i)
|
|
18
|
+
return :site if name.match?(/SITE/i)
|
|
19
|
+
return :common if name.match?(/COMMON/i)
|
|
20
|
+
return :timetable if name.match?(/TIMETABLE/i)
|
|
21
|
+
|
|
22
|
+
:other
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(path)
|
|
26
|
+
@path = path.to_s
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def open
|
|
30
|
+
raise Error, "Package path not found: #{@path}" unless File.exist?(@path)
|
|
31
|
+
|
|
32
|
+
if File.directory?(@path)
|
|
33
|
+
yield DirectorySource.new(@path)
|
|
34
|
+
elsif zip_path?
|
|
35
|
+
Zip::File.open(@path) { |zip| yield ZipSource.new(zip, @path) }
|
|
36
|
+
else
|
|
37
|
+
raise Error, "Package must be a zip or directory: #{@path}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def zip_path?
|
|
44
|
+
@path.match?(/\.zip\z/i)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Shared listing / open helpers for zip and directory packages.
|
|
48
|
+
module Source
|
|
49
|
+
def members
|
|
50
|
+
names.map { |name| Member.new(name: name, kind: Package.frame_kind(name)) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def each_of(kind, &)
|
|
54
|
+
names_for(kind).each { |name| open_member(name, &) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def open_one(kind, &)
|
|
58
|
+
matches = names_for(kind)
|
|
59
|
+
raise Error, "No #{kind} frame found in #{label}" if matches.empty?
|
|
60
|
+
|
|
61
|
+
if matches.size > 1
|
|
62
|
+
raise Error, "Multiple #{kind} frames found in #{label}: #{matches.join(", ")}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
open_member(matches.first, &)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def names_for(kind)
|
|
69
|
+
names.select { |name| Package.frame_kind(name) == kind }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class ZipSource
|
|
74
|
+
include Source
|
|
75
|
+
|
|
76
|
+
def initialize(zip, path)
|
|
77
|
+
@zip = zip
|
|
78
|
+
@path = path
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def label = @path
|
|
82
|
+
|
|
83
|
+
def names
|
|
84
|
+
@names ||= @zip.select { |e| !e.directory? && !workflow?(e.name) }.map(&:name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def open_member(name, &)
|
|
88
|
+
entry = @zip.find_entry(name)
|
|
89
|
+
raise Error, "Missing package member: #{name}" unless entry
|
|
90
|
+
|
|
91
|
+
entry.get_input_stream(&)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def workflow?(name)
|
|
97
|
+
name.match?(%r{\AWorkflow/}i)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
class DirectorySource
|
|
102
|
+
include Source
|
|
103
|
+
|
|
104
|
+
def initialize(path)
|
|
105
|
+
@path = path
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def label = @path
|
|
109
|
+
|
|
110
|
+
def names
|
|
111
|
+
@names ||= Dir.children(@path).select do |name|
|
|
112
|
+
file = File.join(@path, name)
|
|
113
|
+
File.file?(file) && name.match?(/\.xml\z/i)
|
|
114
|
+
end.sort
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def open_member(name, &)
|
|
118
|
+
path = member_path(name)
|
|
119
|
+
raise Error, "Missing package member: #{name}" unless File.file?(path)
|
|
120
|
+
|
|
121
|
+
File.open(path, "rb", &)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
# Members are flat top-level basenames from Dir.children. Reject traversal
|
|
127
|
+
# so a public Package API call cannot escape the package root.
|
|
128
|
+
def member_path(name)
|
|
129
|
+
raw = name.to_s
|
|
130
|
+
if raw.empty? || raw.include?("/") || raw.include?("\\") ||
|
|
131
|
+
raw == "." || raw == ".." || File.absolute_path?(raw)
|
|
132
|
+
raise Error, "Invalid package member name: #{name}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
root = File.expand_path(@path)
|
|
136
|
+
path = File.expand_path(File.join(root, raw))
|
|
137
|
+
unless path == root || path.start_with?("#{root}#{File::SEPARATOR}")
|
|
138
|
+
raise Error, "Invalid package member name: #{name}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
path
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwissNetex
|
|
4
|
+
# Resolve a national package path: use `--from` when set, otherwise download
|
|
5
|
+
# (or reuse cache). Shared by `extract`, `operators`, and `lines`.
|
|
6
|
+
module PackageSource
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def resolve(
|
|
10
|
+
from: nil,
|
|
11
|
+
dataset: nil,
|
|
12
|
+
cache_dir: Download::DEFAULT_CACHE_DIR,
|
|
13
|
+
force: false,
|
|
14
|
+
http: nil,
|
|
15
|
+
progress_io: nil
|
|
16
|
+
)
|
|
17
|
+
path = from.to_s.strip
|
|
18
|
+
return path unless path.empty?
|
|
19
|
+
|
|
20
|
+
progress_io&.puts("No --from given; downloading national package…")
|
|
21
|
+
Download.new(
|
|
22
|
+
dataset: dataset,
|
|
23
|
+
cache_dir: cache_dir,
|
|
24
|
+
force: force,
|
|
25
|
+
http: http || Http.new,
|
|
26
|
+
progress_io: progress_io
|
|
27
|
+
).call.path
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zip"
|
|
4
|
+
|
|
5
|
+
module SwissNetex
|
|
6
|
+
# Opens the single RESOURCE frame from a Swiss NeTEx package path.
|
|
7
|
+
#
|
|
8
|
+
# Accepts a bare XML file, a national/export zip, or an extracted directory.
|
|
9
|
+
# Yields a readable IO; callers stream with Nokogiri Reader (never full DOM
|
|
10
|
+
# of large frames).
|
|
11
|
+
class ResourceFrame
|
|
12
|
+
NAME_RE = /RESOURCE/i
|
|
13
|
+
|
|
14
|
+
def self.open(path, &)
|
|
15
|
+
new(path).open(&)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(path)
|
|
19
|
+
@path = path.to_s
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def open(&)
|
|
23
|
+
raise Error, "Package path not found: #{@path}" unless File.exist?(@path)
|
|
24
|
+
|
|
25
|
+
if File.directory?(@path)
|
|
26
|
+
open_directory(&)
|
|
27
|
+
elsif zip_path?
|
|
28
|
+
open_zip(&)
|
|
29
|
+
else
|
|
30
|
+
File.open(@path, "rb", &)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def zip_path?
|
|
37
|
+
@path.match?(/\.zip\z/i)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def open_zip(&)
|
|
41
|
+
Zip::File.open(@path) do |zip|
|
|
42
|
+
entry = single_match(
|
|
43
|
+
zip.select { |e| !e.directory? && e.name.match?(NAME_RE) },
|
|
44
|
+
kind: "member"
|
|
45
|
+
)
|
|
46
|
+
entry.get_input_stream(&)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def open_directory(&)
|
|
51
|
+
names = Dir.children(@path).select { |name| resource_xml?(name) }
|
|
52
|
+
name = single_match(names, kind: "file")
|
|
53
|
+
File.open(File.join(@path, name), "rb", &)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def resource_xml?(name)
|
|
57
|
+
file = File.join(@path, name)
|
|
58
|
+
File.file?(file) && name.match?(NAME_RE) && name.match?(/\.xml\z/i)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def single_match(matches, kind:)
|
|
62
|
+
raise Error, "No RESOURCE XML #{kind} found in #{@path}" if matches.empty?
|
|
63
|
+
|
|
64
|
+
if matches.size > 1
|
|
65
|
+
labels = matches.map { |m| m.respond_to?(:name) ? m.name : m }
|
|
66
|
+
raise Error,
|
|
67
|
+
"Multiple RESOURCE XML #{kind}s found in #{@path}: #{labels.join(", ")}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
matches.first
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwissNetex
|
|
4
|
+
# Discovery-command table writer: aligned columns on a TTY, raw TSV when piped.
|
|
5
|
+
module Tsv
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def write(io, headers, rows)
|
|
9
|
+
cells = [headers.map { |h| escape(h) }] +
|
|
10
|
+
rows.map { |row| row.map { |cell| escape(cell) } }
|
|
11
|
+
|
|
12
|
+
if tty?(io)
|
|
13
|
+
write_aligned(io, cells)
|
|
14
|
+
else
|
|
15
|
+
write_tsv(io, cells)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def escape(value)
|
|
20
|
+
text = value.nil? ? "" : value.to_s
|
|
21
|
+
text.gsub(/[\t\r\n]/, " ")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def tty?(io)
|
|
25
|
+
io.respond_to?(:tty?) && io.tty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def write_tsv(io, cells)
|
|
29
|
+
cells.each { |row| io.puts(row.join("\t")) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def write_aligned(io, cells)
|
|
33
|
+
widths = column_widths(cells)
|
|
34
|
+
cells.each do |row|
|
|
35
|
+
padded = row.each_with_index.map do |cell, index|
|
|
36
|
+
index == row.size - 1 ? cell : cell.ljust(widths[index])
|
|
37
|
+
end
|
|
38
|
+
io.puts(padded.join(" ").rstrip)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def column_widths(cells)
|
|
43
|
+
widths = Array.new(cells.first.size, 0)
|
|
44
|
+
cells.each do |row|
|
|
45
|
+
row.each_with_index do |cell, index|
|
|
46
|
+
size = cell.to_s.size
|
|
47
|
+
widths[index] = size if size > widths[index]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
widths
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|