aipp 0.2.6 → 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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +21 -0
  4. data/README.md +147 -91
  5. data/exe/aip2aixm +2 -2
  6. data/exe/aip2ofmx +2 -2
  7. data/lib/aipp/aip.rb +96 -11
  8. data/lib/aipp/border.rb +77 -46
  9. data/lib/aipp/debugger.rb +101 -0
  10. data/lib/aipp/downloader.rb +18 -5
  11. data/lib/aipp/executable.rb +33 -20
  12. data/lib/aipp/parser.rb +42 -37
  13. data/lib/aipp/patcher.rb +5 -2
  14. data/lib/aipp/regions/LF/README.md +49 -0
  15. data/lib/aipp/regions/LF/aerodromes.rb +223 -0
  16. data/lib/aipp/regions/LF/d_p_r_airspaces.rb +56 -0
  17. data/lib/aipp/regions/LF/dangerous_activities.rb +49 -0
  18. data/lib/aipp/regions/LF/designated_points.rb +47 -0
  19. data/lib/aipp/regions/LF/fixtures/aerodromes.yml +608 -0
  20. data/lib/aipp/regions/LF/helipads.rb +122 -0
  21. data/lib/aipp/regions/LF/helpers/base.rb +167 -174
  22. data/lib/aipp/regions/LF/helpers/surface.rb +49 -0
  23. data/lib/aipp/regions/LF/helpers/usage_limitation.rb +20 -0
  24. data/lib/aipp/regions/LF/navigational_aids.rb +85 -0
  25. data/lib/aipp/regions/LF/obstacles.rb +153 -0
  26. data/lib/aipp/regions/LF/serviced_airspaces.rb +70 -0
  27. data/lib/aipp/regions/LF/services.rb +172 -0
  28. data/lib/aipp/t_hash.rb +3 -4
  29. data/lib/aipp/version.rb +1 -1
  30. data/lib/aipp.rb +7 -5
  31. data/lib/core_ext/enumerable.rb +2 -2
  32. data/lib/core_ext/hash.rb +21 -5
  33. data/lib/core_ext/nokogiri.rb +54 -0
  34. data/lib/core_ext/string.rb +32 -65
  35. data.tar.gz.sig +0 -0
  36. metadata +70 -81
  37. metadata.gz.sig +0 -0
  38. data/lib/aipp/airac.rb +0 -55
  39. data/lib/aipp/regions/LF/AD-1.3.rb +0 -177
  40. data/lib/aipp/regions/LF/AD-1.6.rb +0 -33
  41. data/lib/aipp/regions/LF/AD-2.rb +0 -344
  42. data/lib/aipp/regions/LF/AD-3.1.rb +0 -185
  43. data/lib/aipp/regions/LF/ENR-2.1.rb +0 -167
  44. data/lib/aipp/regions/LF/ENR-4.1.rb +0 -41
  45. data/lib/aipp/regions/LF/ENR-4.3.rb +0 -27
  46. data/lib/aipp/regions/LF/ENR-5.1.rb +0 -106
  47. data/lib/aipp/regions/LF/ENR-5.4.rb +0 -90
  48. data/lib/aipp/regions/LF/ENR-5.5.rb +0 -55
  49. data/lib/aipp/regions/LF/fixtures/AD-1.3.yml +0 -511
  50. data/lib/aipp/regions/LF/fixtures/AD-2.yml +0 -185
  51. data/lib/aipp/regions/LF/fixtures/AD-3.1.yml +0 -10
  52. data/lib/aipp/regions/LF/helpers/URL.rb +0 -26
  53. data/lib/aipp/regions/LF/helpers/navigational_aid.rb +0 -104
  54. data/lib/aipp/regions/LF/helpers/radio_AD.rb +0 -110
  55. data/lib/core_ext/object.rb +0 -43
