aipp 0.1.3 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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