aipp 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ module AIPP
2
+
3
+ # Executable instantiated by the console tools
4
+ class Executable
5
+ attr_reader :options
6
+
7
+ def initialize(**options)
8
+ @options = options
9
+ @options[:airac] = AIPP::AIRAC.new
10
+ @options[:storage] = Pathname(Dir.home).join('.aipp')
11
+ @options[:verbose] = @options[:force] = @options[:pry_on_error] = false
12
+ OptionParser.new do |o|
13
+ o.banner = <<~END
14
+ Download online AIP and convert it to #{options[:schema].upcase}.
15
+ Usage: #{File.basename($0)} [options]
16
+ END
17
+ o.on('-d', '--airac DATE', String, %Q[AIRAC date (default: "#{@options[:airac].date.xmlschema}")]) { |v| @options[:airac] = AIPP::AIRAC.new(v) }
18
+ o.on('-r', '--region STRING', String, 'region (e.g. "LF")') { |v| @options[:region] = v.upcase }
19
+ o.on('-a', '--aip STRING', String, 'process this AIP only (e.g. "ENR-5.1")') { |v| @options[:aip] = v.upcase }
20
+ o.on('-s', '--storage DIR', String, 'storage directory (default: "~/.aipp")') { |v| @options[:storage] = Pathname(v) }
21
+ o.on('-v', '--[no-]verbose', 'verbose process output (default: false)') { |v| @options[:verbose] = v }
22
+ o.on('-f', '--[no-]force', 'ignore XML schema validation (default: false)') { |v| @options[:force] = v }
23
+ o.on('-W', '--pry-on-warn ID', Integer, 'open pry on warn with ID (default: nil)') { |v| @options[:pry_on_warn] = v }
24
+ o.on('-E', '--[no-]pry-on-error', 'open pry on error (default: false)') { |v| @options[:pry_on_error] = v }
25
+ o.on('-A', '--about', 'show author/license information and exit') { about }
26
+ o.on('-R', '--readme', 'show README and exit') { readme }
27
+ o.on('-L', '--list', 'list implemented regions and AIPs') { list }
28
+ o.on('-V', '--version', 'show version and exit') { version }
29
+ end.parse!
30
+ end
31
+
32
+ # Load necessary files and execute the parser.
33
+ #
34
+ # @raise [RuntimeError] if the region does not exist
35
+ def run
36
+ Pry::rescue do
37
+ fail(OptionParser::MissingArgument, :region) unless options[:region]
38
+ AIPP::Parser.new(options: options).tap do |parser|
39
+ parser.read_config
40
+ parser.read_region
41
+ parser.parse_aip
42
+ parser.validate_aixm
43
+ parser.write_aixm
44
+ parser.write_config
45
+ end
46
+ rescue => error
47
+ puts "ERROR: #{error.message}"
48
+ Pry::rescued(error) if options[:pry_on_error]
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def about
55
+ puts 'Written by Sven Schwyn (bitcetera.com) and distributed under MIT license.'
56
+ exit
57
+ end
58
+
59
+ def readme
60
+ readme_path = Pathname($0).dirname.join('..', 'gems', "aipp-#{AIPP::VERSION}", 'README.md')
61
+ puts IO.read(readme_path)
62
+ exit
63
+ end
64
+
65
+ def list
66
+ regions_path = Pathname($0).dirname.join('..', 'gems', "aipp-#{AIPP::VERSION}", 'lib', 'aipp', 'regions')
67
+ hash = Dir.each_child(regions_path).each.with_object({}) do |region, hash|
68
+ hash[region] = Dir.children(regions_path.join(region)).sort.map do |aip|
69
+ File.basename(aip, '.rb') unless aip == 'helper.rb'
70
+ end.compact
71
+ end
72
+ puts hash.to_yaml.sub(/\A\W*/, '')
73
+ exit
74
+ end
75
+
76
+ def version
77
+ puts AIPP::VERSION
78
+ exit
79
+ end
80
+ end
81
+
82
+ end
@@ -0,0 +1,97 @@
1
+ module AIPP
2
+
3
+ # AIP parser infrastructure
4
+ class Parser
5
+ using AIPP::Refinements
6
+ include AIPP::Progress
7
+
8
+ # @return [Hash] passed command line arguments
9
+ attr_reader :options
10
+
11
+ # @return [Hash] configuration read from config.yml
12
+ attr_reader :config
13
+
14
+ # @return [AIXM::Document] target document
15
+ attr_reader :aixm
16
+
17
+ # @return [OpenStruct] object cache
18
+ attr_reader :cache
19
+
20
+ def initialize(options:)
21
+ @options = options
22
+ @options[:storage] = options[:storage].join(options[:region])
23
+ @options[:storage].mkpath
24
+ @config = {}
25
+ @aixm = AIXM.document(region: @options[:region], effective_at: @options[:airac].date)
26
+ @cache = OpenStruct.new
27
+ @dependencies = THash.new
28
+ end
29
+
30
+ # Read the configuration from config.yml.
31
+ def read_config
32
+ info("Reading config.yml", force: true)
33
+ @config = YAML.load_file(config_file, fallback: {}).transform_keys(&:to_sym) if config_file.exist?
34
+ @config[:namespace] ||= SecureRandom.uuid
35
+ @aixm.namespace = @config[:namespace]
36
+ end
37
+
38
+ # Read the region directory and build the dependency list.
39
+ def read_region
40
+ info("Reading region #{options[:region]}", force: true)
41
+ dir = Pathname(__FILE__).dirname.join('regions', options[:region])
42
+ fail("unknown region `#{options[:region]}'") unless dir.exist?
43
+ dir.glob('*.rb').each do |file|
44
+ info("Requiring #{file.basename}")
45
+ require file
46
+ if (aip = file.basename('.*').to_s) == 'helper'
47
+ extend [:AIPP, options[:region], :Helper].constantize
48
+ else
49
+ @dependencies[aip] = [:AIPP, options[:region], aip.classify, :DEPENDS].constantize
50
+ end
51
+ end
52
+ end
53
+
54
+ # Parse AIP by invoking the parser classes for the current region.
55
+ def parse_aip
56
+ info("AIRAC #{options[:airac].id} effective #{options[:airac].date}", force: true, color: :green)
57
+ @dependencies.tsort(options[:aip]).each do |aip|
58
+ info("Parsing #{aip}", force: true)
59
+ [:AIPP, options[:region], aip.classify].constantize.new(
60
+ aip: aip,
61
+ parser: self
62
+ ).parse
63
+ end
64
+ end
65
+
66
+ # Validate the AIXM document.
67
+ #
68
+ # @raise [RuntimeError] if the document is not valid
69
+ def validate_aixm
70
+ info("Validating #{options[:schema].upcase}", force: true)
71
+ unless aixm.valid?
72
+ send(@options[:force] ? :warn : :fail, "invalid AIXM document:\n#{aixm.errors}")
73
+ end
74
+ end
75
+
76
+ # Write the AIXM document.
77
+ def write_aixm
78
+ file = "#{options[:region]}_#{options[:airac].date.xmlschema}.#{options[:schema]}"
79
+ info("Writing #{file}", force: true)
80
+ AIXM.send("#{options[:schema]}!")
81
+ File.write(file, aixm.to_xml)
82
+ end
83
+
84
+ # Write the configuration to config.yml.
85
+ def write_config
86
+ info("Writing config.yml", force: true)
87
+ File.write(config_file, config.to_yaml)
88
+ end
89
+
90
+ private
91
+
92
+ def config_file
93
+ options[:storage].join('config.yml')
94
+ end
95
+ end
96
+
97
+ end
@@ -0,0 +1,40 @@
1
+ module AIPP
2
+ module Progress
3
+
4
+ # Issue an informational message.
5
+ #
6
+ # @param message [String] informational message
7
+ # @param force [Boolean] whether to show the message only when in verbose mode
8
+ # @param color [Symbol] override default color
9
+ def info(message, force: false, color: nil)
10
+ case
11
+ when !force && options[:verbose]
12
+ color ||= :blue
13
+ puts message.send(color)
14
+ when force
15
+ color ||= :black
16
+ puts message.send(color)
17
+ end
18
+ end
19
+
20
+ # Issue a warning and maybe open a Pry session in the context of the error
21
+ # or binding passed.
22
+ #
23
+ # @example with error context
24
+ # begin
25
+ # (...)
26
+ # rescue => error
27
+ # warn("oops", context: error)
28
+ # end
29
+ # @example with binding context
30
+ # warn("oops", context: binding)
31
+ # @param message [String] warning message
32
+ # @param context [Exception, Binding, nil] error or binding object
33
+ def warn(message, context: nil)
34
+ $WARN_COUNTER = $WARN_COUNTER.to_i + 1
35
+ Kernel.warn "WARNING #{$WARN_COUNTER}: #{message}".red
36
+ Pry::rescued(context) if context && options[:pry_on_warn] == $WARN_COUNTER
37
+ end
38
+
39
+ end
40
+ end
@@ -1,48 +1,111 @@
1
1
  module AIPP