@@ -0,0 +1,153 @@
1
+ module AIPP
2
+ module LF
3
+
4
+ class Obstacles < AIP
5
+
6
+ include AIPP::LF::Helpers::Base
7
+
8
+ # Map type descriptions to AIXM types and remarks
9
+ TYPES = {
10
+ 'Antenne' => [:antenna],
11
+ 'Autre' => [:other],
12
+ 'Bâtiment' => [:building],
13
+ 'Câble' => [:other, 'Cable / Câble'],
14
+ 'Centrale thermique' => [:building, 'Thermal power plant / Centrale thermique'],
15
+ "Château d'eau" => [:tower, "Water tower / Château d'eau"],
16
+ 'Cheminée' => [:chimney],
17
+ 'Derrick' => [:tower, 'Derrick'],
18
+ 'Eglise' => [:tower, 'Church / Eglise'],
19
+ 'Eolienne' => [:wind_turbine],
20
+ 'Eolienne(s)' => [:wind_turbine],
21
+ 'Grue' => [:tower, 'Crane / Grue'],
22
+ 'Mât' => [:mast],
23
+ 'Phare marin' => [:tower, 'Lighthouse / Phare marin'],
24
+ 'Pile de pont' => [:other, 'Bridge piers / Pile de pont'],
25
+ 'Portique' => [:building, 'Arch / Portique'],
26
+ 'Pylône' => [:mast, 'Pylon / Pylône'],
27
+ 'Silo' => [:tower, 'Silo'],
28
+ 'Terril' => [:other, 'Spoil heap / Teril'],
29
+ 'Torchère' => [:chimney, 'Flare / Torchère'],
30
+ 'Tour' => [:tower],
31
+ 'Treillis métallique' => [:other, 'Metallic grid / Treillis métallique']
32
+ }.freeze
33
+
34
+ def parse
35
+ if options[:region_options].include? 'lf_obstacles_xlsx'
36
+ info("reading obstacles from XLSX")
37
+ @xlsx = read('Obstacles')
38
+ parse_from_xlsx
39
+ else
40
+ parse_from_xml
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def parse_from_xlsx
47
+ # Build obstacles
48
+ @xlsx.sheet(@xlsx.sheets.find(/^data/i).first).each(
49
+ name: 'IDENTIFICATEUR',
50
+ type: 'TYPE',
51
+ count: 'NOMBRE',
52
+ longitude: 'LONGITUDE DECIMALE',
53
+ latitude: 'LATITUDE DECIMALE',
54
+ elevation: 'ALTITUDE AU SOMMET',
55
+ height: 'HAUTEUR HORS SOL',
56
+ height_unit: 'UNITE',
57
+ horizontal_accuracy: 'PRECISION HORIZONTALE',
58
+ vertical_accuracy: 'PRECISION VERTICALE',
59
+ visibility: 'BALISAGE',
60
+ remarks: 'REMARK',
61
+ effective_on: 'DATE DE MISE EN VIGUEUR'
62
+ ).with_index(0) do |row, index|
63
+ next unless row[:effective_on].to_s.match? /\d{8}/
64
+ type, type_remarks = TYPES.fetch(row[:type])
65
+ count = row[:count].to_i
66
+ obstacle = AIXM.obstacle(
67
+ source: source(section: 'ENR', position: index),
68
+ name: row[:name],
69
+ type: type,
70
+ xy: AIXM.xy(lat: row[:latitude].to_f, long: row[:longitude].to_f),
71
+ z: AIXM.z(row[:elevation].to_i, :qnh)
72
+ ).tap do |obstacle|
73
+ obstacle.height = AIXM.d(row[:height].to_i, row[:height_unit])
74
+ if row[:horizontal_accuracy]
75
+ accuracy = row[:horizontal_accuracy].split
76
+ obstacle.xy_accuracy = AIXM.d(accuracy.first.to_i, accuracy.last)
77
+ end
78
+ if row[:vertical_accuracy]
79
+ accuracy = row[:horizontal_accuracy].split
80
+ obstacle.z_accuracy = AIXM.d(accuracy.first.to_i, accuracy.last)
81
+ end
82
+ obstacle.marking = row[:visibility].match?(/jour/i)
83
+ obstacle.lighting = row[:visibility].match?(/nuit/i)
84
+ obstacle.remarks = {
85
+ 'type' => type_remarks,
86
+ 'number/nombre' => (count if count > 1),
87
+ 'details' => row[:remarks],
88
+ 'effective/mise en vigueur' => (row[:effective_on].to_s.unpack("a4a2a2").join("-") if row[:updated_on])
89
+ }.to_remarks
90
+ # Group obstacles
91
+ if aixm.features.find_by(:obstacle, xy: obstacle.xy).any?
92
+ warn("duplicate obstacle #{obstacle.name}", severe: false)
93
+ else
94
+ if count > 1
95
+ obstacle_group = AIXM.obstacle_group(
96
+ source: obstacle.source,
97
+ name: obstacle.name
98
+ ).tap do |obstacle_group|
99
+ obstacle_group.remarks = "#{count} obstacles"
100
+ end
101
+ obstacle_group.add_obstacle obstacle
102
+ add obstacle_group
103
+ else
104
+ add obstacle
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+
111
+ def parse_from_xml
112
+ cache.obstacle.css(%Q(Obstacle[lk^="[LF]"])).each do |node|
113
+ # Build obstacles
114
+ type, type_remarks = TYPES.fetch(node.(:TypeObst))
115
+ count = node.(:Combien).to_i
116
+ obstacle = AIXM.obstacle(
117
+ source: source(section: 'ENR', position: node.line),
118
+ name: node.(:NumeroNom),
119
+ type: type,
120
+ xy: xy_from(node.(:Geometrie)),
121
+ z: AIXM.z(node.(:AmslFt).to_i, :qnh)
122
+ ).tap do |obstacle|
123
+ obstacle.height = AIXM.d(node.(:AglFt).to_i, :ft)
124
+ obstacle.marking = node.(:Balisage).match?(/jour/i)
125
+ obstacle.lighting = node.(:Balisage).match?(/nuit/i)
126
+ obstacle.remarks = {
127
+ 'type' => type_remarks,
128
+ 'number/nombre' => (count if count > 1)
129
+ }.to_remarks
130
+ end
131
+ # Group obstacles
132
+ if aixm.features.find_by(:obstacle, xy: obstacle.xy).any?
133
+ warn("duplicate obstacle #{obstacle.name}", severe: false)
134
+ else
135
+ if count > 1
136
+ obstacle_group = AIXM.obstacle_group(
137
+ source: obstacle.source,
138
+ name: obstacle.name
139
+ ).tap do |obstacle_group|
140
+ obstacle_group.remarks = "#{count} obstacles"
141
+ end
142
+ obstacle_group.add_obstacle obstacle
143
+ add obstacle_group
144
+ else
145
+ add obstacle
146
+ end
147
+ end
148
+ end
149
+ end
150
+
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,70 @@
1
+ module AIPP
2
+ module LF
3
+
4
+ class ServicedAirspaces < AIP
5
+
6
+ include AIPP::LF::Helpers::Base
7
+
8
+ # Map source types to type and optional local type and skip regexp
9
+ SOURCE_TYPES = {
10
+ 'FIR' => { type: 'FIR' },
11
+ 'UIR' => { type: 'UIR' },
12
+ 'UTA' => { type: 'UTA' },
13
+ 'CTA' => { type: 'CTA' },
14
+ 'LTA' => { type: 'CTA', local_type: 'LTA' },
15
+ 'TMA' => { type: 'TMA', skip: /geneve/i }, # Geneva listed FYI only
16
+ 'SIV' => { type: 'SECTOR', local_type: 'FIZ/SIV' }, # providing FIS
17
+ 'CTR' => { type: 'CTR' },
18
+ 'RMZ' => { type: 'RAS', local_type: 'RMZ' },
19
+ 'TMZ' => { type: 'RAS', local_type: 'TMZ' },
20
+ 'RMZ-TMZ' => { type: 'RAS', local_type: 'RMZ-TMZ' }
21
+ }.freeze
22
+
23
+ # Map airspace "<type> <name>" to location indicator
24
+ FIR_LOCATION_INDICATORS = {
25
+ 'BORDEAUX' => 'LFBB',
26
+ 'BREST' => 'LFRR',
27
+ 'MARSEILLE' => 'LFMM',
28
+ 'PARIS' => 'LFFF',
29
+ 'REIMS' => 'LFRR'
30
+ }.freeze
31
+
32
+ def parse
33
+ SOURCE_TYPES.each do |source_type, target|
34
+ verbose_info("processing #{source_type}")
35
+ cache.espace.css(%Q(Espace[lk^="[LF][#{source_type} "])).each do |espace_node|
36
+ # Skip all delegated airspaces
37
+ next if espace_node.(:Nom).match? /deleg/i
38
+ next if (re = target[:skip]) && espace_node.(:Nom).match?(re)
39
+ # Build airspaces and layers
40
+ cache.partie.css(%Q(Partie:has(Espace[pk="#{espace_node['pk']}"]))).each do |partie_node|
41
+ add(
42
+ AIXM.airspace(
43
+ source: source(section: 'ENR', position: espace_node.line),
44
+ name: [
45
+ espace_node.(:Nom),
46
+ partie_node.(:NomPartie).remove(/^\.$/).blank_to_nil
47
+ ].compact.join(' '),
48
+ type: target[:type],
49
+ local_type: target[:local_type]
50
+ ).tap do |airspace|
51
+ airspace.meta = espace_node.attr('pk')
52
+ airspace.geometry = geometry_from(partie_node.(:Contour))
53
+ fail("geometry is not closed") unless airspace.geometry.closed?
54
+ cache.volume.css(%Q(Volume:has(Partie[pk="#{partie_node['pk']}"]))).each do |volume_node|
55
+ airspace.add_layer(
56
+ layer_from(volume_node).tap do |layer|
57
+ layer.location_indicator = FIR_LOCATION_INDICATORS.fetch(airspace.name) if airspace.type == :flight_information_region
58
+ end
59
+ )
60
+ end
61
+ end
62
+ )
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ end
70
+ end
@@ -0,0 +1,172 @@
1
+ module AIPP
2
+ module LF
3
+
4
+ class Services < AIP
5
+
6
+ include AIPP::LF::Helpers::Base
7
+
8
+ DEPENDS = %w(aerodromes serviced_airspaces)
9
+
10
+ # Service types and how to treat them
11
+ SOURCE_TYPES = {
12
+ 'A/A' => :address,
13
+ 'AFIS' => :service,
14
+ 'APP' => :service,
15
+ 'ATIS' => :service,
16
+ 'CCM' => :ignore, # centre de contrôle militaire
17
+ 'CEV' => :ignore, # centre d’essai en vol
18
+ 'D-ATIS' => :ignore, # data link ATIS
19
+ 'FIS' => :service,
20
+ 'PAR' => :service,
21
+ 'SRE' => :ignore, # surveillance radar element of PAR
22
+ 'TWR' => :service,
23
+ 'UAC' => :ignore, # upper area control
24
+ 'VDF' => :service,
25
+ 'OTHER' => :service # no <Service> specified at source
26
+ }.freeze
27
+
28
+ # Map French callsigns to English and service type
29
+ CALLSIGNS = {
30
+ 'Approche' => { en: 'Approach', service_type: 'APP' },
31
+ 'Contrôle' => { en: 'Control', service_type: 'ACS' },
32
+ 'Information' => { en: 'Information', service_type: 'FIS' },
33
+ 'GCA' => { en: 'GCA', service_type: 'GCA' },
34
+ 'Gonio' => { en: 'Gonio', service_type: 'VDF' },
35
+ 'Prévol' => { en: 'Delivery', service_type: 'SMC' },
36
+ 'Sol' => { en: 'Ground', service_type: 'SMC' },
37
+ 'Tour' => { en: 'Tower', service_type: 'TWR' },
38
+ 'Trafic' => { en: 'Apron', service_type: 'SMC' }
39
+ }.freeze
40
+
41
+ def parse
42
+ cache.service.css(%Q(Service[lk^="[LF]"][pk])).each do |service_node|
43
+ # Ignore private services/frequencies
44
+ next if service_node.(:IndicLieu) == 'XX'
45
+ # Load referenced airport
46
+ airport = given(service_node.at_css('Ad')&.attr('pk')) do
47
+ find_by(:airport, meta: _1).first
48
+ end
49
+ # Build addresses and services
50
+ case SOURCE_TYPES.fetch(type_for(service_node))
51
+ when :address
52
+ fail "dangling address without airport" unless airport
53
+ addresses_from(service_node).each { airport.add_address(_1) }
54
+ when :service
55
+ given service_from(service_node) do |service|
56
+ cache.frequence.css(%Q(Frequence:has(Service[pk="#{service_node['pk']}"]))).each do |frequence_node|
57
+ if frequency = frequency_from(frequence_node, service_node)
58
+ service.add_frequency frequency
59
+ end
60
+ end
61
+ if airport
62
+ airport.add_unit(service.unit) if airport.units.find(service.unit).none?
63
+ airport.add_service(service) if airport.services.find(service).none?
64
+ end
65
+ given service_node.at_css('Espace')&.attr('pk') do |espace_pk|
66
+ find_by(:airspace, meta: espace_pk).each do |airspace|
67
+ airspace.layers.each { _1.add_service(service) }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ # Assign fallback address (default A/A frequency) to all yet radioless airports
74
+ find_by(:airport).each do |airport|
75
+ unless airport.services.find_by(:service, type: :aerodrome_control_tower_service).any? || airport.addresses.any?
76
+ airport.add_address(fallback_address_for(airport.name))
77
+ end
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def type_for(service_node)
84
+ SOURCE_TYPES.include?(type = service_node.(:Service)) ? type : 'OTHER'
85
+ end
86
+
87
+ def addresses_from(service_node)
88
+ cache.frequence.css(%Q(Frequence:has(Service[pk="#{service_node['pk']}"]))).map do |frequence_node|
89
+ if frequency = frequency_from(frequence_node, service_node)
90
+ AIXM.address(
91
+ type: :radio_frequency,
92
+ address: frequency.transmission_f
93
+ ).tap do |address|
94
+ address.remarks = {
95
+ 'type' => service_node.(:Service),
96
+ 'indicatif/callsign' => frequency.callsigns.map { "#{_2} (#{_1})" }.join("/n")
97
+ }.to_remarks
98
+ end
99
+ end
100
+ end.compact
101
+ end
102
+
103
+ def fallback_address_for(callsign)
104
+ AIXM.address(
105
+ type: :radio_frequency,
106
+ address: AIXM.f(123.5, :mhz)
107
+ ).tap do |address|
108
+ address.remarks = {
109
+ 'type' => 'A/A',
110
+ 'indicatif/callsign' => callsign
111
+ }.to_remarks
112
+ end
113
+ end
114
+
115
+ def service_from(service_node)
116
+ service_type = CALLSIGNS.dig(service_node.(:IndicService), :service_type) || type_for(service_node)
117
+ service = find_by(:service, type: service_type).first
118
+ unit = service&.unit
119
+ unless service
120
+ service = AIXM.service(type: service_type)
121
+ unit = find_by(:unit, name: service_node.(:IndicLieu), type: service.guessed_unit_type).first
122
+ unless unit
123
+ unit = AIXM.unit(
124
+ source: source(section: 'GEN', position: service_node.line),
125
+ organisation: organisation_lf,
126
+ name: service_node.(:IndicLieu),
127
+ type: service.guessed_unit_type,
128
+ class: :icao
129
+ )
130
+ add unit
131
+ end
132
+ unit.add_service service
133
+ service
134
+ end
135
+ end
136
+
137
+ def frequency_from(frequence_node, service_node)
138
+ frequency = frequence_node.(:Frequence).to_f
139
+ case
140
+ when frequency >= 137
141
+ nil
142
+ when frequency < 108
143
+ warn("ignoring too low frequency `#{frequency}'", severe: false)
144
+ nil
145
+ else
146
+ AIXM.frequency(
147
+ transmission_f: AIXM.f(frequency, :mhz),
148
+ callsigns: callsigns_from(service_node)
149
+ ).tap do |frequency|
150
+ frequency.timetable = timetable_from(frequence_node.(:HorCode))
151
+ frequency.remarks = frequence_node.(:Remarque)
152
+ end
153
+ end
154
+ end
155
+
156
+ def callsigns_from(service_node)
157
+ if service_node.(:IndicService) == '.' # auto-information
158
+ %i(fr en).to_h { [_1, '(auto)'] }
159
+ else
160
+ warn("language other than french") unless service_node.(:Langue) == 'fr'
161
+ english = CALLSIGNS.fetch(service_node.(:IndicService)).fetch(:en)
162
+ warn("no english translation for callsign `#{service_node.(:IndicService)}'") unless english
163
+ {
164
+ fr: "#{service_node.(:IndicLieu)} #{service_node.(:IndicService)}",
165
+ en: ("#{service_node.(:IndicLieu)} #{english}" if english)
166
+ }.compact
167
+ end
168
+ end
169
+
170
+ end
171
+ end
172
+ end
data/lib/aipp/t_hash.rb CHANGED
@@ -18,8 +18,8 @@ module AIPP
18
18
 
