aipp 0.2.4 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +2 -0
  3. data/CHANGELOG.md +38 -0
  4. data/README.md +222 -88
  5. data/exe/aip2aixm +2 -2
  6. data/exe/aip2ofmx +2 -2
  7. data/lib/aipp/aip.rb +113 -31
  8. data/lib/aipp/border.rb +77 -46
  9. data/lib/aipp/debugger.rb +101 -0
  10. data/lib/aipp/downloader.rb +39 -26
  11. data/lib/aipp/executable.rb +41 -22
  12. data/lib/aipp/parser.rb +94 -21
  13. data/lib/aipp/patcher.rb +5 -2
  14. data/lib/aipp/pdf.rb +1 -1
  15. data/lib/aipp/regions/LF/README.md +49 -0
  16. data/lib/aipp/regions/LF/aerodromes.rb +223 -0
  17. data/lib/aipp/regions/LF/d_p_r_airspaces.rb +56 -0
  18. data/lib/aipp/regions/LF/dangerous_activities.rb +49 -0
  19. data/lib/aipp/regions/LF/designated_points.rb +47 -0
  20. data/lib/aipp/regions/LF/fixtures/aerodromes.yml +608 -0
  21. data/lib/aipp/regions/LF/helipads.rb +122 -0
  22. data/lib/aipp/regions/LF/helpers/base.rb +218 -0
  23. data/lib/aipp/regions/LF/helpers/surface.rb +49 -0
  24. data/lib/aipp/regions/LF/helpers/usage_limitation.rb +20 -0
  25. data/lib/aipp/regions/LF/navigational_aids.rb +85 -0
  26. data/lib/aipp/regions/LF/obstacles.rb +153 -0
  27. data/lib/aipp/regions/LF/serviced_airspaces.rb +70 -0
  28. data/lib/aipp/regions/LF/services.rb +172 -0
  29. data/lib/aipp/t_hash.rb +4 -5
  30. data/lib/aipp/version.rb +1 -1
  31. data/lib/aipp.rb +11 -5
  32. data/lib/core_ext/enumerable.rb +9 -9
  33. data/lib/core_ext/hash.rb +21 -5
  34. data/lib/core_ext/nokogiri.rb +54 -0
  35. data/lib/core_ext/string.rb +38 -66
  36. data.tar.gz.sig +2 -0
  37. metadata +180 -188
  38. metadata.gz.sig +0 -0
  39. data/.gitignore +0 -8
  40. data/.ruby-version +0 -1
  41. data/.travis.yml +0 -8
  42. data/.yardopts +0 -3
  43. data/Guardfile +0 -7
  44. data/TODO.md +0 -6
  45. data/aipp.gemspec +0 -44
  46. data/gems.rb +0 -3
  47. data/lib/aipp/airac.rb +0 -55
  48. data/lib/aipp/regions/LF/AD-1.3.rb +0 -162
  49. data/lib/aipp/regions/LF/AD-1.6.rb +0 -31
  50. data/lib/aipp/regions/LF/AD-2.rb +0 -313
  51. data/lib/aipp/regions/LF/AD-3.1.rb +0 -185
  52. data/lib/aipp/regions/LF/ENR-2.1.rb +0 -92
  53. data/lib/aipp/regions/LF/ENR-4.1.rb +0 -97
  54. data/lib/aipp/regions/LF/ENR-4.3.rb +0 -28
  55. data/lib/aipp/regions/LF/ENR-5.1.rb +0 -75
  56. data/lib/aipp/regions/LF/ENR-5.5.rb +0 -53
  57. data/lib/aipp/regions/LF/fixtures/AD-1.3.yml +0 -511
  58. data/lib/aipp/regions/LF/fixtures/AD-2.yml +0 -185
  59. data/lib/aipp/regions/LF/fixtures/AD-3.1.yml +0 -10
  60. data/lib/aipp/regions/LF/helpers/AD_radio.rb +0 -90
  61. data/lib/aipp/regions/LF/helpers/URL.rb +0 -26
  62. data/lib/aipp/regions/LF/helpers/common.rb +0 -217
  63. data/lib/core_ext/object.rb +0 -43
  64. data/rakefile.rb +0 -12
  65. data/spec/fixtures/archive.zip +0 -0
  66. data/spec/fixtures/border.geojson +0 -201
  67. data/spec/fixtures/document.pdf +0 -0
  68. data/spec/fixtures/document.pdf.json +0 -1
  69. data/spec/fixtures/new.html +0 -6
  70. data/spec/fixtures/new.pdf +0 -0
  71. data/spec/fixtures/new.txt +0 -1
  72. data/spec/lib/aipp/airac_spec.rb +0 -98
  73. data/spec/lib/aipp/border_spec.rb +0 -135
  74. data/spec/lib/aipp/downloader_spec.rb +0 -81
  75. data/spec/lib/aipp/patcher_spec.rb +0 -46
  76. data/spec/lib/aipp/pdf_spec.rb +0 -124
  77. data/spec/lib/aipp/t_hash_spec.rb +0 -44
  78. data/spec/lib/aipp/version_spec.rb +0 -7
  79. data/spec/lib/core_ext/enumberable_spec.rb +0 -76
  80. data/spec/lib/core_ext/hash_spec.rb +0 -27
  81. data/spec/lib/core_ext/integer_spec.rb +0 -15
  82. data/spec/lib/core_ext/nil_class_spec.rb +0 -11
  83. data/spec/lib/core_ext/string_spec.rb +0 -112
  84. data/spec/sounds/failure.mp3 +0 -0
  85. data/spec/sounds/success.mp3 +0 -0
  86. data/spec/spec_helper.rb +0 -28
