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,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