2
2
  module Refinements
3
3
 
4
- refine Kernel do
5
- def warn(message, binding=nil)
6
- super(message)
7
- if $DEBUG && binding
8
- require 'pry'
9
- binding.pry
10
- end
11
- end
12
- end
13
-
4
+ # @!method blank_to_nil
5
+ # Convert blank strings to +nil+.
6
+ #
7
+ # @example
8
+ # "foobar".blank_to_nil # => "foobar"
9
+ # " ".blank_to_nil # => nil
10
+ # "".blank_to_nil # => nil
11
+ # nil.blank_to_nil # => nil
12
+ #
13
+ # @note This is a refinement for +String+ and +NilClass+
14
+ # @return [String, nil] converted string
14
15
  refine String do
15
- ##
16
- # Convert blank strings to +nil+
17
16
  def blank_to_nil
18
17
  match?(/\A\s*\z/) ? nil : self
19
18
  end
20
19
  end
21
20
 
21
+ # Always returns +nil+, companion to +String#blank_to_nil+.
22
22
  refine NilClass do
23
- ##
24
- # Companion to String#blank_to_nil
25
23
  def blank_to_nil
26
- self
24
+ nil
25
+ end
26
+ end
27
+
28
+ # @!method blank?
29
+ # Check whether the string is blank.
30
+ #
31
+ # @example
32
+ # "foobar".blank? # => false
33
+ # " ".blank? # => true
34
+ # "".blank? # => true
35
+ # nil.blank? # => true
36
+ #
37
+ # @note This is a refinement for +String+ and +NilClass+
38
+ # @return [Boolean] whether the string is blank or not
39
+ refine String do
40
+ def blank?
41
+ !blank_to_nil
27
42
  end
