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,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module SwissNetex
|
|
8
|
+
class Filter
|
|
9
|
+
# Stream an XML document and keep/drop selected entity elements by local name.
|
|
10
|
+
#
|
|
11
|
+
# Yields an {Entity} so callers can decide from attributes (or lazy outer_xml)
|
|
12
|
+
# without always materializing dropped subtrees. Non-filterable structure is
|
|
13
|
+
# copied through. Output goes to a Tempfile by default.
|
|
14
|
+
#
|
|
15
|
+
# Pure whitespace between siblings is held until the next keep or structural
|
|
16
|
+
# node. A drop clears that buffer so filtered packages do not retain blank
|
|
17
|
+
# "holes" where removed entities used to sit (no full DOM pretty-print).
|
|
18
|
+
class XmlSieve
|
|
19
|
+
TYPE_ELEMENT = Nokogiri::XML::Reader::TYPE_ELEMENT
|
|
20
|
+
TYPE_END = Nokogiri::XML::Reader::TYPE_END_ELEMENT
|
|
21
|
+
TYPE_TEXT = Nokogiri::XML::Reader::TYPE_TEXT
|
|
22
|
+
TYPE_CDATA = Nokogiri::XML::Reader::TYPE_CDATA
|
|
23
|
+
TYPE_WS = Nokogiri::XML::Reader::TYPE_WHITESPACE
|
|
24
|
+
TYPE_SWS = Nokogiri::XML::Reader::TYPE_SIGNIFICANT_WHITESPACE
|
|
25
|
+
TYPE_COMMENT = Nokogiri::XML::Reader::TYPE_COMMENT
|
|
26
|
+
PURE_WS = /\A\s*\z/
|
|
27
|
+
|
|
28
|
+
Result = Data.define(:io, :kept, :dropped)
|
|
29
|
+
|
|
30
|
+
# Read-only view of the current filterable element.
|
|
31
|
+
class Entity
|
|
32
|
+
attr_reader :local_name, :id
|
|
33
|
+
|
|
34
|
+
def initialize(reader)
|
|
35
|
+
@reader = reader
|
|
36
|
+
@local_name = reader.local_name
|
|
37
|
+
@id = reader.attribute("id")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def outer_xml
|
|
41
|
+
@outer_xml ||= @reader.outer_xml.to_s
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_s = outer_xml
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(filterable:)
|
|
48
|
+
@filterable = filterable.to_h { |name| [name.to_s, true] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns Filter::XmlSieve::Result. Caller owns `io` (rewinded Tempfile by default).
|
|
52
|
+
def call(source, out: nil, &)
|
|
53
|
+
reader = Nokogiri::XML::Reader(to_io(source))
|
|
54
|
+
out = new_tempfile if out.nil?
|
|
55
|
+
out << "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
|
|
56
|
+
@pending_ws = +""
|
|
57
|
+
kept = 0
|
|
58
|
+
dropped = 0
|
|
59
|
+
|
|
60
|
+
while reader.read
|
|
61
|
+
case handle_node(reader, out, &)
|
|
62
|
+
when :kept then kept += 1
|
|
63
|
+
when :dropped then dropped += 1
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
flush_pending_ws(out)
|
|
68
|
+
out.flush
|
|
69
|
+
out.rewind if out.respond_to?(:rewind)
|
|
70
|
+
Result.new(io: out, kept: kept, dropped: dropped)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def new_tempfile
|
|
76
|
+
file = Tempfile.new(["swiss-netex-sieve", ".xml"])
|
|
77
|
+
file.binmode
|
|
78
|
+
file
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def handle_node(reader, out, &)
|
|
82
|
+
case reader.node_type
|
|
83
|
+
when TYPE_ELEMENT
|
|
84
|
+
handle_element(reader, out, &)
|
|
85
|
+
when TYPE_END
|
|
86
|
+
flush_pending_ws(out)
|
|
87
|
+
out << "</#{reader.name}>"
|
|
88
|
+
:structure
|
|
89
|
+
when TYPE_WS, TYPE_SWS
|
|
90
|
+
buffer_ws(reader.value.to_s)
|
|
91
|
+
:structure
|
|
92
|
+
when TYPE_TEXT
|
|
93
|
+
handle_text(reader, out)
|
|
94
|
+
when TYPE_CDATA
|
|
95
|
+
flush_pending_ws(out)
|
|
96
|
+
out << reader.value.to_s
|
|
97
|
+
:structure
|
|
98
|
+
when TYPE_COMMENT
|
|
99
|
+
flush_pending_ws(out)
|
|
100
|
+
out << "<!--#{reader.value}-->"
|
|
101
|
+
:structure
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def handle_text(reader, out)
|
|
106
|
+
value = reader.value.to_s
|
|
107
|
+
if pure_ws?(value)
|
|
108
|
+
buffer_ws(value)
|
|
109
|
+
else
|
|
110
|
+
flush_pending_ws(out)
|
|
111
|
+
out << value
|
|
112
|
+
end
|
|
113
|
+
:structure
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def handle_element(reader, out, &)
|
|
117
|
+
if @filterable[reader.local_name]
|
|
118
|
+
filter_entity(reader, out, &)
|
|
119
|
+
else
|
|
120
|
+
flush_pending_ws(out)
|
|
121
|
+
write_start(reader, out)
|
|
122
|
+
:structure
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def filter_entity(reader, out)
|
|
127
|
+
entity = Entity.new(reader)
|
|
128
|
+
if yield(entity)
|
|
129
|
+
flush_pending_ws(out)
|
|
130
|
+
out << entity.outer_xml
|
|
131
|
+
skip_element(reader)
|
|
132
|
+
:kept
|
|
133
|
+
else
|
|
134
|
+
clear_pending_ws
|
|
135
|
+
skip_element(reader)
|
|
136
|
+
:dropped
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def buffer_ws(value)
|
|
141
|
+
@pending_ws << value
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def flush_pending_ws(out)
|
|
145
|
+
return if @pending_ws.empty?
|
|
146
|
+
|
|
147
|
+
out << @pending_ws
|
|
148
|
+
@pending_ws.clear
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def clear_pending_ws
|
|
152
|
+
@pending_ws.clear
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def pure_ws?(value)
|
|
156
|
+
value.match?(PURE_WS)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def write_start(reader, out)
|
|
160
|
+
out << "<#{reader.name}"
|
|
161
|
+
reader.attributes.each { |key, value| out << " #{key}=\"#{esc_attr(value)}\"" }
|
|
162
|
+
out << (reader.empty_element? ? "/>" : ">")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def skip_element(reader)
|
|
166
|
+
return if reader.empty_element?
|
|
167
|
+
|
|
168
|
+
depth = reader.depth
|
|
169
|
+
while reader.read
|
|
170
|
+
break if reader.node_type == TYPE_END && reader.depth == depth
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def esc_attr(value)
|
|
175
|
+
value.to_s
|
|
176
|
+
.gsub("&", "&")
|
|
177
|
+
.gsub('"', """)
|
|
178
|
+
.gsub("<", "<")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def to_io(source)
|
|
182
|
+
return StringIO.new(source) if source.is_a?(String)
|
|
183
|
+
return source if source.respond_to?(:read)
|
|
184
|
+
|
|
185
|
+
raise Error, "XmlSieve expects a String or IO-like object"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "zip"
|
|
6
|
+
|
|
7
|
+
require_relative "filter/refs"
|
|
8
|
+
require_relative "filter/xml_sieve"
|
|
9
|
+
require_relative "filter/service_lines"
|
|
10
|
+
require_relative "filter/timetable"
|
|
11
|
+
require_relative "filter/timetable_batch"
|
|
12
|
+
require_relative "filter/common_frame"
|
|
13
|
+
require_relative "filter/frames"
|
|
14
|
+
require_relative "filter/frame_worker"
|
|
15
|
+
|
|
16
|
+
module SwissNetex
|
|
17
|
+
# Filter a national NeTEx package down to selected operator(s) / line(s).
|
|
18
|
+
#
|
|
19
|
+
# Pipeline: resolve operators → SERVICE lines → TIMETABLE journeys (+ refs) →
|
|
20
|
+
# resolve StopPlaces via assignments → rewrite support frames → zip + README.
|
|
21
|
+
class Filter
|
|
22
|
+
Result = Data.define(
|
|
23
|
+
:path,
|
|
24
|
+
:operator_ids,
|
|
25
|
+
:line_ids,
|
|
26
|
+
:journey_count,
|
|
27
|
+
:line_count,
|
|
28
|
+
:timetable_shards,
|
|
29
|
+
:counts
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def initialize(
|
|
33
|
+
from:,
|
|
34
|
+
operators:,
|
|
35
|
+
output:,
|
|
36
|
+
lines: [],
|
|
37
|
+
progress_io: nil,
|
|
38
|
+
allow_empty: false,
|
|
39
|
+
verbose: false
|
|
40
|
+
)
|
|
41
|
+
@from = from.to_s
|
|
42
|
+
@operator_aliases = Array(operators)
|
|
43
|
+
@line_aliases = Array(lines)
|
|
44
|
+
@output = output.to_s
|
|
45
|
+
@progress_io = progress_io
|
|
46
|
+
@allow_empty = allow_empty
|
|
47
|
+
@verbose = verbose
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def call
|
|
51
|
+
validate!
|
|
52
|
+
operator_ids = resolve_operators
|
|
53
|
+
refs = Refs.new
|
|
54
|
+
refs.add(:operators, operator_ids)
|
|
55
|
+
|
|
56
|
+
Dir.mktmpdir("swiss-netex-filter-") do |tmp|
|
|
57
|
+
line_ids = select_lines(operator_ids, refs)
|
|
58
|
+
ensure_lines!(line_ids)
|
|
59
|
+
|
|
60
|
+
journey_count, shard_names = filter_timetables(tmp, operator_ids, line_ids, refs)
|
|
61
|
+
ensure_journeys!(journey_count)
|
|
62
|
+
|
|
63
|
+
expand_stop_places(refs)
|
|
64
|
+
expand_day_types(refs)
|
|
65
|
+
write_support_frames(tmp, refs)
|
|
66
|
+
write_readme(tmp, operator_ids, line_ids, journey_count, shard_names, refs)
|
|
67
|
+
path = write_zip(tmp)
|
|
68
|
+
log_summary(path, operator_ids, line_ids, journey_count, shard_names, refs)
|
|
69
|
+
|
|
70
|
+
Result.new(
|
|
71
|
+
path: path,
|
|
72
|
+
operator_ids: operator_ids,
|
|
73
|
+
line_ids: line_ids,
|
|
74
|
+
journey_count: journey_count,
|
|
75
|
+
line_count: line_ids.size,
|
|
76
|
+
timetable_shards: shard_names.size,
|
|
77
|
+
counts: refs.counts
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def validate!
|
|
85
|
+
raise Error, "Missing --from package path" if @from.empty?
|
|
86
|
+
raise Error, "Package path not found: #{@from}" unless File.exist?(@from)
|
|
87
|
+
raise Error, "Missing --output path" if @output.empty?
|
|
88
|
+
raise Error, "No --operator given" if @operator_aliases.empty?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def resolve_operators
|
|
92
|
+
log "Indexing operators…"
|
|
93
|
+
Operators.load(@from).resolve(@operator_aliases)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def select_lines(operator_ids, refs)
|
|
97
|
+
log "Selecting SERVICE lines…"
|
|
98
|
+
lines = {}
|
|
99
|
+
Package.open(@from) do |pkg|
|
|
100
|
+
pkg.open_one(:service) do |io|
|
|
101
|
+
lines = ServiceLines.new(
|
|
102
|
+
operator_ids: operator_ids,
|
|
103
|
+
line_aliases: @line_aliases,
|
|
104
|
+
refs: refs
|
|
105
|
+
).call(io)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
log " kept #{lines.size} line(s)"
|
|
110
|
+
lines.keys
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def ensure_lines!(line_ids)
|
|
114
|
+
return if line_ids.any?
|
|
115
|
+
return if @allow_empty
|
|
116
|
+
|
|
117
|
+
raise Error,
|
|
118
|
+
"No lines matched the operator/line filter " \
|
|
119
|
+
"(use --allow-empty to write an empty package)"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def filter_timetables(tmp, operator_ids, line_ids, refs)
|
|
123
|
+
names = Package.open(@from) { |pkg| pkg.names_for(:timetable) }
|
|
124
|
+
log "Filtering #{names.size} TIMETABLE shard(s)…"
|
|
125
|
+
TimetableBatch.new(
|
|
126
|
+
package_path: @from,
|
|
127
|
+
tmp: tmp,
|
|
128
|
+
operator_ids: operator_ids,
|
|
129
|
+
line_ids: line_ids,
|
|
130
|
+
line_filter: @line_aliases.any?,
|
|
131
|
+
progress_io: @progress_io,
|
|
132
|
+
verbose: @verbose
|
|
133
|
+
).call(names, refs)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def ensure_journeys!(count)
|
|
137
|
+
return if count.positive?
|
|
138
|
+
return if @allow_empty
|
|
139
|
+
|
|
140
|
+
raise Error,
|
|
141
|
+
"No ServiceJourneys matched the filter (use --allow-empty to write anyway)"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def expand_stop_places(refs)
|
|
145
|
+
return if refs.scheduled_stop_points.empty?
|
|
146
|
+
|
|
147
|
+
log "Resolving StopPlace refs from SERVICE assignments…"
|
|
148
|
+
Package.open(@from) do |pkg|
|
|
149
|
+
pkg.open_one(:service) do |io|
|
|
150
|
+
collect_stop_place_refs(io, refs)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
log " stop places: #{refs.size(:stop_places)}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def collect_stop_place_refs(io, refs)
|
|
157
|
+
reader = Nokogiri::XML::Reader(io)
|
|
158
|
+
while reader.read
|
|
159
|
+
next unless assignment_element?(reader)
|
|
160
|
+
|
|
161
|
+
xml = reader.outer_xml
|
|
162
|
+
ssp = xml[/<ScheduledStopPointRef\b[^>]*\bref="([^"]+)"/, 1]
|
|
163
|
+
next unless ssp && refs.include?(:scheduled_stop_points, ssp)
|
|
164
|
+
|
|
165
|
+
stop = xml[/<StopPlaceRef\b[^>]*\bref="([^"]+)"/, 1]
|
|
166
|
+
refs.add(:stop_places, stop) if stop
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def assignment_element?(reader)
|
|
171
|
+
reader.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT &&
|
|
172
|
+
reader.local_name == "PassengerStopAssignment"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# DayTypes often sit before AvailabilityConditions; scan kept ACs first so
|
|
176
|
+
# DayType/DayTypeAssignment keep decisions see nested DayTypeRef values.
|
|
177
|
+
def expand_day_types(refs)
|
|
178
|
+
return if refs.availability_conditions.empty?
|
|
179
|
+
|
|
180
|
+
log "Resolving DayType refs from SERVICECALENDAR…"
|
|
181
|
+
Package.open(@from) do |pkg|
|
|
182
|
+
pkg.open_one(:service_calendar) do |io|
|
|
183
|
+
collect_day_type_refs(io, refs)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
log " day types: #{refs.size(:day_types)}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def collect_day_type_refs(io, refs)
|
|
190
|
+
reader = Nokogiri::XML::Reader(io)
|
|
191
|
+
while reader.read
|
|
192
|
+
next unless ac_element?(reader)
|
|
193
|
+
|
|
194
|
+
xml = reader.outer_xml
|
|
195
|
+
id = xml[/\bid="([^"]+)"/, 1]
|
|
196
|
+
next unless id && refs.include?(:availability_conditions, id)
|
|
197
|
+
|
|
198
|
+
refs.merge_from_xml(xml)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def ac_element?(reader)
|
|
203
|
+
reader.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT &&
|
|
204
|
+
reader.local_name == "AvailabilityCondition"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def write_support_frames(tmp, refs)
|
|
208
|
+
log "Rewriting support frames…"
|
|
209
|
+
names = Package.open(@from) do |pkg|
|
|
210
|
+
{
|
|
211
|
+
resource: pkg.names_for(:resource).first,
|
|
212
|
+
service: pkg.names_for(:service).first,
|
|
213
|
+
site: pkg.names_for(:site).first,
|
|
214
|
+
service_calendar: pkg.names_for(:service_calendar).first,
|
|
215
|
+
common: pkg.names_for(:common).first
|
|
216
|
+
}
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
pairs = names.filter_map { |kind, name| [kind, name] if name }
|
|
220
|
+
worker = FrameWorker.new(package_path: @from, tmp: tmp, refs: refs)
|
|
221
|
+
results = worker.call_many(pairs)
|
|
222
|
+
return unless @verbose
|
|
223
|
+
|
|
224
|
+
pairs.each do |kind,|
|
|
225
|
+
result = results.fetch(kind)
|
|
226
|
+
log " #{kind}: kept #{result.fetch("kept")}, dropped #{result.fetch("dropped")}"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def write_readme(tmp, operator_ids, line_ids, journey_count, shard_names, refs)
|
|
231
|
+
File.write(
|
|
232
|
+
File.join(tmp, "README.txt"),
|
|
233
|
+
readme_body(operator_ids, line_ids, journey_count, shard_names, refs)
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def readme_body(operator_ids, line_ids, journey_count, shard_names, refs)
|
|
238
|
+
<<~TEXT
|
|
239
|
+
swiss-netex filtered package
|
|
240
|
+
============================
|
|
241
|
+
|
|
242
|
+
Tool version: #{VERSION}
|
|
243
|
+
Source: #{@from}
|
|
244
|
+
Generated: #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}
|
|
245
|
+
|
|
246
|
+
Filters
|
|
247
|
+
-------
|
|
248
|
+
Operators: #{operator_ids.join(", ")}
|
|
249
|
+
Operator aliases: #{@operator_aliases.join(", ")}
|
|
250
|
+
Lines filter: #{lines_filter_label}
|
|
251
|
+
Line ids kept: #{line_ids.size}
|
|
252
|
+
|
|
253
|
+
Counts
|
|
254
|
+
------
|
|
255
|
+
ServiceJourneys: #{journey_count}
|
|
256
|
+
TIMETABLE shards: #{shard_names.size}
|
|
257
|
+
ScheduledStopPoints: #{refs.size(:scheduled_stop_points)}
|
|
258
|
+
StopPlaces: #{refs.size(:stop_places)}
|
|
259
|
+
DestinationDisplays: #{refs.size(:destination_displays)}
|
|
260
|
+
AvailabilityConditions: #{refs.size(:availability_conditions)}
|
|
261
|
+
|
|
262
|
+
Notes
|
|
263
|
+
-----
|
|
264
|
+
- Timetable data remains under OpenTransportData terms.
|
|
265
|
+
- This package is a subset of the Swiss national NeTEx export.
|
|
266
|
+
- Empty TIMETABLE shards were omitted; kept shards keep their original
|
|
267
|
+
national indices (gaps are normal).
|
|
268
|
+
- Operating days often use AvailabilityCondition ValidDayBits rather
|
|
269
|
+
than DayType / DayTypeRef. DayTypes: #{refs.size(:day_types)} collected.
|
|
270
|
+
- COMMON JourneyMeeting / InterchangeRule entities are kept only when
|
|
271
|
+
every tracked journey, stop, and line ref still resolves in this
|
|
272
|
+
package. Cross-operator rules disappear unless both sides are in the
|
|
273
|
+
filter; an empty connection section is valid.
|
|
274
|
+
TEXT
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def lines_filter_label
|
|
278
|
+
@line_aliases.empty? ? "(all lines for operators)" : @line_aliases.join(", ")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def write_zip(tmp)
|
|
282
|
+
destination = resolve_output_path
|
|
283
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
284
|
+
# Write beside the destination then rename so an interrupted run cannot
|
|
285
|
+
# leave a truncated zip where a previous good output was.
|
|
286
|
+
temp_path = "#{destination}.partial.#{Process.pid}"
|
|
287
|
+
|
|
288
|
+
begin
|
|
289
|
+
Zip::File.open(temp_path, create: true) do |zip|
|
|
290
|
+
Dir.children(tmp).sort.each do |name|
|
|
291
|
+
path = File.join(tmp, name)
|
|
292
|
+
next unless File.file?(path)
|
|
293
|
+
|
|
294
|
+
zip.add(name, path)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
File.rename(temp_path, destination)
|
|
298
|
+
ensure
|
|
299
|
+
FileUtils.rm_f(temp_path)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
log "Wrote #{destination}" unless @verbose
|
|
303
|
+
destination
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def resolve_output_path
|
|
307
|
+
expanded = File.expand_path(@output)
|
|
308
|
+
return expanded if @output.match?(/\.zip\z/i)
|
|
309
|
+
return File.join(expanded, default_output_name) if directory_output?
|
|
310
|
+
return "#{expanded}.zip" if bare_output_stem?
|
|
311
|
+
|
|
312
|
+
expanded
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def directory_output?
|
|
316
|
+
File.directory?(@output) || @output.end_with?("/")
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# `-o tl` → tl.zip. Explicit extensions (`tl.tar`) and dirs stay unchanged.
|
|
320
|
+
def bare_output_stem?
|
|
321
|
+
File.extname(@output).empty?
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def default_output_name
|
|
325
|
+
base = File.basename(@from, ".*")
|
|
326
|
+
slug = @operator_aliases.map { |alias_name| alias_name.to_s.gsub(/[^\w.-]+/, "-") }.join("-")
|
|
327
|
+
"#{base}_op-#{slug}.zip"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def log_summary(path, operator_ids, line_ids, journey_count, shard_names, refs)
|
|
331
|
+
log "Summary: operators=#{operator_ids.size} lines=#{line_ids.size} " \
|
|
332
|
+
"journeys=#{journey_count} timetable_shards=#{shard_names.size} " \
|
|
333
|
+
"stops=#{refs.size(:stop_places)}"
|
|
334
|
+
return unless @verbose
|
|
335
|
+
|
|
336
|
+
refs.counts.sort_by { |name, _| name.to_s }.each do |name, count|
|
|
337
|
+
log " refs.#{name}: #{count}"
|
|
338
|
+
end
|
|
339
|
+
log "Wrote #{path}"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def log(message)
|
|
343
|
+
@progress_io&.puts(message)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module SwissNetex
|
|
8
|
+
# Minimal stdlib HTTP helper. Follows redirects and streams response bodies.
|
|
9
|
+
# Inject a stub in tests instead of hitting the network.
|
|
10
|
+
#
|
|
11
|
+
# Note: Cloudflare R2 signed URLs used by opentransportdata.swiss reject HEAD
|
|
12
|
+
# (403). Size comes from the streaming GET's Content-Length header instead.
|
|
13
|
+
class Http
|
|
14
|
+
DEFAULT_OPEN_TIMEOUT = 30
|
|
15
|
+
DEFAULT_READ_TIMEOUT = 60
|
|
16
|
+
DEFAULT_REDIRECT_LIMIT = 10
|
|
17
|
+
|
|
18
|
+
NETWORK_ERRORS = [
|
|
19
|
+
SocketError,
|
|
20
|
+
Timeout::Error,
|
|
21
|
+
Errno::ECONNREFUSED,
|
|
22
|
+
Errno::ECONNRESET,
|
|
23
|
+
Errno::EHOSTUNREACH,
|
|
24
|
+
OpenSSL::SSL::SSLError,
|
|
25
|
+
EOFError,
|
|
26
|
+
Net::OpenTimeout,
|
|
27
|
+
Net::ReadTimeout
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
def initialize(open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
|
|
31
|
+
@open_timeout = open_timeout
|
|
32
|
+
@read_timeout = read_timeout
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def get_body(url)
|
|
36
|
+
request(url, &:body)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Streams the response body.
|
|
40
|
+
#
|
|
41
|
+
# If a block is given, it is called as `block.call(chunk, content_length)`
|
|
42
|
+
# for each body chunk. `content_length` is the response Content-Length
|
|
43
|
+
# (Integer) or nil — the same value on every call.
|
|
44
|
+
#
|
|
45
|
+
# Returns the Content-Length Integer, or nil when absent.
|
|
46
|
+
def stream(url, &block)
|
|
47
|
+
content_length = nil
|
|
48
|
+
|
|
49
|
+
request(url) do |response|
|
|
50
|
+
content_length = parse_content_length(response)
|
|
51
|
+
response.read_body do |chunk|
|
|
52
|
+
block&.call(chunk, content_length)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
content_length
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def request(url, redirect_limit: DEFAULT_REDIRECT_LIMIT, &)
|
|
62
|
+
raise Error, "Too many HTTP redirects" if redirect_limit.negative?
|
|
63
|
+
|
|
64
|
+
uri = parse_http_uri(url)
|
|
65
|
+
|
|
66
|
+
Net::HTTP.start(
|
|
67
|
+
uri.host,
|
|
68
|
+
uri.port,
|
|
69
|
+
use_ssl: uri.scheme == "https",
|
|
70
|
+
open_timeout: @open_timeout,
|
|
71
|
+
read_timeout: @read_timeout
|
|
72
|
+
) do |http|
|
|
73
|
+
# Identity encoding keeps Content-Length equal to yielded body bytes.
|
|
74
|
+
# Net::HTTP would otherwise decode gzip while CL stays wire size, which
|
|
75
|
+
# breaks Download size verification. Name is get (not request) so the
|
|
76
|
+
# recursive request(...) call below is not shadowed by a local.
|
|
77
|
+
get = Net::HTTP::Get.new(uri)
|
|
78
|
+
get["Accept-Encoding"] = "identity"
|
|
79
|
+
|
|
80
|
+
http.request(get) do |response|
|
|
81
|
+
case response
|
|
82
|
+
when Net::HTTPRedirection
|
|
83
|
+
location = response["location"]
|
|
84
|
+
raise Error, "Redirect without Location from #{uri}" if location.nil? || location.empty?
|
|
85
|
+
|
|
86
|
+
return request(
|
|
87
|
+
absolute_redirect(uri, location),
|
|
88
|
+
redirect_limit: redirect_limit - 1,
|
|
89
|
+
&
|
|
90
|
+
)
|
|
91
|
+
when Net::HTTPSuccess
|
|
92
|
+
return yield(response)
|
|
93
|
+
else
|
|
94
|
+
raise Error, "HTTP #{response.code} for #{uri}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
rescue Error
|
|
99
|
+
raise
|
|
100
|
+
rescue *NETWORK_ERRORS => e
|
|
101
|
+
raise Error, "HTTP request failed for #{url}: #{e.message}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def parse_http_uri(url)
|
|
105
|
+
uri = URI.parse(url.to_s)
|
|
106
|
+
raise Error, "URL must be HTTP(S): #{url}" unless uri.is_a?(URI::HTTP)
|
|
107
|
+
raise Error, "URL must include a host: #{url}" if uri.host.to_s.empty?
|
|
108
|
+
|
|
109
|
+
uri
|
|
110
|
+
rescue URI::InvalidURIError
|
|
111
|
+
raise Error, "Invalid URL: #{url}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def absolute_redirect(from_uri, location)
|
|
115
|
+
redirect = URI.parse(location)
|
|
116
|
+
return redirect.to_s if redirect.absolute?
|
|
117
|
+
|
|
118
|
+
URI.join(from_uri, location).to_s
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def parse_content_length(response)
|
|
122
|
+
value = response["content-length"]
|
|
123
|
+
return if value.nil? || value.empty?
|
|
124
|
+
|
|
125
|
+
Integer(value)
|
|
126
|
+
rescue ArgumentError
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|