aipp 1.0.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +2 -2
  3. data/CHANGELOG.md +17 -1
  4. data/README.md +269 -150
  5. data/exe/aip2aixm +2 -8
  6. data/exe/aip2ofmx +2 -8
  7. data/exe/notam2aixm +5 -0
  8. data/exe/notam2ofmx +5 -0
  9. data/lib/aipp/aip/README.md +10 -0
  10. data/lib/aipp/aip/executable.rb +40 -0
  11. data/lib/aipp/aip/parser.rb +9 -0
  12. data/lib/aipp/aip/runner.rb +85 -0
  13. data/lib/aipp/border.rb +2 -2
  14. data/lib/aipp/debugger.rb +14 -19
  15. data/lib/aipp/downloader/file.rb +57 -0
  16. data/lib/aipp/downloader/graphql.rb +29 -0
  17. data/lib/aipp/downloader/http.rb +48 -0
  18. data/lib/aipp/downloader.rb +78 -29
  19. data/lib/aipp/environment.rb +88 -0
  20. data/lib/aipp/executable.rb +36 -53
  21. data/lib/aipp/notam/README.md +25 -0
  22. data/lib/aipp/notam/executable.rb +27 -0
  23. data/lib/aipp/notam/parser.rb +9 -0
  24. data/lib/aipp/notam/runner.rb +28 -0
  25. data/lib/aipp/parser.rb +133 -160
  26. data/lib/aipp/patcher.rb +4 -5
  27. data/lib/aipp/regions/LF/README.md +6 -2
  28. data/lib/aipp/regions/LF/aip/aerodromes.rb +220 -0
  29. data/lib/aipp/regions/LF/aip/d_p_r_airspaces.rb +53 -0
  30. data/lib/aipp/regions/LF/aip/dangerous_activities.rb +48 -0
  31. data/lib/aipp/regions/LF/aip/designated_points.rb +44 -0
  32. data/lib/aipp/regions/LF/aip/helipads.rb +119 -0
  33. data/lib/aipp/regions/LF/aip/navigational_aids.rb +82 -0
  34. data/lib/aipp/regions/LF/aip/obstacles.rb +150 -0
  35. data/lib/aipp/regions/LF/aip/serviced_airspaces.rb +67 -0
  36. data/lib/aipp/regions/LF/aip/services.rb +169 -0
  37. data/lib/aipp/regions/LF/fixtures/aerodromes.yml +2 -2
  38. data/lib/aipp/regions/LF/helpers/base.rb +32 -32
  39. data/lib/aipp/regions/LS/README.md +59 -0
  40. data/lib/aipp/regions/LS/helpers/base.rb +111 -0
  41. data/lib/aipp/regions/LS/notam/ENR.rb +173 -0
  42. data/lib/aipp/runner.rb +152 -0
  43. data/lib/aipp/version.rb +1 -1
  44. data/lib/aipp.rb +30 -11
  45. data/lib/core_ext/array.rb +13 -0
  46. data/lib/core_ext/nokogiri.rb +56 -8
  47. data/lib/core_ext/string.rb +63 -1
  48. data.tar.gz.sig +0 -0
  49. metadata +115 -64
  50. metadata.gz.sig +0 -0
  51. data/lib/aipp/aip.rb +0 -166
  52. data/lib/aipp/regions/LF/aerodromes.rb +0 -223
  53. data/lib/aipp/regions/LF/d_p_r_airspaces.rb +0 -56
  54. data/lib/aipp/regions/LF/dangerous_activities.rb +0 -49
  55. data/lib/aipp/regions/LF/designated_points.rb +0 -47
  56. data/lib/aipp/regions/LF/helipads.rb +0 -122
  57. data/lib/aipp/regions/LF/navigational_aids.rb +0 -85
  58. data/lib/aipp/regions/LF/obstacles.rb +0 -153
  59. data/lib/aipp/regions/LF/serviced_airspaces.rb +0 -70
  60. data/lib/aipp/regions/LF/services.rb +0 -172
