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.
- 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
|