@@ -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,14 +18,14 @@ 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)
26
26
  if node
27
27
  subhash = subhash_for node
28
- super().select { |n| subhash.include? n }
28
+ super().select { subhash.include? _1 }
29
29
  else
30
30
  super()
31
31
  end
@@ -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 { |n| subhash_for(n, 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.4".freeze
2
+ VERSION = "1.0.0".freeze
3
3
  end
data/lib/aipp.rb CHANGED
@@ -1,44 +1,50 @@
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'
6
+ require 'csv'
7
+ require 'roo'
7
8
  require 'pathname'
9
+ require 'tmpdir'
10
+ require 'net/http'
8
11
  require 'open-uri'
9
12
  require 'securerandom'
10
13
  require 'tsort'
11
14
  require 'ostruct'
12
15
  require 'date'
13
16
  require 'nokogiri'
14
- require 'nokogumbo'
15
17
  require 'pdf-reader'
16
18
  require 'json'
17
19
  require 'zip'
20
+ require 'airac'
18
21
  require 'aixm'
19
22
 
20
23
  require 'active_support'
21
24
  require 'active_support/core_ext/object/blank'
22
25
  require 'active_support/core_ext/string'
23
- require_relative 'core_ext/object'
26
+
24
27
  require_relative 'core_ext/integer'
25
28
  require_relative 'core_ext/string'
26
29
  require_relative 'core_ext/nil_class'
27
30
  require_relative 'core_ext/enumerable'
28
31
  require_relative 'core_ext/hash'
32
+ require_relative 'core_ext/nokogiri'
29
33
 
30
34
  require_relative 'aipp/version'
35
+ require_relative 'aipp/debugger'
31
36
  require_relative 'aipp/pdf'
32
37
  require_relative 'aipp/border'
33
38
  require_relative 'aipp/t_hash'
34
39
  require_relative 'aipp/executable'
35
- require_relative 'aipp/airac'
36
40
  require_relative 'aipp/patcher'
37
41
  require_relative 'aipp/aip'
38
42
  require_relative 'aipp/parser'
39
43
  require_relative 'aipp/downloader'
40
44
 
41
45
  # Disable "did you mean?" suggestions
46
+ #
47
+ # @!visibility private
42
48
  module DidYouMean::Correctable
43
49
  remove_method :to_s
44
50
  end
@@ -5,11 +5,11 @@ module Enumerable
5
5
  # returning an array of these sub-enumerables.
6
6
  #
7
7
  # @example
8
- # [1, 2, 0, 3, 4].split { |e| e == 0 } # => [[1, 2], [3, 4]]
9
- # [1, 2, 0, 3, 4].split(0) # => [[1, 2], [3, 4]]
10
- # [0, 0, 1, 0, 2].split(0) # => [[], [] [1], [2]]
11
- # [1, 0, 0, 2, 3].split(0) # => [[1], [], [2], [3]]
12
- # [1, 0, 2, 0, 0].split(0) # => [[1], [2]]
8
+ # [1, 2, 0, 3, 4].split { _1 == 0 } # => [[1, 2], [3, 4]]
9
+ # [1, 2, 0, 3, 4].split(0) # => [[1, 2], [3, 4]]
10
+ # [0, 0, 1, 0, 2].split(0) # => [[], [] [1], [2]]
11
+ # [1, 0, 0, 2, 3].split(0) # => [[1], [], [2], [3]]
12
+ # [1, 0, 2, 0, 0].split(0) # => [[1], [2]]
13
13
  #
14
14
  # @note While similar to +Array#split+ from ActiveSupport, this core
15
15
  # extension works for all enumerables and therefore works fine with.
@@ -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
@@ -35,7 +35,7 @@ module Enumerable
35
35
  # an array of subsequent elements which don't match the chunk condition.
36
36
  #
37
37
  # @example
38
- # [1, 10, 11, 12, 2, 20, 21, 3, 30, 31, 32].group_by_chunks { |i| i < 10 }
38
+ # [1, 10, 11, 12, 2, 20, 21, 3, 30, 31, 32].group_by_chunks { _1 < 10 }
39
39
  # # => { 1 => [10, 11, 12], 2 => [20, 21], 3 => [30, 31, 32] }
40
40
  #
41
41
  # @note The first element must match the chunk condition.
@@ -46,7 +46,7 @@ module Enumerable
46
46
  # @return [Hash]
47
47
  def group_by_chunks
48
48
  fail(ArgumentError, "first element must match chunk condition") unless yield(first)
49
- slice_when { |_, e| yield(e) }.map { |e| [e.first, e[1..]] }.to_h
49
+ slice_when { yield(_2) }.map { [_1.first, _1[1..]] }.to_h
50
50
  end
51
51
 
52
52
  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
@@ -23,7 +23,7 @@ class String
23
23
  gsub(/[#{AIXM::MIN}]{2}|[#{AIXM::SEC}]/, '"'). # unify quotes
24
24
  gsub(/[#{AIXM::MIN}]/, "'"). # unify apostrophes
25
25
  gsub(/"[[:blank:]]*(.*?)[[:blank:]]*"/m, '"\1"'). # remove whitespace within quotes
26
- split(/\r?\n/).map { |s| s.strip.blank_to_nil }.compact.join("\n") # remove blank lines
26
+ split(/\r?\n/).map { _1.strip.blank_to_nil }.compact.join("\n") # remove blank lines
27
27
  end
28
28
 
29
29
  # Strip and collapse unnecessary whitespace
@@ -32,64 +32,53 @@ class String
32
32
  # are preserved and not collapsed into one space.
33
33
  #
34
34
  # @example
35
- # " foo\n\nbar \r".copact # => "foo\nbar"
35
+ # " foo\n\nbar \r".compact # => "foo\nbar"
36
36
  #
37
37
  # @return [String] compacted string
38
38
  def compact
39
- split("\n").map { |s| s.squish.blank_to_nil }.compact.join("\n")
39
+ split("\n").map { _1.squish.blank_to_nil }.compact.join("\n")
40
40
  end
41
41
 
42
- # Calculate the correlation of two strings by counting mutual words
43
- #
44
- # Both strings are normalized as follows:
45
- # * remove accents, umlauts etc
46
- # * remove everything but members of the +\w+ class
47
- # * downcase
48
- #
49
- # The normalized strings are split into words. Only words fulfilling either
50
- # of the following conditions are taken into consideration:
51
- # * words present in and translated by the +synonyms+ map
52
- # * words of at least 5 characters length
53
- # * words consisting of exactly one letter followed by any number of digits
54
- # (an optional whitespace between the two is ignored, e.g. "D 25" is the
55
- # same as "D25")
56
- #
57
- # The +synonyms+ map is an array where terms in even positions map to their
58
- # synonym in the following (odd) position:
59
- #
60
- # SYNONYMS = ['term1', 'synonym1', 'term2', 'synonym2']
42
+ # Similar to +strip+, but remove any leading or trailing non-letters/numbers
43
+ # which includes whitespace
44
+ def full_strip
45
+ remove(/\A[^\p{L}\p{N}]*|[^\p{L}\p{N}]*\z/)
46
+ end
47
+
48
+ # Similar to +scan+, but remove matches from the string
49
+ def extract(pattern)
50
+ scan(pattern).tap { remove! pattern }
51
+ end
52
+
53
+ # Apply the patterns in the given order and return...
54
+ # * first capture group - if a pattern matches and contains a capture group
55
+ # * entire match - if a pattern matches and contains no capture group
56
+ # * +default+ - if no pattern matches and a +default+ is set
57
+ # * +nil+ - if no pattern matches and no +default+ is set
61
58
  #
62
59
  # @example
63
- # subject = "Truck en route on N 3 sud"
64
- # subject.correlate("my car is on D25") # => 0
65
- # subject.correlate("my truck is on D25") # => 1
66
- # subject.correlate("my truck is on N3") # => 2
67
- # subject.correlate("south", ['sud', 'south']) # => 1
68
- #
69
- # @param other [String] string to compare with
70
- # @param synonyms [Array<String>] array of synonym pairs
71
- # @return [Integer] 0 for unrelated strings and positive integers for related
72
- # strings with higher numbers indicating tighter correlation
73
- def correlate(other, synonyms=[])
74
- self_words, other_words = [self, other].map do |string|
75
- string.
76
- unicode_normalize(:nfd).
77
- downcase.gsub(/[-\u2013]/, ' ').
78
- remove(/[^\w\s]/).
79
- gsub(/\b(\w)\s?(\d+)\b/, '\1\2').
80
- compact.
81
- split(/\W+/).
82
- map { |w| (i = synonyms.index(w)).nil? ? w : (i.odd? ? w : synonyms[i + 1]).upcase }.
83
- keep_if { |w| w.match?(/\w{5,}|\w\d+|[[:upper:]]/) }.
84
- uniq
60
+ # "A/A: 123.5 mhz".first_match(/123\.5/) # => "123.5"
61
+ # "A/A: 123.5 mhz".first_match(/:\s+([\d.]+)/) # => "123.5"
62
+ # "A/A: 123.5 mhz".first_match(/121\.5/) # nil
63
+ # "A/A: 123.5 mhz".first_match(/(121\.5)/) # nil
64
+ # "A/A: 123.5 mhz".first_match(/121\.5/, default: "123") # "123"
65
+ #
66
+ # @param patterns [Array<Regexp>] one or more patterns to apply in order
67
+ # @param default [String] string to return instead of +nil+ if the pattern
68
+ # doesn't match
69
+ # @return [String, nil]
70
+ def first_match(*patterns, default: nil)
71
+ patterns.each do |pattern|
72
+ if captures = match(pattern)
73
+ return captures[1] || captures[0]
74
+ end
85
75
  end
86
- (self_words & other_words).count
76
+ default
87
77
  end
88
78
 
89
- # Similar to +strip+, but remove any leading or trailing non-letters/numbers
90
- # which includes whitespace
91
- def full_strip
92
- remove(/\A[^\p{L}\p{N}]*|[^\p{L}\p{N}]*\z/)
79
+ # Remove all XML/HTML tags and entities from the string
80
+ def strip_markup
81
+ self.gsub(/<.*?>|&[#\da-z]+;/i, '')
93
82
  end
94
83
 
95
84
  # Same as +to_f+ but accept both dot and comma as decimal separator
@@ -104,21 +93,4 @@ class String
104
93
  sub(/,/, '.').to_f
105
94
  end
106
95
 
107
- # Add spaces between obviously glued words:
108
- # * camel glued words
109
- # * three-or-more-letter and number-only words
110
- #
111
- # @example
112
- # "thisString has spaceProblems".unglue # => "this String has space problems"
113
- # "the first123meters of D25".unglue # => "the first 123 meters of D25"
114
- #
115
- # @return [String] unglued string
116
- def unglue
117
- self.dup.tap do |string|
118
- [/([[:lower:]])([[:upper:]])/, /([[:alpha:]]{3,})(\d)/, /(\d)([[:alpha:]]{3,})/].freeze.each do |regexp|
119
- string.gsub!(regexp, '\1 \2')
120
- end
121
- end
122
- end
123
-
124
96
  end
data.tar.gz.sig ADDED
@@ -0,0 +1,2 @@
1
+  h�ƅ:�/n�!�Ud�����.u��<-���"譈!�����Z<��V�����Z�{ � ����c��M$���J�Z . �n��C�5[��f��XR̘�H@y9d�!�
2
+ \�ۃ�{W!`��m���nC��uk2���qOܱsAy��l;��U�?y�!�4�=;v��4�[���]P�j]s*ݷ!�g�:�\ӬD��8�,�*,^��zA�4��,1�:�x�)��š�!z�t���?W$��1�k