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.
@@ -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("&", "&amp;")
177
+ .gsub('"', "&quot;")
178
+ .gsub("<", "&lt;")
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