28
43
  end
29
44
 
45
+ # Always returns +true+, companion to +String#blank?+.
46
+ refine NilClass do
47
+ def blank?
48
+ true
49
+ end
50
+ end
51
+
52
+ # @!method classify
53
+ # Convert file name to class name.
54
+ #
55
+ # @example
56
+ # "ENR-5.1".classify # => "ENR51"
57
+ # "helper".classify # => "Helper"
58
+ # "foo_bar".classify # => "FooBar"
59
+ #
60
+ # @note This is a refinement for +String+
61
+ # @return [String] converted string
62
+ refine String do
63
+ def classify
64
+ gsub(/\W/, '').gsub(/(?:^|_)(\w)/) { $1.upcase }
65
+ end
66
+ end
67
+
68
+ # @!method constantize
69
+ # Get constant for array containing the lookup path.
70
+ #
71
+ # @example
72
+ # %w(AIPP AIRAC).constantize # => AIPP::AIRAC
73
+ #
74
+ # @note This is a refinement for +Array+
75
+ # @return [Class] converted array
30
76
  refine Array do
31
- ##
32
- # Split an array into nested arrays at the pattern (similar to +String#split+)
33
- def split(pattern)
77
+ def constantize
78
+ Kernel.const_get(self.join('::'))
79
+ end
80
+ end
81
+
82
+ # @!method split(object=nil, &block)
83
+ # Divides an enumerable into sub-enumerables based on a delimiter,
84
+ # returning an array of these sub-enumerables.
85
+ #
86
+ # It takes the same arguments as +Enumerable#find_index+ and suppresses
87
+ # trailing zero-length sub-enumerator as does +String#split+.
88
+ #
89
+ # @example
90
+ # [1, 2, 0, 3, 4].split { |e| e == 0 } # => [[1, 2], [3, 4]]
91
+ # [1, 2, 0, 3, 4].split(0) # => [[1, 2], [3, 4]]
92
+ # [0, 0, 1, 0, 2].split(0) # => [[], [] [1], [2]]
93
+ # [1, 0, 0, 2, 3].split(0) # => [[1], [], [2], [3]]
94
+ # [1, 0, 2, 0, 0].split(0) # => [[1], [2]]
95
+ #
96
+ # @note This is a refinement for +Enumerable+
97
+ # @param object [Object] element at which to split
98
+ # @yield [Object] element to analyze
99
+ # @yieldreturn [Boolean] whether to split at this element or not
100
+ # @return [Array]
101
+ refine Enumerable do
102
+ def split(*args, &block)
34
103
  [].tap do |array|