19
19
  alias_method :tsort_each_node, :each_key
20
20
 
21
- def tsort_each_child(node, &block)
22
- fetch(node).each(&block)
21
+ def tsort_each_child(node, &)
22
+ fetch(node).each(&)
23
23
  end
24
24
 
25
25
  def tsort(node=nil)
@@ -35,9 +35,8 @@ module AIPP
35
35
 
36
36
  def subhash_for(node, memo=[])
37
37
  memo.tap do |m|
38
- fail TSort::Cyclic if m.include? node
39
38
  m << node
40
- fetch(node).each { subhash_for(_1, m) }
39
+ (fetch(node) - m).each { subhash_for(_1, m) }
41
40
  end
42
41
  end
43
42
 
data/lib/aipp/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module AIPP
2
- VERSION = "0.2.6".freeze
2
+ VERSION = "1.0.0".freeze
3
3
  end
data/lib/aipp.rb CHANGED
@@ -1,40 +1,42 @@
1
+ require 'debug/session'
1
2
  require 'forwardable'
2
3
  require 'colorize'
3
- require 'pry'
4
- require 'pry-rescue'
5
4
  require 'optparse'
6
5
  require 'yaml'
7
6
  require 'csv'
7
+ require 'roo'
8
8
  require 'pathname'
9
9
  require 'tmpdir'
