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,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module SwissNetex
6
+ class Filter
7
+ # Per-frame rewrite rules once journey refs are known.
8
+ # Each method returns XmlSieve::Result (tempfile IO + counters).
9
+ #
10
+ # Unknown large entity types are listed as filterable and denied by default so
11
+ # a future national export that adds Route / JourneyPattern / ServiceLink does
12
+ # not pass millions of unreferenced entities through.
13
+ module Frames
14
+ module_function
15
+
16
+ SERVICE_ENTITIES = %w[
17
+ Line DestinationDisplay ScheduledStopPoint PassengerStopAssignment
18
+ Notice DefaultConnection SiteConnection Direction
19
+ Route JourneyPattern ServiceLink ServiceJourneyPattern TimingLink
20
+ ].freeze
21
+
22
+ SITE_ENTITIES = %w[StopPlace TopographicPlace Quay].freeze
23
+
24
+ RESOURCE_ENTITIES = %w[
25
+ Operator ResponsibilitySet TypeOfProductCategory VehicleType TypeOfNotice
26
+ ].freeze
27
+
28
+ CALENDAR_ENTITIES = %w[AvailabilityCondition DayType DayTypeAssignment].freeze
29
+
30
+ def service(source, refs)
31
+ rewrite(source, SERVICE_ENTITIES) { |entity| keep_service_entity?(entity, refs) }
32
+ end
33
+
34
+ def site(source, refs)
35
+ rewrite(source, SITE_ENTITIES) do |entity|
36
+ case entity.local_name
37
+ when "TopographicPlace"
38
+ true # small fixed/shared set in Swiss exports
39
+ when "StopPlace"
40
+ # Nested quays travel with kept StopPlace outer_xml; no extra ref buckets.
41
+ refs.include?(:stop_places, entity.id)
42
+ else
43
+ false
44
+ end
45
+ end
46
+ end
47
+
48
+ def resource(source, refs)
49
+ rewrite(source, RESOURCE_ENTITIES) do |entity|
50
+ case entity.local_name
51
+ when "TypeOfNotice" then true # small catalogue
52
+ when "Operator" then refs.include?(:operators, entity.id)
53
+ when "ResponsibilitySet" then refs.include?(:responsibility_sets, entity.id)
54
+ when "TypeOfProductCategory" then refs.include?(:type_of_product_categories, entity.id)
55
+ when "VehicleType" then refs.include?(:vehicle_types, entity.id)
56
+ else false
57
+ end
58
+ end
59
+ end
60
+
61
+ def common(source, refs)
62
+ CommonFrame.new(refs).call(source)
63
+ end
64
+
65
+ def service_calendar(source, refs)
66
+ rewrite(source, CALENDAR_ENTITIES) do |entity|
67
+ case entity.local_name
68
+ when "AvailabilityCondition" then refs.include?(:availability_conditions, entity.id)
69
+ when "DayType" then refs.include?(:day_types, entity.id)
70
+ when "DayTypeAssignment" then day_type_assignment_keep?(entity.outer_xml, refs)
71
+ else false
72
+ end
73
+ end
74
+ end
75
+
76
+ def rewrite(source, filterable, &)
77
+ XmlSieve.new(filterable: filterable).call(source, &)
78
+ end
79
+
80
+ def keep_service_entity?(entity, refs)
81
+ case entity.local_name
82
+ when "Line" then refs.include?(:lines, entity.id)
83
+ when "DestinationDisplay" then refs.include?(:destination_displays, entity.id)
84
+ when "ScheduledStopPoint" then refs.include?(:scheduled_stop_points, entity.id)
85
+ when "Notice" then refs.include?(:notices, entity.id)
86
+ when "Direction" then true # small fixed set in Swiss exports
87
+ when "PassengerStopAssignment" then ssp_ref_kept?(entity.outer_xml, refs)
88
+ when "DefaultConnection" then stop_place_ref_kept?(entity.outer_xml, refs)
89
+ when "SiteConnection" then site_connection_kept?(entity.outer_xml, refs)
90
+ else false
91
+ end
92
+ end
93
+
94
+ def ssp_ref_kept?(xml, refs)
95
+ ref = first_ref(xml, "ScheduledStopPointRef")
96
+ ref && refs.include?(:scheduled_stop_points, ref)
97
+ end
98
+
99
+ def stop_place_ref_kept?(xml, refs)
100
+ ref = first_ref(xml, "StopPlaceRef")
101
+ ref && refs.include?(:stop_places, ref)
102
+ end
103
+
104
+ # Keep walk transfers only when both ends are in the filtered stop set.
105
+ def site_connection_kept?(xml, refs)
106
+ refs_list = all_refs(xml, "StopPlaceRef")
107
+ !refs_list.empty? && refs_list.all? { |ref| refs.include?(:stop_places, ref) }
108
+ end
109
+
110
+ def first_ref(xml, element_name)
111
+ all_refs(xml, element_name).first
112
+ end
113
+
114
+ def all_refs(xml, element_name)
115
+ text = xml.to_s
116
+ text.scan(/<#{element_name}\b[^>]*\bref="([^"]+)"/).flatten +
117
+ text.scan(/<[^:>]+:#{element_name}\b[^>]*\bref="([^"]+)"/).flatten
118
+ end
119
+
120
+ def day_type_assignment_keep?(xml, refs)
121
+ ref = first_ref(xml, "DayTypeRef")
122
+ ref && refs.include?(:day_types, ref)
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwissNetex
4
+ class Filter
5
+ # Mutable sets of NeTEx ids collected while filtering, used for transitive keep.
6
+ class Refs
7
+ ATTRS = %i[
8
+ operators
9
+ lines
10
+ scheduled_stop_points
11
+ stop_places
12
+ destination_displays
13
+ notices
14
+ availability_conditions
15
+ day_types
16
+ responsibility_sets
17
+ type_of_product_categories
18
+ type_of_services
19
+ vehicle_types
20
+ service_facility_sets
21
+ train_numbers
22
+ journeys
23
+ ].freeze
24
+
25
+ REF_BUCKETS = {
26
+ "OperatorRef" => :operators,
27
+ "LineRef" => :lines,
28
+ "ScheduledStopPointRef" => :scheduled_stop_points,
29
+ "StopPlaceRef" => :stop_places,
30
+ "DestinationDisplayRef" => :destination_displays,
31
+ "NoticeRef" => :notices,
32
+ "AvailabilityConditionRef" => :availability_conditions,
33
+ "DayTypeRef" => :day_types,
34
+ "TypeOfProductCategoryRef" => :type_of_product_categories,
35
+ "TypeOfServiceRef" => :type_of_services,
36
+ "VehicleTypeRef" => :vehicle_types,
37
+ "ServiceFacilitySetRef" => :service_facility_sets,
38
+ "TrainNumberRef" => :train_numbers,
39
+ "ResponsibilitySetRef" => :responsibility_sets
40
+ }.freeze
41
+
42
+ attr_reader(*ATTRS)
43
+
44
+ def initialize
45
+ ATTRS.each { |name| instance_variable_set(:"@#{name}", {}) }
46
+ end
47
+
48
+ def add(collection, *ids)
49
+ set = public_send(collection)
50
+ ids.flatten.each do |id|
51
+ text = id.to_s.strip
52
+ set[text] = true unless text.empty?
53
+ end
54
+ end
55
+
56
+ def include?(collection, id)
57
+ public_send(collection).key?(id.to_s)
58
+ end
59
+
60
+ def size(collection)
61
+ public_send(collection).size
62
+ end
63
+
64
+ def merge_from_xml(xml)
65
+ xml.to_s.scan(/<([A-Za-z0-9:]+)[^>]*\bref="([^"]+)"/) do |raw_name, ref|
66
+ bucket = REF_BUCKETS[local_name(raw_name)]
67
+ add(bucket, ref) if bucket
68
+ end
69
+ extract_responsibility_set(xml)
70
+ end
71
+
72
+ # Compact export for IPC (fork workers) / merging batch deltas.
73
+ def export
74
+ ATTRS.each_with_object({}) do |name, hash|
75
+ keys = public_send(name).keys
76
+ hash[name] = keys unless keys.empty?
77
+ end
78
+ end
79
+
80
+ def merge_export(data)
81
+ return self unless data
82
+
83
+ data.each do |name, ids|
84
+ add(name.to_sym, ids) if ATTRS.include?(name.to_sym)
85
+ end
86
+ self
87
+ end
88
+
89
+ def counts
90
+ ATTRS.to_h { |name| [name, public_send(name).size] }
91
+ end
92
+
93
+ private
94
+
95
+ def local_name(name)
96
+ name.to_s.split(":", 2).last
97
+ end
98
+
99
+ def extract_responsibility_set(xml)
100
+ xml.to_s.scan(/\bresponsibilitySetRef="([^"]+)"/) do |ref,|
101
+ add(:responsibility_sets, ref)
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require "stringio"
5
+
6
+ module SwissNetex
7
+ class Filter
8
+ # First SERVICE pass: choose Line ids for the selected operators (and optional
9
+ # line aliases) without rewriting the frame yet.
10
+ class ServiceLines
11
+ Line = Data.define(
12
+ :id,
13
+ :operator_id,
14
+ :public_code,
15
+ :short_name,
16
+ :slnid,
17
+ :name
18
+ )
19
+
20
+ def initialize(operator_ids:, line_aliases: [], refs: nil)
21
+ @operator_ids = operator_ids.to_h { |id| [id.to_s, true] }
22
+ @line_aliases = normalize_aliases(line_aliases)
23
+ @refs = refs
24
+ end
25
+
26
+ def call(source)
27
+ kept = {}
28
+ each_line_xml(source) do |xml|
29
+ line = parse_line(xml)
30
+ next unless line && keep_line?(line)
31
+
32
+ kept[line.id] = line
33
+ remember(line.id, xml)
34
+ end
35
+ kept
36
+ end
37
+
38
+ private
39
+
40
+ def remember(id, xml)
41
+ return unless @refs
42
+
43
+ @refs.add(:lines, id)
44
+ @refs.merge_from_xml(xml)
45
+ end
46
+
47
+ def keep_line?(line)
48
+ return false unless @operator_ids.key?(line.operator_id)
49
+ return true if @line_aliases.empty?
50
+
51
+ line_matches_alias?(line)
52
+ end
53
+
54
+ def line_matches_alias?(line)
55
+ [
56
+ line.id,
57
+ line.public_code,
58
+ line.short_name,
59
+ line.slnid,
60
+ line.name
61
+ ].compact.map { |value| normalize(value) }.any? { |key| @line_aliases.key?(key) }
62
+ end
63
+
64
+ def each_line_xml(source)
65
+ reader = Nokogiri::XML::Reader(to_io(source))
66
+ while reader.read
67
+ next unless line_element?(reader)
68
+
69
+ xml = reader.outer_xml
70
+ yield xml if xml && !xml.empty?
71
+ end
72
+ end
73
+
74
+ def line_element?(reader)
75
+ reader.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT &&
76
+ reader.local_name == "Line"
77
+ end
78
+
79
+ def parse_line(xml)
80
+ root = Nokogiri::XML(xml).root
81
+ return unless root
82
+
83
+ id = root["id"].to_s.strip
84
+ return if id.empty?
85
+
86
+ Line.new(
87
+ id: id,
88
+ operator_id: attr_ref(root, "OperatorRef"),
89
+ public_code: text_at(root, "PublicCode"),
90
+ short_name: text_at(root, "ShortName"),
91
+ slnid: key_value(root, "SLNID"),
92
+ name: text_at(root, "Name")
93
+ )
94
+ end
95
+
96
+ def attr_ref(node, element_name)
97
+ child = node.at_xpath(".//*[local-name()=#{element_name.inspect}]")
98
+ blank_to_nil(child&.[]("ref")&.strip)
99
+ end
100
+
101
+ def text_at(node, element_name)
102
+ child = node.at_xpath(".//*[local-name()=#{element_name.inspect}]")
103
+ blank_to_nil(child&.text&.strip)
104
+ end
105
+
106
+ def key_value(node, key_name)
107
+ node.xpath(".//*[local-name()='KeyValue']").each do |kv|
108
+ key = kv.at_xpath(".//*[local-name()='Key']")&.text&.strip
109
+ next unless key&.casecmp?(key_name)
110
+
111
+ return blank_to_nil(kv.at_xpath(".//*[local-name()='Value']")&.text&.strip)
112
+ end
113
+ nil
114
+ end
115
+
116
+ def blank_to_nil(text)
117
+ text.nil? || text.empty? ? nil : text
118
+ end
119
+
120
+ def normalize_aliases(aliases)
121
+ Array(aliases).each_with_object({}) do |value, hash|
122
+ key = normalize(value)
123
+ hash[key] = true unless key.empty?
124
+ end
125
+ end
126
+
127
+ def normalize(value)
128
+ value.to_s.strip.downcase
129
+ end
130
+
131
+ def to_io(source)
132
+ return StringIO.new(source) if source.is_a?(String)
133
+ return source if source.respond_to?(:read)
134
+
135
+ raise Error, "ServiceLines expects a String or IO-like object"
136
+ end
137
+ end
138
+ end
139
+ end