35
- nested_array = []
36
- each do |element|
37
- if pattern === element
38
- array << nested_array
39
- nested_array = []
40
- else
41
- nested_array << element
42
- end
104
+ while index = slice((start ||= 0)...length).find_index(*args, &block)
105
+ array << slice(start...start+index)
106
+ start += index + 1
43
107
  end
44
- array << nested_array
45
- array.pop while array.last == []
108
+ array << slice(start..-1) if start < length
46
109
  end
47
110
  end
48
111
  end
@@ -0,0 +1,89 @@
1
+ module AIPP
2
+ module LF
3
+
4
+ # FIR, TMA etc
5
+ class ENR21 < AIP
6
+ using AIPP::Refinements
7
+
8
+ # Map source types to type and local type
9
+ SOURCE_TYPES = {
10
+ 'FIR' => { type: 'FIR', local_type: nil },
11
+ 'UIR' => { type: 'UIR', local_type: nil },
12
+ 'UTA' => { type: 'UTA', local_type: nil },
13
+ 'CTA' => { type: 'CTA', local_type: nil },
14
+ 'LTA' => { type: 'CTA', local_type: 'LTA' },
15
+ 'TMA' => { type: 'TMA', local_type: nil },
16
+ 'SIV' => { type: 'SECTOR', local_type: 'SIV' } # providing FIS
17
+ }.freeze
18
+
19
+ # Map airspace "<type> <name>" to location indicator
20
+ LOCATION_INDICATORS = {
21
+ 'FIR BORDEAUX' => 'LFBB',
22
+ 'FIR BREST' => 'LFRR',
23
+ 'FIR MARSEILLE' => 'LFMM',
24
+ 'FIR PARIS' => 'LFFF',
25
+ 'FIR REIMS' => 'LFRR'
26
+ }.freeze
27
+
28
+ def parse
29
+ load_html.css('tbody').each do |tbody|
30
+ airspace = nil
31
+ tbody.css('tr').to_enum.with_index(1).each do |tr, index|
32
+ if tr.attr(:id).match?(/--TXT_NAME/)
33
+ aixm.features << airspace if airspace
34
+ airspace = airspace_from cleanup(node: tr).css(:td).first
35
+ info "Parsing #{airspace.type} #{airspace.name}" unless airspace.type == :terminal_control_area
36
+ next
37
+ end
38
+ begin
39
+ tds = cleanup(node: tr).css('td')
40
+ if airspace.type == :terminal_control_area && tds[0].text.blank_to_nil
41
+ airspace = airspace_from tds[0]
42
+ info "Parsing #{airspace.type} #{airspace.name}"
43
+ end
44
+ if airspace
45
+ if tds[0].text.blank_to_nil
46
+ airspace.geometry = geometry_from tds[0]
47
+ fail("geometry is not closed") unless airspace.geometry.closed?
48
+ end
49
+ layer = layer_from(tds[-3])
50
+ layer.class = class_from(tds[1]) if tds.count == 5
51
+ layer.location_indicator = LOCATION_INDICATORS.fetch("#{airspace.type} #{airspace.name}", nil)
52
+ # TODO: unit, call sign and frequency from tds[-2]
53
+ layer.timetable = timetable_from(tds[-1])
54
+ layer.remarks = remarks_from(tds[-1])
55
+ airspace.layers << layer
56
+ end
57
+ rescue => error
58
+ warn("error parsing #{airspace.type} `#{airspace.name}' at ##{index}: #{error.message}", context: error)
59
+ end
60
+ end
61
+ aixm.features << airspace if airspace
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def airspace_from(td)
68
+ spans = td.children.split { |e| e.name == 'br' }.first.css(:span).drop_while { |e| e.text.match? '\s' }
69
+ source_type = spans[0].text.blank_to_nil
70
+ fail "unknown type `#{source_type}'" unless SOURCE_TYPES.has_key? source_type
71
+ AIXM.airspace(
72
+ name: anglicise(name: spans[1..-1].join(' ')),
73
+ type: SOURCE_TYPES.dig(source_type, :type),
74
+ local_type: SOURCE_TYPES.dig(source_type, :local_type)
75
+ ).tap do |airspace|
76
+ airspace.source = source_for(td)
77
+ end
78
+ end
79
+
80
+ def class_from(td)
81
+ td.text.strip
82
+ end
83
+
84
+ def remarks_from(td)
85
+ td.text.strip.gsub(/(\s)\s+/, '\1').blank_to_nil
86
+ end
87
+ end
88
+ end
89
+ end