10
+ require 'net/http'
10
11
  require 'open-uri'
11
12
  require 'securerandom'
12
13
  require 'tsort'
13
14
  require 'ostruct'
14
15
  require 'date'
15
16
  require 'nokogiri'
16
- require 'nokogumbo'
17
17
  require 'pdf-reader'
18
18
  require 'json'
19
19
  require 'zip'
20
+ require 'airac'
20
21
  require 'aixm'
21
22
 
22
23
  require 'active_support'
23
24
  require 'active_support/core_ext/object/blank'
24
25
  require 'active_support/core_ext/string'
25
- require_relative 'core_ext/object'
26
+
26
27
  require_relative 'core_ext/integer'
27
28
  require_relative 'core_ext/string'
28
29
  require_relative 'core_ext/nil_class'
29
30
  require_relative 'core_ext/enumerable'
30
31
  require_relative 'core_ext/hash'
32
+ require_relative 'core_ext/nokogiri'
31
33
 
32
34
  require_relative 'aipp/version'
35
+ require_relative 'aipp/debugger'
33
36
  require_relative 'aipp/pdf'
34
37
  require_relative 'aipp/border'
35
38
  require_relative 'aipp/t_hash'
36
39
  require_relative 'aipp/executable'
37
- require_relative 'aipp/airac'
38
40
  require_relative 'aipp/patcher'
