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,546 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module SwissNetex
|
|
6
|
+
class CLI
|
|
7
|
+
EXIT_OK = 0
|
|
8
|
+
EXIT_USAGE = 64
|
|
9
|
+
EXIT_FAILURE = 1
|
|
10
|
+
|
|
11
|
+
COMMANDS = {
|
|
12
|
+
"download" => "Download the latest national NeTEx package",
|
|
13
|
+
"extract" => "Download (if needed) and filter by operator/line",
|
|
14
|
+
"filter" => "Filter a local package by operator/line",
|
|
15
|
+
"help" => "Show help for the CLI or a command",
|
|
16
|
+
"lines" => "List lines in a package (table/TSV)",
|
|
17
|
+
"operators" => "List operators in a package (table/TSV)",
|
|
18
|
+
"version" => "Print the swiss-netex version"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def self.start(argv = ARGV, stdout: $stdout, stderr: $stderr, http: nil)
|
|
22
|
+
new(stdout: stdout, stderr: stderr, http: http).run(argv)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(stdout: $stdout, stderr: $stderr, http: nil)
|
|
26
|
+
@stdout = stdout
|
|
27
|
+
@stderr = stderr
|
|
28
|
+
@http = http
|
|
29
|
+
@active_help = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def run(argv)
|
|
33
|
+
command, *args = argv
|
|
34
|
+
command = "help" if command.nil? || %w[-h --help].include?(command)
|
|
35
|
+
command = "version" if %w[-v --version].include?(command)
|
|
36
|
+
|
|
37
|
+
dispatch(command, args)
|
|
38
|
+
rescue Error => e
|
|
39
|
+
fail_error(e.message)
|
|
40
|
+
rescue OptionParser::ParseError => e
|
|
41
|
+
fail_usage(e.message)
|
|
42
|
+
rescue Zip::Error => e
|
|
43
|
+
fail_error("Corrupt or unreadable zip: #{e.message}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def dispatch(command, args)
|
|
49
|
+
case command
|
|
50
|
+
when "download" then download(args)
|
|
51
|
+
when "extract" then extract(args)
|
|
52
|
+
when "filter" then filter(args)
|
|
53
|
+
when "operators" then operators(args)
|
|
54
|
+
when "lines" then lines(args)
|
|
55
|
+
when "help" then help(args.first)
|
|
56
|
+
when "version" then version
|
|
57
|
+
else unknown_command(command)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def version
|
|
62
|
+
@stdout.puts "swiss-netex #{VERSION}"
|
|
63
|
+
EXIT_OK
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def help(command = nil)
|
|
67
|
+
if command.nil? || command.empty?
|
|
68
|
+
@stdout.puts top_level_help
|
|
69
|
+
return EXIT_OK
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
return unknown_command(command) unless COMMANDS.key?(command)
|
|
73
|
+
|
|
74
|
+
@stdout.puts command_help(command)
|
|
75
|
+
EXIT_OK
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def download(argv)
|
|
79
|
+
@active_help = download_help
|
|
80
|
+
options = blank_download_options
|
|
81
|
+
leftover = download_parser(options).parse(argv)
|
|
82
|
+
return show_command_help(download_help) if options[:show_help]
|
|
83
|
+
return unexpected_args(leftover, download_help) if leftover.any?
|
|
84
|
+
|
|
85
|
+
dataset = resolve_download_dataset(options[:dataset], options[:year])
|
|
86
|
+
result = Download.new(
|
|
87
|
+
dataset: dataset,
|
|
88
|
+
output: options[:output],
|
|
89
|
+
cache_dir: options[:cache_dir],
|
|
90
|
+
force: options[:force],
|
|
91
|
+
http: @http || Http.new,
|
|
92
|
+
progress_io: @stderr
|
|
93
|
+
).call
|
|
94
|
+
|
|
95
|
+
@stdout.puts result.path
|
|
96
|
+
EXIT_OK
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def filter(argv)
|
|
100
|
+
@active_help = filter_help
|
|
101
|
+
options = blank_filter_options
|
|
102
|
+
leftover = filter_parser(options).parse(argv)
|
|
103
|
+
return show_command_help(filter_help) if options[:show_help]
|
|
104
|
+
return unexpected_args(leftover, filter_help) if leftover.any?
|
|
105
|
+
|
|
106
|
+
result = Filter.new(
|
|
107
|
+
from: options[:from],
|
|
108
|
+
output: options[:output],
|
|
109
|
+
operators: options[:operators],
|
|
110
|
+
lines: options[:lines],
|
|
111
|
+
allow_empty: options[:allow_empty],
|
|
112
|
+
verbose: options[:verbose],
|
|
113
|
+
progress_io: @stderr
|
|
114
|
+
).call
|
|
115
|
+
|
|
116
|
+
@stdout.puts result.path
|
|
117
|
+
EXIT_OK
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def extract(argv)
|
|
121
|
+
@active_help = extract_help
|
|
122
|
+
options = blank_extract_options
|
|
123
|
+
leftover = extract_parser(options).parse(argv)
|
|
124
|
+
return show_command_help(extract_help) if options[:show_help]
|
|
125
|
+
return unexpected_args(leftover, extract_help) if leftover.any?
|
|
126
|
+
|
|
127
|
+
reject_unused_download_opts_with_from!(options)
|
|
128
|
+
dataset = resolve_download_dataset(options[:dataset], options[:year])
|
|
129
|
+
result = Extract.new(
|
|
130
|
+
operators: options[:operators],
|
|
131
|
+
lines: options[:lines],
|
|
132
|
+
from: options[:from],
|
|
133
|
+
output: options[:output],
|
|
134
|
+
dataset: dataset,
|
|
135
|
+
cache_dir: options[:cache_dir],
|
|
136
|
+
force: options[:force],
|
|
137
|
+
allow_empty: options[:allow_empty],
|
|
138
|
+
verbose: options[:verbose],
|
|
139
|
+
http: @http,
|
|
140
|
+
progress_io: @stderr
|
|
141
|
+
).call
|
|
142
|
+
|
|
143
|
+
@stdout.puts result.path
|
|
144
|
+
EXIT_OK
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def operators(argv)
|
|
148
|
+
@active_help = operators_help
|
|
149
|
+
options = blank_discovery_options
|
|
150
|
+
leftover = operators_parser(options).parse(argv)
|
|
151
|
+
return show_command_help(operators_help) if options[:show_help]
|
|
152
|
+
return unexpected_args(leftover, operators_help) if leftover.any?
|
|
153
|
+
|
|
154
|
+
path = resolve_package_path(options)
|
|
155
|
+
index = Operators.load(path)
|
|
156
|
+
raise Error, "No operators found in #{path}" if index.empty?
|
|
157
|
+
|
|
158
|
+
Tsv.write(@stdout, Operators::HEADERS, index.rows)
|
|
159
|
+
EXIT_OK
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def lines(argv)
|
|
163
|
+
@active_help = lines_help
|
|
164
|
+
options = blank_discovery_options.merge(operators: [], lines: [])
|
|
165
|
+
leftover = lines_parser(options).parse(argv)
|
|
166
|
+
return show_command_help(lines_help) if options[:show_help]
|
|
167
|
+
return unexpected_args(leftover, lines_help) if leftover.any?
|
|
168
|
+
|
|
169
|
+
path = resolve_package_path(options)
|
|
170
|
+
entries = Lines.load(
|
|
171
|
+
path,
|
|
172
|
+
operators: options[:operators],
|
|
173
|
+
lines: options[:lines]
|
|
174
|
+
)
|
|
175
|
+
raise Error, "No lines matched" if entries.empty?
|
|
176
|
+
|
|
177
|
+
Tsv.write(@stdout, Lines::HEADERS, entries.map(&:to_row))
|
|
178
|
+
EXIT_OK
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def blank_download_options
|
|
182
|
+
{
|
|
183
|
+
dataset: nil,
|
|
184
|
+
year: nil,
|
|
185
|
+
output: nil,
|
|
186
|
+
cache_dir: Download::DEFAULT_CACHE_DIR,
|
|
187
|
+
cache_dir_set: false,
|
|
188
|
+
force: false,
|
|
189
|
+
show_help: false
|
|
190
|
+
}
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def blank_filter_options
|
|
194
|
+
{
|
|
195
|
+
from: nil,
|
|
196
|
+
output: nil,
|
|
197
|
+
operators: [],
|
|
198
|
+
lines: [],
|
|
199
|
+
allow_empty: false,
|
|
200
|
+
verbose: false,
|
|
201
|
+
show_help: false
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def blank_extract_options
|
|
206
|
+
blank_filter_options.merge(blank_package_source_options)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def blank_discovery_options
|
|
210
|
+
blank_package_source_options.merge(from: nil, show_help: false)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def blank_package_source_options
|
|
214
|
+
{
|
|
215
|
+
dataset: nil,
|
|
216
|
+
year: nil,
|
|
217
|
+
cache_dir: Download::DEFAULT_CACHE_DIR,
|
|
218
|
+
cache_dir_set: false,
|
|
219
|
+
force: false
|
|
220
|
+
}
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def download_parser(options)
|
|
224
|
+
OptionParser.new do |opts|
|
|
225
|
+
opts.banner = <<~BANNER.chomp
|
|
226
|
+
Usage: swiss-netex download [options]
|
|
227
|
+
|
|
228
|
+
Download the latest Swiss national NeTEx zip from opentransportdata.swiss.
|
|
229
|
+
Resource downloads are public; no API token is required on the happy path.
|
|
230
|
+
BANNER
|
|
231
|
+
opts.separator ""
|
|
232
|
+
opts.separator "Options:"
|
|
233
|
+
add_dataset_options(opts, options)
|
|
234
|
+
opts.on("-o", "--output PATH", "--out PATH",
|
|
235
|
+
"Output file or directory (default: under --cache-dir)") do |value|
|
|
236
|
+
options[:output] = value
|
|
237
|
+
end
|
|
238
|
+
add_cache_dir_option(opts, options)
|
|
239
|
+
add_force_option(opts, options)
|
|
240
|
+
add_help_option(opts, options)
|
|
241
|
+
opts.separator ""
|
|
242
|
+
opts.separator "Prints the downloaded (or cached) file path on stdout."
|
|
243
|
+
opts.separator "Progress and status messages go to stderr."
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def filter_parser(options)
|
|
248
|
+
OptionParser.new do |opts|
|
|
249
|
+
opts.banner = <<~BANNER.chomp
|
|
250
|
+
Usage: swiss-netex filter [options]
|
|
251
|
+
|
|
252
|
+
Filter a local Swiss national NeTEx package (zip or directory) by operator
|
|
253
|
+
and optional line. Writes a smaller zip with transitive frame contents.
|
|
254
|
+
BANNER
|
|
255
|
+
opts.separator ""
|
|
256
|
+
opts.separator "Options:"
|
|
257
|
+
add_from_option(opts, options, required_note: true)
|
|
258
|
+
add_output_option(opts, options)
|
|
259
|
+
add_operator_option(opts, options)
|
|
260
|
+
add_line_option(opts, options)
|
|
261
|
+
add_allow_empty_option(opts, options)
|
|
262
|
+
add_verbose_option(opts, options)
|
|
263
|
+
add_help_option(opts, options)
|
|
264
|
+
opts.separator ""
|
|
265
|
+
opts.separator "Prints the output zip path on stdout."
|
|
266
|
+
opts.separator "Progress and status messages go to stderr."
|
|
267
|
+
opts.separator "--operator may be repeated (union of operators)."
|
|
268
|
+
opts.separator "National filter needs several GB of free RAM (TIMETABLE streaming)."
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def extract_parser(options)
|
|
273
|
+
OptionParser.new do |opts|
|
|
274
|
+
opts.banner = <<~BANNER.chomp
|
|
275
|
+
Usage: swiss-netex extract [options]
|
|
276
|
+
|
|
277
|
+
Download the latest national package when --from is omitted, then filter by
|
|
278
|
+
operator/line. Equivalent to download + filter for the common workflow.
|
|
279
|
+
BANNER
|
|
280
|
+
opts.separator ""
|
|
281
|
+
opts.separator "Options:"
|
|
282
|
+
add_operator_option(opts, options)
|
|
283
|
+
add_line_option(opts, options)
|
|
284
|
+
add_from_option(opts, options, required_note: false)
|
|
285
|
+
add_dataset_options(opts, options)
|
|
286
|
+
add_output_option(opts, options)
|
|
287
|
+
add_cache_dir_option(opts, options)
|
|
288
|
+
add_force_option(opts, options)
|
|
289
|
+
add_allow_empty_option(opts, options)
|
|
290
|
+
add_verbose_option(opts, options)
|
|
291
|
+
add_help_option(opts, options)
|
|
292
|
+
opts.separator ""
|
|
293
|
+
opts.separator "Prints the output zip path on stdout."
|
|
294
|
+
opts.separator "Progress and status messages go to stderr."
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def operators_parser(options)
|
|
299
|
+
OptionParser.new do |opts|
|
|
300
|
+
opts.banner = <<~BANNER.chomp
|
|
301
|
+
Usage: swiss-netex operators [options]
|
|
302
|
+
|
|
303
|
+
List operators from the RESOURCE frame on stdout (TSV when piped;
|
|
304
|
+
aligned columns on a TTY). Scans RESOURCE only (no TIMETABLE).
|
|
305
|
+
Omit --from to download (or reuse cache) first.
|
|
306
|
+
BANNER
|
|
307
|
+
opts.separator ""
|
|
308
|
+
opts.separator "Options:"
|
|
309
|
+
add_from_option(opts, options, required_note: false)
|
|
310
|
+
add_dataset_options(opts, options)
|
|
311
|
+
add_cache_dir_option(opts, options)
|
|
312
|
+
add_force_option(opts, options)
|
|
313
|
+
add_help_option(opts, options)
|
|
314
|
+
opts.separator ""
|
|
315
|
+
opts.separator "Columns: #{Operators::HEADERS.join(", ")}"
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def lines_parser(options)
|
|
320
|
+
OptionParser.new do |opts|
|
|
321
|
+
opts.banner = <<~BANNER.chomp
|
|
322
|
+
Usage: swiss-netex lines [options]
|
|
323
|
+
|
|
324
|
+
List lines from the SERVICE frame on stdout (TSV when piped; aligned
|
|
325
|
+
columns on a TTY). Scans SERVICE only. Optional --operator / --line
|
|
326
|
+
narrow the list (same alias rules as filter). Omit --from to download
|
|
327
|
+
(or reuse cache) first.
|
|
328
|
+
BANNER
|
|
329
|
+
opts.separator ""
|
|
330
|
+
opts.separator "Options:"
|
|
331
|
+
add_from_option(opts, options, required_note: false)
|
|
332
|
+
add_dataset_options(opts, options)
|
|
333
|
+
add_cache_dir_option(opts, options)
|
|
334
|
+
add_force_option(opts, options)
|
|
335
|
+
add_operator_option(opts, options)
|
|
336
|
+
add_line_option(opts, options)
|
|
337
|
+
add_help_option(opts, options)
|
|
338
|
+
opts.separator ""
|
|
339
|
+
opts.separator "Columns: #{Lines::HEADERS.join(", ")}"
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def add_from_option(opts, options, required_note:)
|
|
344
|
+
label =
|
|
345
|
+
if required_note
|
|
346
|
+
"Source package zip or extracted directory (required)"
|
|
347
|
+
else
|
|
348
|
+
"Local package zip/dir (skips download when set)"
|
|
349
|
+
end
|
|
350
|
+
opts.on("--from PATH", label) { |value| options[:from] = value }
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def add_output_option(opts, options)
|
|
354
|
+
opts.on("-o", "--output PATH", "--out PATH",
|
|
355
|
+
"Output zip path or directory (bare names get .zip)") do |value|
|
|
356
|
+
options[:output] = value
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def add_operator_option(opts, options)
|
|
361
|
+
opts.on("-O", "--operator ALIAS",
|
|
362
|
+
"Operator alias (GO, NeTEx id, SBOID, ShortName); repeatable") do |value|
|
|
363
|
+
options[:operators] << value
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def add_line_option(opts, options)
|
|
368
|
+
opts.on("-l", "--line ALIAS", "--lines ALIAS",
|
|
369
|
+
"Optional line filter (PublicCode, id, SLNID, ShortName, Name);",
|
|
370
|
+
"repeatable") do |value|
|
|
371
|
+
options[:lines] << value
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def add_allow_empty_option(opts, options)
|
|
376
|
+
opts.on("--allow-empty",
|
|
377
|
+
"Write output even when no lines or ServiceJourneys match") do
|
|
378
|
+
options[:allow_empty] = true
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def add_verbose_option(opts, options)
|
|
383
|
+
opts.on("-v", "--verbose",
|
|
384
|
+
"Per-shard and per-frame keep/drop counters on stderr") do
|
|
385
|
+
options[:verbose] = true
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def add_help_option(opts, options)
|
|
390
|
+
opts.on("-h", "--help", "Show this help") { options[:show_help] = true }
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def add_dataset_options(opts, options)
|
|
394
|
+
opts.on("-d", "--dataset URL_OR_SLUG",
|
|
395
|
+
"Dataset page URL or slug",
|
|
396
|
+
"(default: latest timetablenetex_YYYY from catalog)") do |value|
|
|
397
|
+
options[:dataset] = value
|
|
398
|
+
end
|
|
399
|
+
opts.on("-y", "--year YYYY",
|
|
400
|
+
"Shortcut for --dataset timetablenetex_YYYY",
|
|
401
|
+
"(mutually exclusive with --dataset)") do |value|
|
|
402
|
+
options[:year] = value
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def add_cache_dir_option(opts, options)
|
|
407
|
+
opts.on("-c", "--cache-dir PATH",
|
|
408
|
+
"Cache directory when downloading",
|
|
409
|
+
"(default: #{Download::DEFAULT_CACHE_DIR})") do |value|
|
|
410
|
+
options[:cache_dir] = value
|
|
411
|
+
options[:cache_dir_set] = true
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def add_force_option(opts, options)
|
|
416
|
+
opts.on("-f", "--force", "Re-download even if a cache file already exists") do
|
|
417
|
+
options[:force] = true
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def download_help = download_parser({}).help.rstrip
|
|
422
|
+
def filter_help = filter_parser(blank_filter_options).help.rstrip
|
|
423
|
+
def extract_help = extract_parser(blank_extract_options).help.rstrip
|
|
424
|
+
def operators_help = operators_parser(blank_discovery_options).help.rstrip
|
|
425
|
+
|
|
426
|
+
def lines_help
|
|
427
|
+
lines_parser(blank_discovery_options.merge(operators: [], lines: [])).help.rstrip
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def resolve_package_path(options)
|
|
431
|
+
reject_unused_download_opts_with_from!(options)
|
|
432
|
+
dataset = resolve_download_dataset(options[:dataset], options[:year])
|
|
433
|
+
PackageSource.resolve(
|
|
434
|
+
from: options[:from],
|
|
435
|
+
dataset: dataset,
|
|
436
|
+
cache_dir: options[:cache_dir],
|
|
437
|
+
force: options[:force],
|
|
438
|
+
http: @http,
|
|
439
|
+
progress_io: @stderr
|
|
440
|
+
)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def resolve_download_dataset(dataset, year)
|
|
444
|
+
if dataset && year
|
|
445
|
+
raise OptionParser::ParseError, "--dataset and --year are mutually exclusive"
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
return dataset if dataset
|
|
449
|
+
return nil if year.nil?
|
|
450
|
+
|
|
451
|
+
unless year.match?(/\A\d{4}\z/)
|
|
452
|
+
raise OptionParser::ParseError, "--year must be a 4-digit Fahrplanjahr (e.g. 2026)"
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
"timetablenetex_#{year}"
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# --from skips download; reject flags that would otherwise be silently ignored.
|
|
459
|
+
def reject_unused_download_opts_with_from!(options)
|
|
460
|
+
return if options[:from].to_s.strip.empty?
|
|
461
|
+
|
|
462
|
+
used = []
|
|
463
|
+
used << "--dataset" if options[:dataset]
|
|
464
|
+
used << "--year" if options[:year]
|
|
465
|
+
used << "--force" if options[:force]
|
|
466
|
+
used << "--cache-dir" if options[:cache_dir_set]
|
|
467
|
+
return if used.empty?
|
|
468
|
+
|
|
469
|
+
raise OptionParser::ParseError,
|
|
470
|
+
"--from cannot be combined with #{used.join(", ")}"
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def show_command_help(text)
|
|
474
|
+
@stdout.puts text
|
|
475
|
+
EXIT_OK
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def unexpected_args(leftover, help_text)
|
|
479
|
+
@stderr.puts "Error: unexpected argument(s): #{leftover.join(" ")}"
|
|
480
|
+
@stderr.puts
|
|
481
|
+
@stderr.puts help_text
|
|
482
|
+
EXIT_USAGE
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def unknown_command(command)
|
|
486
|
+
@stderr.puts "Unknown command: #{command}"
|
|
487
|
+
@stderr.puts
|
|
488
|
+
@stderr.puts top_level_help
|
|
489
|
+
EXIT_USAGE
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def fail_error(message)
|
|
493
|
+
@stderr.puts "Error: #{message}"
|
|
494
|
+
EXIT_FAILURE
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def fail_usage(message)
|
|
498
|
+
@stderr.puts "Error: #{message}"
|
|
499
|
+
@stderr.puts
|
|
500
|
+
@stderr.puts(@active_help || top_level_help)
|
|
501
|
+
EXIT_USAGE
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def top_level_help
|
|
505
|
+
lines = [
|
|
506
|
+
"Usage: swiss-netex <command> [options]",
|
|
507
|
+
"",
|
|
508
|
+
"CLI for Swiss national NeTEx timetable packages",
|
|
509
|
+
"(opentransportdata.swiss): download, inspect, and filter by operator/line.",
|
|
510
|
+
"",
|
|
511
|
+
"Commands:"
|
|
512
|
+
]
|
|
513
|
+
|
|
514
|
+
COMMANDS.each do |name, summary|
|
|
515
|
+
lines << format(" %<name>-12s %<summary>s", name: name, summary: summary)
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
(lines + [
|
|
519
|
+
"",
|
|
520
|
+
"Run `swiss-netex help <command>` for command-specific usage."
|
|
521
|
+
]).join("\n")
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def command_help(command)
|
|
525
|
+
case command
|
|
526
|
+
when "download" then download_help
|
|
527
|
+
when "filter" then filter_help
|
|
528
|
+
when "extract" then extract_help
|
|
529
|
+
when "operators" then operators_help
|
|
530
|
+
when "lines" then lines_help
|
|
531
|
+
when "help"
|
|
532
|
+
<<~HELP.chomp
|
|
533
|
+
Usage: swiss-netex help [command]
|
|
534
|
+
|
|
535
|
+
Show top-level help, or detailed usage for a command.
|
|
536
|
+
HELP
|
|
537
|
+
when "version"
|
|
538
|
+
<<~HELP.chomp
|
|
539
|
+
Usage: swiss-netex version
|
|
540
|
+
|
|
541
|
+
Print the installed swiss-netex version.
|
|
542
|
+
HELP
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module SwissNetex
|
|
6
|
+
# Parses an opentransportdata.swiss CKAN dataset HTML page and picks the
|
|
7
|
+
# newest national NeTEx package by the export timestamp embedded in the
|
|
8
|
+
# filename (`…_YYYYMMDDHHMM.zip`).
|
|
9
|
+
class DatasetPage
|
|
10
|
+
HREF_RE = /href\s*=\s*["']([^"']+)["']/i
|
|
11
|
+
PACKAGE_NAME_RE = /\A(PROD_NETEX_TT_.+\.zip)\z/i
|
|
12
|
+
TIMESTAMP_RE = /_(\d{12})\.zip\z/i
|
|
13
|
+
|
|
14
|
+
Resource = Data.define(:url, :filename, :timestamp)
|
|
15
|
+
|
|
16
|
+
def self.latest(html, base_url:)
|
|
17
|
+
new(html, base_url: base_url).latest
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(html, base_url:)
|
|
21
|
+
@html = html.to_s
|
|
22
|
+
@base_url = base_url.to_s.chomp("/")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def resources
|
|
26
|
+
seen = {}
|
|
27
|
+
@html.scan(HREF_RE).filter_map do |(href)|
|
|
28
|
+
resource = resource_from_href(unescape_attr(href))
|
|
29
|
+
next unless resource
|
|
30
|
+
next if seen[resource.url]
|
|
31
|
+
|
|
32
|
+
seen[resource.url] = true
|
|
33
|
+
resource
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def latest
|
|
38
|
+
list = resources
|
|
39
|
+
raise Error, "No PROD_NETEX_TT_*.zip resource found on dataset page" if list.empty?
|
|
40
|
+
|
|
41
|
+
list.max_by(&:timestamp)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def resource_from_href(href)
|
|
47
|
+
uri = absolute_uri(href)
|
|
48
|
+
return unless uri
|
|
49
|
+
|
|
50
|
+
filename = File.basename(uri.path.to_s)
|
|
51
|
+
match = PACKAGE_NAME_RE.match(filename)
|
|
52
|
+
return unless match
|
|
53
|
+
|
|
54
|
+
timestamp = TIMESTAMP_RE.match(filename)&.captures&.first
|
|
55
|
+
return unless timestamp
|
|
56
|
+
|
|
57
|
+
Resource.new(
|
|
58
|
+
url: uri.to_s,
|
|
59
|
+
filename: canonicalize_filename(match[1]),
|
|
60
|
+
timestamp: timestamp
|
|
61
|
+
)
|
|
62
|
+
rescue URI::InvalidURIError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def absolute_uri(href)
|
|
67
|
+
return if href.nil? || href.empty? || href.start_with?("#", "javascript:")
|
|
68
|
+
|
|
69
|
+
uri = URI.parse(href)
|
|
70
|
+
return uri if uri.absolute?
|
|
71
|
+
|
|
72
|
+
URI.join("#{@base_url}/", href)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# CKAN download paths are lowercased; keep the conventional display casing.
|
|
76
|
+
def canonicalize_filename(name)
|
|
77
|
+
stem = name.sub(/\.zip\z/i, "")
|
|
78
|
+
"#{stem.upcase}.zip"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def unescape_attr(value)
|
|
82
|
+
value
|
|
83
|
+
.gsub("&", "&")
|
|
84
|
+
.gsub(""", "\"")
|
|
85
|
+
.gsub("'", "'")
|
|
86
|
+
.gsub("<", "<")
|
|
87
|
+
.gsub(">", ">")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|