aipp 1.0.0 → 2.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 (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