39
41
  require_relative 'aipp/aip'
40
42
  require_relative 'aipp/parser'
@@ -20,9 +20,9 @@ module Enumerable
20
20
  # @yield [Object] element to analyze
21
21
  # @yieldreturn [Boolean] whether to split at this element or not
22
22
  # @return [Array]
23
- def split(*args, &block)
23
+ def split(*args, &)
24
24
  [].tap do |array|
25
- while index = slice((start ||= 0)...length).find_index(*args, &block)
25
+ while index = slice((start ||= 0)...length).find_index(*args, &)
26
26
  array << slice(start...start+index)
27
27
  start += index + 1
28
28
  end
data/lib/core_ext/hash.rb CHANGED
@@ -4,8 +4,8 @@ class Hash
4
4
  #
5
5
  # Similar to +fetch+, search the hash keys for the search string and return
6
6
  # the corresponding value. Unlike +fetch+, however, if a hash key is a Regexp,
7
- # the search string is matched against this Regexp. The hash is searched
8
- # in it's natural order.
7
+ # the search argument is matched against this Regexp. The hash is searched
8
+ # in its natural order.
9
9
  #
10
10
  # @example
11
11
  # h = { /aa/ => :aa, /a/ => :a, 'b' => :b }
@@ -18,14 +18,30 @@ class Hash
18
18
  # @param default [Object] fallback value if no key matched
