aipp 0.1.3 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/.ruby-version +1 -1
- data/.yardopts +3 -0
- data/CHANGELOG.md +25 -10
- data/README.md +116 -27
- data/aipp.gemspec +4 -2
- data/exe/aip2aixm +1 -50
- data/exe/aip2ofmx +11 -0
- data/lib/aipp.rb +20 -3
- data/lib/aipp/aip.rb +63 -0
- data/lib/aipp/airac.rb +17 -9
- data/lib/aipp/executable.rb +82 -0
- data/lib/aipp/parser.rb +97 -0
- data/lib/aipp/progress.rb +40 -0
- data/lib/aipp/refinements.rb +91 -28
- data/lib/aipp/regions/LF/ENR-2.1.rb +89 -0
- data/lib/aipp/regions/LF/ENR-4.1.rb +101 -0
- data/lib/aipp/regions/LF/ENR-4.3.rb +26 -0
- data/lib/aipp/regions/LF/ENR-5.1.rb +65 -0
- data/lib/aipp/regions/LF/helper.rb +177 -0
- data/lib/aipp/t_hash.rb +46 -0
- data/lib/aipp/version.rb +1 -1
- data/spec/lib/aipp/airac_spec.rb +3 -3
- data/spec/lib/aipp/refinements_spec.rb +97 -27
- data/spec/lib/aipp/t_hash_spec.rb +44 -0
- metadata +50 -14
- data/lib/aipp/loader.rb +0 -52
- data/lib/aipp/parsers/LF/AD-1.5.rb +0 -128
- data/lib/aipp/parsers/LF/ENR-4.1.rb +0 -105
- data/lib/aipp/parsers/LF/ENR-4.3.rb +0 -32
- data/lib/aipp/parsers/LF/ENR-5.1.rb +0 -134
- data/lib/aipp/parsers/LF/helpers/html.rb +0 -11
- data/lib/aipp/parsers/LF/helpers/url.rb +0 -15
@@ -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
|
data/lib/aipp/parser.rb
ADDED
@@ -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
|
data/lib/aipp/refinements.rb
CHANGED
@@ -1,48 +1,111 @@
|
|
1
1
|
module AIPP
|
2
2
|
module Refinements
|
3
3
|
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
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
|
-
|
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
|
-
|
33
|
-
|
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
|
-
|
36
|
-
|
37
|
-
|
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 <<
|
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
|