@@ -0,0 +1,111 @@
1
+ module AIPP
2
+ module NewayAPI
3
+ HttpAdapter = GraphQL::Client::HTTP.new(ENV['NEWAY_API_URL']) do
4
+ def headers(context)
5
+ { "Authorization": "Bearer #{ENV['NEWAY_API_AUTHORIZATION']}" }
6
+ end
7
+ end
8
+ Schema = GraphQL::Client.load_schema(HttpAdapter)
9
+ Client = GraphQL::Client.new(schema: Schema, execute: HttpAdapter)
10
+
11
+ class Notam
12
+ Query = Client.parse <<~END
13
+ query ($region: String!, $series: [String!], $start: Int!, $end: Int!) {
14
+ queryNOTAMs(
15
+ filter: {region: $region, series: $series, start: $start, end: $end}
16
+ ) {
17
+ notamRaw
18
+ }
19
+ }
20
+ END
21
+ end
22
+ end
23
+
24
+ module LS
25
+ module Helpers
26
+ module Base
27
+
28
+ using AIXM::Refinements
29
+
30
+ # Mandatory Interface
31
+
32
+ def setup
33
+ AIPP.cache.aip = read('AIP').css('Ase')
34
+ AIPP.cache.dabs = read('DABS')
35
+ end
36
+
37
+ def origin_for(document)
38
+ case document
39
+ when 'ENR'
40
+ variables = {
41
+ region: 'LS',
42
+ series: %w(W B),
43
+ start: aixm.effective_at.beginning_of_day.to_i,
44
+ end: aixm.expiration_at.to_i
45
+ }
46
+ verbose_info("Querying API with #{variables}")
47
+ AIPP::Downloader::GraphQL.new(
48
+ client: AIPP::NewayAPI::Client,
49
+ query: AIPP::NewayAPI::Notam::Query,
50
+ variables: variables
51
+ )
52
+ when 'AD'
53
+ fail "not yet implemented"
54
+ when 'AIP'
55
+ AIPP::Downloader::HTTP.new(
56
+ archive: "https://snapshots.openflightmaps.org/live/#{AIRAC::Cycle.new.id}/ofmx/lsas/latest/ofmx_ls.zip",
57
+ file: "ofmx_ls/isolated/ofmx_ls.ofmx"
58
+ )
59
+ when 'DABS'
60
+ if aixm.effective_at.to_date == Date.today # DABS cross check works reliably for today only
61
+ AIPP::Downloader::HTTP.new(
62
+ file: "https://www.skybriefing.com/o/dabs?today",
63
+ type: :pdf
64
+ )
65
+ end
66
+ else
67
+ fail "document not recognized"
68
+ end
69
+ end
70
+
71
+ # Templates
72
+
73
+ def organisation_lf
74
+ unless AIPP.cache.organisation_lf
75
+ AIPP.cache.organisation_lf = AIXM.organisation(
76
+ source: source(position: 1, document: "GEN-3.1"),
77
+ name: 'SWITZERLAND',
78
+ type: 'S'
79
+ ).tap do |organisation|
80
+ organisation.id = 'LS'
81
+ end
82
+ add AIPP.cache.organisation_ls
83
+ end
84
+ AIPP.cache.organisation_ls
85
+ end
86
+
87
+ # Parserettes
88
+
89
+ def timetable_from(schedules)
90
+ AIXM.timetable.tap do |timetable|
91
+ schedules&.each do |schedule|
92
+ schedule.actives.each do |actives|
93
+ schedule.times.each do |times|
94
+ timesheet = AIXM.timesheet(
95
+ adjust_to_dst: false,
96
+ dates: (actives.instance_of?(Range) ? actives : (actives..actives))
97
+ # TODO: transform to...
98
+ # dates: actives
99
+ )
100
+ timesheet.times = times
101
+ timetable.add_timesheet timesheet
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,173 @@
1
+ using AIXM::Refinements
2
+
3
+ module AIPP::LS::NOTAM
4
+ class ENR < AIPP::NOTAM::Parser
5
+
6
+ include AIPP::LS::Helpers::Base
7
+
8
+ def parse
9
+ json = read
10
+ fail "malformed JSON received from API" unless json.has_key?('queryNOTAMs')
11
+ added_notam_ids = []
12
+ json['queryNOTAMs'].each do |row|
13
+ next unless row['notamRaw'].match? /^Q\) LS/ # only parse national NOTAM
14
+
15
+ # HACK: try to add missing commas to D-item of A- and B-series NOTAM
16
+ if row['notamRaw'].match? /\A[AB]/
17
+ if row['notamRaw'].gsub!(/(#{NOTAM::Schedule::HOUR_RE.decapture}-#{NOTAM::Schedule::HOUR_RE.decapture})/, '\1,')
18
+ row['notamRaw'].gsub!(/,+/, ',')
19
+ row['notamRaw'].sub!(/,\n/, "\n")
20
+ warn("HACK: added missing commas to D item")
21
+ end
22
+ end
23
+
24
+ (notam = notam_for(row['notamRaw'])) or next
25
+ if respect? notam
26
+ next if notam.data[:five_day_schedules] == []
27
+ added_notam_ids << notam.data[:id]
28
+ add(
29
+ case notam.data[:content]
30
+ when /\A[DR].AREA.+ACT/, /TMA.+ACT/
31
+ if fragment = fragment_for(notam)
32
+ AIXM.generic(fragment: fragment_for(notam)).tap do |airspace|
33
+ element = airspace.fragment.children.first
34
+ element.prepend_child(['<!--', notam.text ,'-->'].join("\n"))
35
+ content = ["NOTAM #{notam.data[:id]}", element.at_css('txtName').content].join(": ").strip
36
+ element.at_css('txtName').content = content
37
+ content = [element.at_css('txtRmk')&.text, notam.data[:translated_content]].join("\n").strip
38
+ element.find_or_add_child('txtRmk').content = content
39
+ if schedule = notam.data[:five_day_schedules]
40
+ timetable = timetable_from(schedule)
41
+ element
42
+ .find_or_add_child('Att', before_css: %w(codeSelAvbl txtRmk))
43
+ .replace(timetable.to_xml(as: :Att).chomp)
44
+ end
45
+ end
46
+ else
47
+ warn "no feature found for `#{notam.data[:content]}' - fallback to point and radius"
48
+ airspace_from(notam).tap do |airspace|
49
+ airspace.geometry = geometry_from_q_item(notam)
50
+ end
51
+ end
52
+ when /\ATEMPO [DR].AREA.+(?:ACT|EST|ESTABLISHED) WI AREA/
53
+ airspace_from(notam).tap do |airspace|
54
+ airspace.geometry = geometry_from_content(notam)
55
+ end
56
+ else
57
+ airspace_from(notam).tap do |airspace|
58
+ airspace.geometry = geometry_from_q_item(notam)
59
+ end
60
+ end
61
+ )
62
+ else
63
+ verbose_info("Skipping NOTAM #{notam.data[:id]}")
64
+ end
65
+ end
66
+ dabs_cross_check(added_notam_ids)
67
+ end
68
+
69
+ private
70
+
71
+ def notam_for(raw_notam)
72
+ notam_id = raw_notam.strip.split(/\s+/, 2).first
73
+ if AIPP.options.crossload
74
+ crossload_file = AIPP.options.crossload.join('LS', "#{notam_id.sub('/', '_')}.txt")
75
+ if File.exist? crossload_file
76
+ info("crossloading #{crossload_file}")
77
+ return NOTAM.parse(crossload_file.read)
78
+ end
79
+ end
80
+ NOTAM.parse(raw_notam)
81
+ rescue
82
+ warn "cannot parse #{notam_id}"
83
+ raise if AIPP.options.force
84
+ end
85
+
86
+ # @return [Boolean] whether to respect this NOTAM or ignore it
87
+ def respect?(notam)
88
+ notam.data[:condition] != :checklist && (
89
+ notam.data[:scope].include?(:navigation_warning) ||
90
+ %i(terminal_control_area).include?(notam.data[:subject]) # TODO: include :obstacle as well
91
+ )
92
+ end
93
+
94
+ def fragment_for(notam)
95
+ case notam.data[:content]
96
+ when /(?<type>TMA) ((SECT )?(?<section>\d+) )?ACT/
97
+ 'Ase:has(codeType:contains("%s") + codeId:contains("%s %s"))' % [$~['type'], notam.data[:locations].first, $~['section']]
98
+ when /[DR].AREA (?<name>LS-[DR]\d+[A-Z]?).+ACT/
99
+ 'Ase:has(codeId:matches("^%s( .+)?$"))' % [$~['name']]
100
+ else
101
+ return
102
+ end.then do |selector|
103
+ AIPP.cache.aip.at_css(selector, Nokogiri::MATCHES)
104
+ end
105
+ end
106
+
107
+ def airspace_from(notam)
108
+ AIXM.airspace(
109
+ id: notam.data[:id],
110
+ type: :regulated_airspace,
111
+ name: "NOTAM #{notam.data[:id]}"
112
+ ).tap do |airspace|
113
+ airspace.add_layer(
114
+ AIXM.layer(
115
+ vertical_limit: AIXM.vertical_limit(
116
+ upper_z: notam.data[:upper_limit],
117
+ lower_z: notam.data[:lower_limit]
118
+ )
119
+ ).tap do |layer|
120
+ layer.selective = true
121
+ if schedule = notam.data[:five_day_schedules]
122
+ layer.timetable = timetable_from(schedule)
123
+ end
124
+ layer.remarks = notam.data[:translated_content]
125
+ end
126
+ )
127
+ airspace.comment = notam.text
128
+ end
129
+ end
130
+
131
+ def geometry_from_content(notam)
132
+ if notam.data[:content].squish.match(/WI AREA(?<coordinates>(?: \d{6}N\d{7}E)+)/)
133
+ AIXM.geometry.tap do |geometry|
134
+ $~['coordinates'].split.each do |coordinate|
135
+ xy = AIXM.xy(lat: coordinate[0, 7], long: coordinate[7, 8])
136
+ geometry.add_segment(AIXM.point(xy: xy))
137
+ end
138
+ end
139
+ else
140
+ warn "cannot parse WI AREA - fallback to point and radius"
141
+ geometry_from_q_item(notam)
142
+ end
143
+ end
144
+
145
+ def geometry_from_q_item(notam)
146
+ AIXM.geometry.tap do |geometry|
147
+ geometry.add_segment AIXM.circle(
148
+ center_xy: notam.data[:center_point],
149
+ radius: notam.data[:radius]
150
+ )
151
+ end
152
+ end
153
+
154
+ def obstacle_from(notam)
155
+ # TODO: implement obstacle
156
+ end
157
+
158
+ def dabs_cross_check(added_notam_ids)
159
+ dabs_date = aixm.effective_at.to_date.strftime("DABS Date: %Y %^b %d")
160
+ case
161
+ when AIPP.cache.dabs.nil?
162
+ warn("DABS not available - skipping cross check")
163
+ when !AIPP.cache.dabs.text.include?(dabs_date)
164
+ warn("DABS date mismatch - skippping cross check")
165
+ else
166
+ dabs_notam_ids = AIPP.cache.dabs.text.scan(NOTAM::Item::ID_RE.decapture).uniq
167
+ missing_notam_ids = dabs_notam_ids - added_notam_ids
168
+ warn("DABS disagrees: #{missing_notam_ids.join(', ')} missing") if missing_notam_ids.any?
169
+ end
170
+ end
171
+
172
+ end
173
+ end
@@ -0,0 +1,152 @@
1
+ module AIPP
2
+
3
+ # @abstract
4
+ class Runner
5
+ include AIPP::Debugger
6
+ using AIXM::Refinements
7
+
8
+ # @return [AIXM::Document] target document
9
+ attr_reader :aixm
10
+
11
+ def initialize
12
+ AIPP.options.storage = AIPP.options.storage.join(AIPP.options.region, AIPP.options.module.downcase)
13
+ AIPP.options.storage.mkpath
14
+ @dependencies = THash.new
15
+ @aixm = AIXM.document(effective_at: effective_at, expiration_at: expiration_at)
16
+ AIXM.send("#{AIPP.options.schema}!")
17
+ AIXM.config.region = AIPP.options.region
18
+ end
19
+
20
+ # @return [String]
21
+ def inspect
22
+ "#<#{self.class}>"
23
+ end
24
+
25
+ # @abstract
26
+ def effective_at
27
+ fail "effective_at method must be implemented by module runner"
28
+ end
29
+
30
+ # @abstract
31
+ def expiration_at
32
+ nil
33
+ end
34
+
35
+ # @abstract
36
+ def run
37
+ fail "run method must be implemented by module runner"
38
+ end
39
+
40
+ # @return [Pathname] directory containing all files for the current region
41
+ def region_dir
42
+ Pathname(__FILE__).dirname.join('regions', AIPP.options.region)
43
+ end
44
+
45
+ # @return [String] sources file name (default: xmlschema representation
46
+ # of effective_at date/time)
47
+ def sources_file
48
+ effective_at.xmlschema
49
+ end
50
+
51
+ def output_file
52
+ "#{AIPP.options.region}_#{AIPP.options.module}_#{effective_at.strftime('%F_%HZ')}.#{AIPP.options.schema}"
53
+ end
54
+
55
+ # @return [Pathname] directory containing the builds
56
+ def builds_dir
57
+ AIPP.options.storage.join('builds')
58
+ end
59
+
60
+ # @return [Pathname] config file for the current region
61
+ def config_file
62
+ AIPP.options.storage.join('config.yml')
63
+ end
64
+
65
+ private
66
+
67
+ # Read the configuration from config.yml.
68
+ def read_config
69
+ info("reading config.yml")
70
+ AIPP.config.read! config_file
71
+ @aixm.namespace = AIPP.config.namespace
72
+ end
73
+
74
+ # Read the region directory.
75
+ def read_region
76
+ info("reading region #{AIPP.options.region}")
77
+ fail("unknown region `#{AIPP.options.region}'") unless region_dir.exist?
78
+ verbose_info "reading fixtures"
79
+ AIPP.fixtures.read! region_dir.join('fixtures')
80
+ verbose_info "reading borders"
81
+ AIPP.borders.read! region_dir.join('borders')
82
+ verbose_info "reading helpers"
83
+ region_dir.glob('helpers/*.rb').each { |f| require f }
84
+ end
85
+
86
+ # Read parser files.
87
+ def read_parsers
88
+ verbose_info("reading parsers")
89
+ region_dir.join(AIPP.options.module.downcase).glob('*.rb').each do |file|
90
+ verbose_info "requiring #{file.basename}"
91
+ require file
92
+ section = file.basename('.*').to_s.classify
93
+ @dependencies[section] = class_for(section).dependencies
94
+ end
95
+ end
96
+
97
+ # Parse sections by invoking the parser classes.
98
+ def parse_sections
99
+ AIPP::Downloader.new(storage: AIPP.options.storage, source: sources_file) do |downloader|
100
+ @dependencies.tsort(AIPP.options.section).each do |section|
101
+ info("parsing #{section.sectionize}")
102
+ class_for(section).new(
103
+ downloader: downloader,
104
+ aixm: aixm
105
+ ).attach_patches.tap(&:parse).detach_patches
106
+ end
107
+ end
108
+ end
109
+
110
+ # Validate the AIXM document.
111
+ #
112
+ # @raise [RuntimeError] if the document is not valid
113
+ def validate_aixm
114
+ info("detecting duplicates")
115
+ if (duplicates = aixm.features.duplicates).any?
116
+ message = "duplicates found"
117
+ details = duplicates.map { "#{_1.inspect} from #{_1.source}" }.join("\n")
118
+ AIPP.options.force ? warn(message) : fail([message, details].join(":\n"))
119
+ end
120
+ info("validating #{AIPP.options.schema.upcase}")
121
+ unless aixm.valid?
122
+ message = "invalid #{AIPP.options.schema.upcase} document"
123
+ details = aixm.errors.map(&:message).join("\n")
124
+ AIPP.options.force ? warn(message) : fail([message, details].join(":\n"))
125
+ end
126
+ info("counting #{aixm.features.count} features")
127
+ end
128
+
129
+ # Write the AIXM document.
130
+ def write_aixm(file)
131
+ info("writing #{file}")
132
+ AIXM.config.mid = AIPP.options.mid
133
+ File.write(file, aixm.to_xml)
134
+ end
135
+
136
+ # Write build information.
137
+ def write_build
138
+ info ("skipping build")
139
+ end
140
+
141
+ # Write the configuration to config.yml.
142
+ def write_config
143
+ info("writing config.yml")
144
+ AIPP.config.write! config_file
145
+ end
146
+
147
+ def class_for(section)
148
+ [:AIPP, AIPP.options.region, AIPP.options.module.upcase, section.classify].constantize
149
+ end
150
+ end
151
+
152
+ end
data/lib/aipp/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module AIPP
2
- VERSION = "1.0.0".freeze
2
+ VERSION = "2.0.0".freeze
3
3
  end
data/lib/aipp.rb CHANGED
@@ -1,46 +1,65 @@
1
1
  require 'debug/session'
2
- require 'forwardable'
2
+ require 'singleton'
3
3
  require 'colorize'
4
4
  require 'optparse'
5
5
  require 'yaml'
6
- require 'csv'
7
- require 'roo'
8
6
  require 'pathname'
7
+ require 'fileutils'
9
8
  require 'tmpdir'
10
- require 'net/http'
11
- require 'open-uri'
12
9
  require 'securerandom'
13
10
  require 'tsort'
14
11
  require 'ostruct'
15
12
  require 'date'
13
+ require 'excon'
14
+ require 'graphql/client'
15
+ require 'graphql/client/http'
16
16
  require 'nokogiri'
17
+ require 'csv'
18
+ require 'roo'
17
19
  require 'pdf-reader'
18
20
  require 'json'
19
21
  require 'zip'
20
22
  require 'airac'
21
23
  require 'aixm'
24
+ require 'notam'
22
25
 
23
26
  require 'active_support'
24
27
  require 'active_support/core_ext/object/blank'
25
28
  require 'active_support/core_ext/string'
29
+ require 'active_support/core_ext/date_time'
26
30
 
31
+ require_relative 'core_ext/nil_class'
27
32
  require_relative 'core_ext/integer'
28
33
  require_relative 'core_ext/string'
29
- require_relative 'core_ext/nil_class'
34
+ require_relative 'core_ext/array'
30
35
  require_relative 'core_ext/enumerable'
31
36
  require_relative 'core_ext/hash'
32
37
  require_relative 'core_ext/nokogiri'
33
38
 
34
39
  require_relative 'aipp/version'
35
40
  require_relative 'aipp/debugger'
36
- require_relative 'aipp/pdf'
41
+ require_relative 'aipp/downloader'
42
+ require_relative 'aipp/downloader/file'
43
+ require_relative 'aipp/downloader/http'
44
+ require_relative 'aipp/downloader/graphql'
45
+ require_relative 'aipp/patcher'
46
+ require_relative 'aipp/parser'
47
+
48
+ require_relative 'aipp/environment'
37
49
  require_relative 'aipp/border'
50
+ require_relative 'aipp/pdf'
38
51
  require_relative 'aipp/t_hash'
52
+
39
53
  require_relative 'aipp/executable'
40
- require_relative 'aipp/patcher'
41
- require_relative 'aipp/aip'
42
- require_relative 'aipp/parser'
43
- require_relative 'aipp/downloader'
54
+ require_relative 'aipp/runner'
55
+
56
+ require_relative 'aipp/aip/executable'
57
+ require_relative 'aipp/aip/runner'
58
+ require_relative 'aipp/aip/parser'
59
+
60
+ require_relative 'aipp/notam/executable'
61
+ require_relative 'aipp/notam/runner'
62
+ require_relative 'aipp/notam/parser'
44
63
 
45
64
  # Disable "did you mean?" suggestions
46
65
  #
@@ -0,0 +1,13 @@
1
+ class Array
2
+
3
+ # Convert array of namespaces to constant.
4
+ #
5
+ # @example
6
+ # %i(AIPP AIP Base).constantize # => AIPP::AIP::Base
7
+ #
8
+ # @return [Class, Module] converted array
9
+ def constantize
10
+ map(&:to_s).join('::').constantize
11
+ end
12
+
13
+ end
@@ -1,18 +1,26 @@
1
1
  module Nokogiri
2
+
3
+ module PseudoClasses
4
+ class Matches
5
+ def matches(node_set, regexp)
6
+ node_set.find_all { _1.content.match?(/#{regexp}/) }
7
+ end
8
+ end
9
+ end
10
+
11
+ # Pseudo class which matches the content of each node set member against the
12
+ # given regular expression.
13
+ #
14
+ # @example
15
+ # node.css('title:matches("\w+")', Nokogiri::MATCHES)
16
+ MATCHES = PseudoClasses::Matches.new
17
+
2
18
  module XML
3
19
  class Element
4
20
 
5
21
  BOOLEANIZE_AS_TRUE_RE = /^(true|yes|oui|ja)$/i.freeze
6
22
  BOOLEANIZE_AS_FALSE_RE = /^(false|no|non|nein)$/i.freeze
7
23
 
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
24
  # Shortcut to query +contents+ array which accepts both String or
17
25
  # Symbol queries as well as query postfixes.
18
26
  #
@@ -39,6 +47,46 @@ module Nokogiri
39
47
  end
40
48
  end
41
49
 
50
+ # Traverse all child elements and build a hash mapping the symbolized
51
+ # child node name to the child content.
52
+ #
53
+ # @return [Hash]
54
+ def contents
55
+ @contents ||= elements.to_h { [_1.name.to_sym, _1.content] }
56
+ end
57
+
58
+ # Find this child element or add a new such element if none is found.
59
+ #
60
+ # The position to add is determined as follows:
61
+ #
62
+ # * If +after_css+ is given, its rules are applied in reverse order and
63
+ # the last matching rule defines the predecessor of the added child.
64
+ # * If only +before_css+ is given, its rules are applied in order and
65
+ # the first matching rule defines the successor of the added child.
66
+ # * If none of the above are given, the child is added at the end.
67
+ #
68
+ # @param name [Array<String>] name of the child element
69
+ # @param after_css [Array<String>] array of CSS rules
70
+ # @param before_css [Array<String>] array of CSS rules
71
+ # @return [Nokogiri::XML::Element, nil] element or +nil+ if none found
72
+ # and no position to add a new one could be determined
73
+ def find_or_add_child(name, after_css: nil, before_css: nil)
74
+ at_css(name) or begin
75
+ case
76
+ when after_css
77
+ at_css(*after_css.reverse).then do |predecessor|
78
+ predecessor&.add_next_sibling("<#{name}/>")
79
+ end&.first
80
+ when before_css
81
+ at_css(*before_css).then do |successor|
82
+ successor&.add_previous_sibling("<#{name}/>")
83
+ end&.first
84
+ else
85
+ add_child("<#{name}/>").first
86
+ end
87
+ end
88
+ end
89
+
42
90
  private
43
91
 
44
92
  def booleanize(content)
@@ -1,6 +1,53 @@
1
1
  class String
2
+ remove_method :classify
2
3
 
3
- # Convert blank strings to +nil+.
4
+ # Convert (underscored) file name to (camelcased) class name
5
+ #
6
+ # Similar to +classify+ from ActiveSupport, however, with a few differences:
7
+ #
8
+ # * Namespaces are ignored.
9
+ # * Plural strings are not singularized.
10
+ # * Characters other than A-Z, a-z, 0-9 and _ are removed.
11
+ #
12
+ # Use +sectionize+ to reverse this method.
13
+ #
14
+ # @example
15
+ # "navigational_aids".classify # => "NavigationalAids"
16
+ # "ENR".classify # => "ENR"
17
+ # "ENR-4.1".classify # => "ENR41"
18
+ # "AIPP/LF/AIP/ENR-4.1".classify # => "ENR41"
19
+ #
20
+ # @return [String] converted string
21
+ def classify
22
+ split('/').last.remove(/\W/).camelcase
23
+ end
24
+
25
+ # Convert (camelcased) class name to (underscored) file name
26
+ #
27
+ # Similar to +underscore+ from ActiveSupport, however, with a few differences:
28
+ #
29
+ # * Namespaces are ignored.
30
+ # * AIP naming conventions are honored.
31
+ #
32
+ # Use +classify+ to reverse this method.
33
+ #
34
+ # @example
35
+ # "NavigationalAids".sectionize # => "navigational_aids"
36
+ # "ENR".sectionize # => "ENR"
37
+ # "ENR41".sectionize # => "ENR-4.1"
38
+ # "AIPP::LF::AIP::ENR41".sectionize # => "ENR-4.1"
39
+ #
40
+ # @return [String] converted string
41
+ def sectionize
42
+ case klass = self.split('::').last
43
+ when /\A([A-Z]{2,3})\z/ then $1
44
+ when /\A([A-Z]{2,3})(\d)\z/ then "#{$1}-#{$2}"
45
+ when /\A([A-Z]{2,3})(\d)(\d+)\z/ then "#{$1}-#{$2}.#{$3}"
46
+ else klass.underscore
47
+ end
48
+ end
49
+
50
+ # Convert blank strings to +nil+
4
51
  #
5
52
  # @example
6
53
  # "foobar".blank_to_nil # => "foobar"
@@ -77,10 +124,25 @@ class String
77
124
  end
78
125
 
79
126
  # Remove all XML/HTML tags and entities from the string
127
+ #
128
+ # @example
129
+ # "this <em>is</em> a <br> test".strip_markup # => "this is a test"
130
+ #
131
+ # @return [String]
80
132
  def strip_markup
81
133
  self.gsub(/<.*?>|&[#\da-z]+;/i, '')
82
134
  end
83
135
 
136
+ # Builds the MD5 hash as hex and returns the first eight characters.
137
+ #
138
+ # @example
139
+ # "this is a test".to_digest # => "54b0c58c"
140
+ #
141
+ # @return [String]
142
+ def to_digest
143
+ Digest::MD5.hexdigest(self)[0,8]
144
+ end
145
+
84
146
  # Same as +to_f+ but accept both dot and comma as decimal separator
85
147
  #
86
148
  # @example
data.tar.gz.sig CHANGED
Binary file