19
19
  # @return [Object] hash value
20
20
  # @raise [KeyError] no key matched and no default given
21
- def metch(search, default=nil)
21
+ def metch(search, default=:__n_o_n_e__)
22
22
  fetch search
23
23
  rescue KeyError
24
24
  each do |key, value|
25
25
  next unless key.is_a? Regexp
26
- return value if search.match? key
26
+ return value if key.match? search
27
27
  end
28
- default ? default : raise(KeyError, "no match found: #{search.inspect}")
28
+ raise(KeyError, "no match found: #{search.inspect}") if default == :__n_o_n_e__
29
+ default
29
30
  end
30
31
 
32
+ # Compile a titles/texts hash to remarks Markdown string
33
+ #
34
+ # @example
35
+ # { name: 'foobar', ignore: => nil, 'count/quantité' => 3 }.to_remarks
36
+ # # => "NAME\nfoobar\n\nCOUNT/QUANTITÉ\n3"
37
+ # { ignore: nil, ignore_as_well: "" }.to_remarks
38
+ # # => nil
39
+ #
40
+ # @return [String, nil] compiled remarks
41
+ def to_remarks
42
+ map { |k, v| "**#{k.to_s.upcase}**\n#{v}" unless v.blank? }.
43
+ compact.
44
+ join("\n\n").
45
+ blank_to_nil
46
+ end
31
47
  end
@@ -0,0 +1,54 @@
1
+ module Nokogiri
2
+ module XML
3
+ class Element
4
+
5
+ BOOLEANIZE_AS_TRUE_RE = /^(true|yes|oui|ja)$/i.freeze
6
+ BOOLEANIZE_AS_FALSE_RE = /^(false|no|non|nein)$/i.freeze
7
+
8
+ # Traverse all child elements and build a hash mapping the symbolized
9
+ # child node name to the child content.
10
+ #
11
+ # @return [Hash]
12
+ def contents
13
+ @contents ||= elements.to_h { [_1.name.to_sym, _1.content] }
14
+ end
15
+
16
+ # Shortcut to query +contents+ array which accepts both String or
17
+ # Symbol queries as well as query postfixes.
18
+ #
19
+ # @example query optional content for :key
20
+ # element.(:key) # same as element.contents[:key]
21
+ #
22
+ # @example query mandatory content for :key
23
+ # element.(:key!) # fails if the key does not exist
24
+ #
25
+ # @example query boolean content for :key
26
+ # element.(:key?) # returns true or false
27
+ #
28
+ # @see +BOOLEANIZE_AS_TRUE_RE+ and +BOOLEANIZE_AS_FALSE_RE+ define the
29
+ # regular expressions which convert the content to boolean. Furthermore,
30
+ # nil is interpreted as false as well.
31
+ #
32
+ # @raise KeyError mandatory or boolean content not found
33
+ # @return [String, Boolean]
34
+ def call(query)
35
+ case query
36
+ when /\?$/ then booleanize(contents.fetch(query[...-1].to_sym))
37
+ when /\!$/ then contents.fetch(query[...-1].to_sym)
38
+ else contents[query.to_sym]
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def booleanize(content)
45
+ case content
46
+ when nil then false
47
+ when BOOLEANIZE_AS_TRUE_RE then true
48
+ when BOOLEANIZE_AS_FALSE_RE then false
49
+ else fail(KeyError, "`#{content}' not recognized as boolean")
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end