aipp 0.2.1 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/.travis.yml +1 -2
  4. data/CHANGELOG.md +15 -0
  5. data/README.md +122 -37
  6. data/TODO.md +4 -0
  7. data/aipp.gemspec +8 -3
  8. data/lib/aipp.rb +14 -2
  9. data/lib/aipp/aip.rb +44 -29
  10. data/lib/aipp/downloader.rb +115 -0
  11. data/lib/aipp/executable.rb +6 -6
  12. data/lib/aipp/parser.rb +23 -23
  13. data/lib/aipp/patcher.rb +47 -0
  14. data/lib/aipp/pdf.rb +123 -0
  15. data/lib/aipp/regions/LF/AD-1.3.rb +162 -0
  16. data/lib/aipp/regions/LF/AD-1.3.yml +511 -0
  17. data/lib/aipp/regions/LF/AD-1.6.rb +31 -0
  18. data/lib/aipp/regions/LF/AD-2.rb +316 -0
  19. data/lib/aipp/regions/LF/AD-2.yml +185 -0
  20. data/lib/aipp/regions/LF/AD-3.1.rb-NEW +11 -0
  21. data/lib/aipp/regions/LF/ENR-2.1.rb +25 -24
  22. data/lib/aipp/regions/LF/ENR-4.1.rb +24 -23
  23. data/lib/aipp/regions/LF/ENR-4.3.rb +8 -6
  24. data/lib/aipp/regions/LF/ENR-5.1.rb +32 -22
  25. data/lib/aipp/regions/LF/ENR-5.5.rb-NEW +11 -0
  26. data/lib/aipp/regions/LF/helpers/AD_radio.rb +90 -0
  27. data/lib/aipp/regions/LF/helpers/URL.rb +26 -0
  28. data/lib/aipp/regions/LF/helpers/common.rb +186 -0
  29. data/lib/aipp/version.rb +1 -1
  30. data/lib/core_ext/enumerable.rb +52 -0
  31. data/lib/core_ext/nil_class.rb +10 -0
  32. data/lib/core_ext/object.rb +42 -0
  33. data/lib/core_ext/string.rb +105 -0
  34. data/spec/fixtures/archive.zip +0 -0
  35. data/spec/fixtures/document.pdf +0 -0
  36. data/spec/fixtures/document.pdf.json +1 -0
  37. data/spec/fixtures/new.html +6 -0
  38. data/spec/fixtures/new.pdf +0 -0
  39. data/spec/fixtures/new.txt +1 -0
  40. data/spec/lib/aipp/downloader_spec.rb +81 -0
  41. data/spec/lib/aipp/patcher_spec.rb +46 -0
  42. data/spec/lib/aipp/pdf_spec.rb +124 -0
  43. data/spec/lib/core_ext/enumberable_spec.rb +76 -0
  44. data/spec/lib/core_ext/nil_class_spec.rb +11 -0
  45. data/spec/lib/core_ext/string_spec.rb +88 -0
  46. data/spec/spec_helper.rb +1 -0
  47. metadata +123 -23
  48. data/lib/aipp/progress.rb +0 -40
  49. data/lib/aipp/refinements.rb +0 -114
  50. data/lib/aipp/regions/LF/helper.rb +0 -177
  51. data/spec/lib/aipp/refinements_spec.rb +0 -123
@@ -0,0 +1,115 @@
1
+ module AIPP
2
+
3
+ # AIP downloader infrastructure
4
+ #
5
+ # The downloader operates in the +storage+ directory where it creates two
6
+ # subdirectories "archive" and "work". The initializer looks for +archive+
7
+ # in "archives" and (if found) unzips its contents into "work". When reading
8
+ # a +document+, the downloader looks for the +document+ in "work" and
9
+ # (unless found) downloads it from +url+. HTML documents are parsed to
10
+ # +Nokogiri::HTML5::Document+, PDF documents are parsed to +AIPP::PDF+.
11
+ # Finally, the contents of "work" are written back to +archive+.
12
+ #
13
+ # @example
14
+ # AIPP::Downloader.new(storage: options[:storage], archive: "2018-11-08") do |downloader|
15
+ # html = downloader.read(
16
+ # document: 'ENR-5.1',
17
+ # url: 'https://www.sia.aviation-civile.gouv.fr/dvd/eAIP_08_NOV_2018/FRANCE/AIRAC-2018-11-08/html/eAIP/FR-ENR-5.1-fr-FR.html'
18
+ # )
19
+ # pdf = downloader.read(
20
+ # document: 'VAC-LFMV',
21
+ # url: 'https://www.sia.aviation-civile.gouv.fr/dvd/eAIP_08_NOV_2018/Atlas-VAC/PDF_AIPparSSection/VAC/AD/AD-2.LFMV.pdf'
22
+ # )
23
+ # end
24
+ class Downloader
25
+
26
+ # @return [Pathname] directory to operate within
27
+ attr_reader :storage
28
+
29
+ # @return [String] name of the archive (without extension ".zip")
30
+ attr_reader :archive
31
+
32
+ # @return [Pathname] full path to the archive
33
+ attr_reader :archive_file
34
+
35
+ # @param storage [Pathname] directory to operate within
36
+ # @param archive [String] name of the archive (without extension ".zip")
37
+ def initialize(storage:, archive:)
38
+ @storage, @archive = storage, archive
39
+ fail(ArgumentError, 'bad storage directory') unless Dir.exist? storage
40
+ @archive_file = archives_path.join("#{@archive}.zip")
41
+ prepare
42
+ unzip if @archive_file.exist?
43
+ yield self
44
+ zip
45
+ ensure
46
+ teardown
47
+ end
48
+
49
+ # Download and read +document+
50
+ #
51
+ # @param document [String] document to read (without extension)
52
+ # @param url [String] URL to download the document from
53
+ # @param type [Symbol, nil] document type: +nil+ (default) to derive it from
54
+ # the URL, :html, or :pdf
55
+ # @return [Nokogiri::HTML5::Document, AIPP::PDF]
56
+ def read(document:, url:, type: nil)
57
+ type ||= Pathname(URI(url).path).extname[1..-1].to_sym
58
+ file = work_path.join([document, type].join('.'))
59
+ unless file.exist?
60
+ debug "Downloading #{document}"
61
+ IO.copy_stream(Kernel.open(url), file)
62
+ end
63
+ convert file
64
+ end
65
+
66
+ private
67
+
68
+ def archives_path
69
+ @storage.join('archives')
70
+ end
71
+
72
+ def work_path
73
+ @storage.join('work')
74
+ end
75
+
76
+ def prepare
77
+ teardown
78
+ archives_path.mkpath
79
+ work_path.mkpath
80
+ end
81
+
82
+ def teardown
83
+ if work_path.exist?
84
+ work_path.children.each(&:delete)
85
+ work_path.delete
86
+ end
87
+ end
88
+
89
+ def unzip
90
+ Zip::File.open(archive_file).each do |entry|
91
+ entry.extract(work_path.join(entry.name))
92
+ end
93
+ end
94
+
95
+ def zip
96
+ backup_file = archive_file.sub(/$/, '.old') if archive_file.exist?
97
+ archive_file.rename(backup_file) if backup_file
98
+ Zip::File.open(archive_file, Zip::File::CREATE) do |zip|
99
+ work_path.children.each do |entry|
100
+ zip.add(entry.basename.to_s, entry) unless entry.basename.to_s[0] == '.'
101
+ end
102
+ end
103
+ backup_file&.delete
104
+ end
105
+
106
+ def convert(file)
107
+ case file.extname
108
+ when '.html' then Nokogiri.HTML5(file)
109
+ when '.pdf' then AIPP::PDF.new(file)
110
+ else
111
+ fail(ArgumentError, "invalid document type")
112
+ end
113
+ end
114
+ end
115
+ end
@@ -8,7 +8,7 @@ module AIPP
8
8
  @options = options
9
9
  @options[:airac] = AIPP::AIRAC.new
10
10
  @options[:storage] = Pathname(Dir.home).join('.aipp')
11
- @options[:verbose] = @options[:force] = @options[:pry_on_error] = false
11
+ @options[:force] = $PRY_ON_WARN = $PRY_ON_ERROR = false
12
12
  OptionParser.new do |o|
13
13
  o.banner = <<~END
14
14
  Download online AIP and convert it to #{options[:schema].upcase}.
@@ -18,10 +18,10 @@ module AIPP
18
18
  o.on('-r', '--region STRING', String, 'region (e.g. "LF")') { |v| @options[:region] = v.upcase }
19
19
  o.on('-a', '--aip STRING', String, 'process this AIP only (e.g. "ENR-5.1")') { |v| @options[:aip] = v.upcase }
20
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
21
  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 }
22
+ o.on('-v', '--[no-]verbose', 'verbose output and Ruby debug mode (default: false)') { |v| $DEBUG = v }
23
+ o.on('-w', '--pry-on-warn [ID]', Integer, 'open pry on warn with ID (default: nil)') { |v| $PRY_ON_WARN = v || true }
24
+ o.on('-e', '--[no-]pry-on-error', 'open pry on error (default: false)') { |v| $PRY_ON_ERROR = v }
25
25
  o.on('-A', '--about', 'show author/license information and exit') { about }
26
26
  o.on('-R', '--readme', 'show README and exit') { readme }
27
27
  o.on('-L', '--list', 'list implemented regions and AIPs') { list }
@@ -33,7 +33,7 @@ module AIPP
33
33
  #
34
34
  # @raise [RuntimeError] if the region does not exist
35
35
  def run
36
- Pry::rescue do
36
+ Pry.rescue do
37
37
  fail(OptionParser::MissingArgument, :region) unless options[:region]
38
38
  AIPP::Parser.new(options: options).tap do |parser|
39
39
  parser.read_config
@@ -45,7 +45,7 @@ module AIPP
45
45
  end
46
46
  rescue => error
47
47
  puts "ERROR: #{error.message}"
48
- Pry::rescued(error) if options[:pry_on_error]
48
+ Pry::rescued(error) if $PRY_ON_ERROR
49
49
  end
50
50
  end
51
51
 
@@ -2,8 +2,6 @@ module AIPP
2
2
 
3
3
  # AIP parser infrastructure
4
4
  class Parser
5
- using AIPP::Refinements
6
- include AIPP::Progress
7
5
 
8
6
  # @return [Hash] passed command line arguments
9
7
  attr_reader :options
@@ -20,16 +18,16 @@ module AIPP
20
18
  def initialize(options:)
21
19
  @options = options
22
20
  @options[:storage] = options[:storage].join(options[:region])
23
- @options[:storage].mkpath
21
+ @options[:storage].mkpath unless @options[:storage].exist?
24
22
  @config = {}
25
23
  @aixm = AIXM.document(region: @options[:region], effective_at: @options[:airac].date)
26
- @cache = OpenStruct.new
27
24
  @dependencies = THash.new
25
+ @cache = OpenStruct.new
28
26
  end
29
27
 
30
28
  # Read the configuration from config.yml.
31
29
  def read_config
32
- info("Reading config.yml", force: true)
30
+ info("Reading config.yml")
33
31
  @config = YAML.load_file(config_file, fallback: {}).transform_keys(&:to_sym) if config_file.exist?
34
32
  @config[:namespace] ||= SecureRandom.uuid
35
33
  @aixm.namespace = @config[:namespace]
@@ -37,29 +35,30 @@ module AIPP
37
35
 
38
36
  # Read the region directory and build the dependency list.
39
37
  def read_region
40
- info("Reading region #{options[:region]}", force: true)
38
+ info("Reading region #{options[:region]}")
41
39
  dir = Pathname(__FILE__).dirname.join('regions', options[:region])
42
40
  fail("unknown region `#{options[:region]}'") unless dir.exist?
41
+ dir.glob('helpers/*.rb').each { |f| require f }
43
42
  dir.glob('*.rb').each do |file|
44
- info("Requiring #{file.basename}")
43
+ debug("Requiring #{file.basename}")
45
44
  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
45
+ aip = file.basename('.*').to_s
46
+ @dependencies[aip] = ("AIPP::%s::%s::DEPENDS" % [options[:region], aip.remove(/\W/).classify]).constantize
51
47
  end
52
48
  end
53
49
 
54
50
  # Parse AIP by invoking the parser classes for the current region.
55
51
  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
52
+ info("AIRAC #{options[:airac].id} effective #{options[:airac].date}", color: :green)
53
+ AIPP::Downloader.new(storage: options[:storage], archive: options[:airac].date.xmlschema) do |downloader|
54
+ @dependencies.tsort(options[:aip]).each do |aip|
55
+ info("Parsing #{aip}")
56
+ ("AIPP::%s::%s" % [options[:region], aip.remove(/\W/).classify]).constantize.new(
57
+ aip: aip,
58
+ downloader: downloader,
59
+ parser: self
60
+ ).attach_patches.tap(&:parse).detach_patches
61
+ end
63
62
  end
64
63
  end
65
64
 
@@ -67,23 +66,24 @@ module AIPP
67
66
  #
68
67
  # @raise [RuntimeError] if the document is not valid
69
68
  def validate_aixm
70
- info("Validating #{options[:schema].upcase}", force: true)
69
+ info("Validating #{options[:schema].upcase}")
71
70
  unless aixm.valid?
72
- send(@options[:force] ? :warn : :fail, "invalid AIXM document:\n#{aixm.errors}")
71
+ message = "invalid AIXM document:\n" + aixm.errors.map(&:message).join("\n")
72
+ @options[:force] ? warn(message, pry: binding) : fail(message)
73
73
  end
74
74
  end
75
75
 
76
76
  # Write the AIXM document.
77
77
  def write_aixm
78
78
  file = "#{options[:region]}_#{options[:airac].date.xmlschema}.#{options[:schema]}"
79
- info("Writing #{file}", force: true)
79
+ info("Writing #{file}")
80
80
  AIXM.send("#{options[:schema]}!")
81
81
  File.write(file, aixm.to_xml)
82
82
  end
83
83
 
84
84
  # Write the configuration to config.yml.
85
85
  def write_config
86
- info("Writing config.yml", force: true)
86
+ info("Writing config.yml")
87
87
  File.write(config_file, config.to_yaml)
88
88
  end
89
89
 
@@ -0,0 +1,47 @@
1
+ module AIPP
2
+ module Patcher
3
+
4
+ def self.included(klass)
5
+ klass.extend(ClassMethods)
6
+ klass.class_variable_set(:@@patches, {})
7
+ end
8
+
9
+ module ClassMethods
10
+ def patches
11
+ class_variable_get(:@@patches)
12
+ end
13
+
14
+ def patch(klass, attribute, &block)
15
+ (patches[self] ||= []) << [klass, attribute, block]
16
+ end
17
+ end
18
+
19
+ def attach_patches
20
+ parser = self
21
+ self.class.patches[self.class]&.each do |(klass, attribute, block)|
22
+ klass.instance_eval do
23
+ alias_method :"original_#{attribute}=", :"#{attribute}="
24
+ define_method(:"#{attribute}=") do |value|
25
+ catch :abort do
26
+ value = block.call(parser, self, value)
27
+ debug("PATCH: #{self.inspect}", color: :magenta)
28
+ end
29
+ send(:"original_#{attribute}=", value)
30
+ end
31
+ end
32
+ end
33
+ self
34
+ end
35
+
36
+ def detach_patches
37
+ self.class.patches[self.class]&.each do |(klass, attribute, _)|
38
+ klass.instance_eval do
39
+ alias_method :"#{attribute}=", :"original_#{attribute}="
40
+ remove_method :"original_#{attribute}="
41
+ end
42
+ end
43
+ self
44
+ end
45
+
46
+ end
47
+ end
@@ -0,0 +1,123 @@
1
+ module AIPP
2
+
3
+ # PDF to text reader with support for pages and fencing
4
+ #
5
+ # @example
6
+ # pdf = AIPP::PDF.new("/path/to/file.pdf")
7
+ # pdf.file # => #<Pathname:/path/to/file.pdf>
8
+ # pdf.from(100).to(200).each_line_with_position do |line, page, last|
9
+ # line # => line content (e.g. "first line")
10
+ # page # => page number (e.g. 1)
11
+ # last # => last line boolean (true for last line, false otherwise)
12
+ # end
13
+ class PDF
14
+ attr_reader :file
15
+
16
+ def initialize(file, cache: true)
17
+ @file = file.is_a?(Pathname) ? file : Pathname(file)
18
+ @text, @page_ranges = cache ? read_cache : read
19
+ @from = 0
20
+ @to = @last = @text.length - 1
21
+ end
22
+
23
+ # @return [String]
24
+ def inspect
25
+ %Q(#<#{self.class} file=#{@file} range=#{range}>)
26
+ end
27
+
28
+ # Fence the PDF beginning with this index
29
+ #
30
+ # @param index [Integer, Symbol] either an integer position within the
31
+ # +text+ string or +:begin+ to indicate "first existing position"
32
+ # @return [self]
33
+ def from(index)
34
+ index = 0 if index == :begin
35
+ fail ArgumentError unless (0..@to).include? index
36
+ @from = index
37
+ self
38
+ end
39
+
40
+ # Fence the PDF ending with this index
41
+ #
42
+ # @param index [Integer, Symbol] either an integer position within the
43
+ # +text+ string or +:end+ to indicate "last existing position"
44
+ # @return [self]
45
+ def to(index)
46
+ index = @last if index == :end
47
+ fail ArgumentError unless (@from..@last).include? index
48
+ @to = index
49
+ self
50
+ end
51
+
52
+ # Get the current fencing range
53
+ #
54
+ # @return [Range<Integer>]
55
+ def range
56
+ (@from..@to)
57
+ end
58
+
59
+ # Text string of the PDF with fencing applied
60
+ #
61
+ # @return [String] PDF converted to string
62
+ def text
63
+ @text[range]
64
+ end
65
+
66
+ # Text split to individual lines
67
+ #
68
+ # @return [Array] lines
69
+ def lines
70
+ text.split(/(?<=[\n\f])/)
71
+ end
72
+
73
+ # Executes the block for every line and passes the line content, page
74
+ # number and end of document boolean.
75
+ #
76
+ # If no block is given, an enumerator is returned instead.
77
+ #
78
+ # @yieldparam line [String] content of the line
79
+ # @yieldparam page [Integer] page number the line is found on within the PDF
80
+ # @yieldparam last [Boolean] true for the last line, false otherwise
81
+ # @return [Enumerator]
82
+ def each_line
83
+ return enum_for(:each) unless block_given?
84
+ offset, last_line_index = @from, lines.count - 1
85
+ lines.each_with_index do |line, line_index|
86
+ yield(line, page_for(index: offset), line_index == last_line_index)
87
+ offset += line.length
88
+ end
89
+ end
90
+ alias_method :each, :each_line
91
+
92
+ private
93
+
94
+ def read
95
+ pages = ::PDF::Reader.new(@file).pages
96
+ [pages.map(&:text).join("\f"), page_ranges_for(pages)]
97
+ end
98
+
99
+ def read_cache
100
+ cache_file = "#{@file}.json"
101
+ if File.exist?(cache_file) && (File.stat(@file).mtime - File.stat(cache_file).mtime).abs < 1
102
+ JSON.load File.read(cache_file)
103
+ else
104
+ read.tap do |data|
105
+ File.write(cache_file, data.to_json)
106
+ FileUtils.touch(cache_file, mtime: File.stat(@file).mtime)
107
+ end
108
+ end
109
+ end
110
+
111
+ def page_ranges_for(pages)
112
+ [].tap do |page_ranges|
113
+ pages.each_with_index do |page, index|
114
+ page_ranges << (page_ranges.last || 0) + page.text.length + index
115
+ end
116
+ end
117
+ end
118
+
119
+ def page_for(index:)
120
+ @page_ranges.index(@page_ranges.bsearch { |i| i >= index }) + 1
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,162 @@
1
+ module AIPP
2
+ module LF
3
+
4
+ # Aerodromes
5
+ class AD13 < AIP
6
+
7
+ include AIPP::LF::Helpers::Common
8
+
9
+ DEPENDS = %w(AD-2)
10
+
11
+ # Map names of id-less airports to unofficial ids
12
+ ID_LESS_AIRPORTS = {
13
+ "ALBE" => 'LF9001',
14
+ "BEAUMONT DE LOMAGNE" => 'LF9002',
15
+ "BERDOUES" => 'LF9003',
16
+ "BOULOC" => 'LF9004',
17
+ "BUXEUIL ST REMY / CREUSE" => 'LF9005',
18
+ "CALVIAC" => 'LF9006',
19
+ "CAYLUS" => 'LF9007',
20
+ "CORBONOD" => 'LF9008',
21
+ "L'ISLE EN DODON" => 'LF9009',
22
+ "LACAVE LE FRAU" => 'LF9010',
23
+ "LUCON CHASNAIS" => 'LF9011',
24
+ "PEYRELEVADE" => 'LF9012',
25
+ "SAINT CYR LA CAMPAGNE" => 'LF9013',
26
+ "SEPTFONDS" => 'LF9014',
27
+ "TALMONT VENDEE AIR PARK" => 'LF9015'
28
+ }
29
+
30
+ def parse
31
+ ad2_exists = false
32
+ tbody = prepare(html: read).css('tbody').first # skip altiports
33
+ tbody.css('tr').to_enum.with_index(1).each do |tr, index|
34
+ if tr.attr(:id).match?(/-TXT_NAME-/)
35
+ write @airport if @airport && !ad2_exists
36
+ @airport = airport_from tr
37
+ debug "Parsing #{@airport.id}"
38
+ ad2_exists = false
39
+ if airport = select(:airport, id: @airport.id).first
40
+ ad2_exists = true
41
+ @airport = airport
42
+ end
43
+ add_usage_limitations_from tr
44
+ next
45
+ end
46
+ @airport.add_runway(runway_from(tr)) unless ad2_exists
47
+ rescue => error
48
+ warn("error parsing #{@airport.id} at ##{index}: #{error.message}", pry: error)
49
+ end
50
+ write @airport if @airport && !ad2_exists
51
+ end
52
+
53
+ private
54
+
55
+ def airport_from(tr)
56
+ tds = tr.css('td')
57
+ id = tds[0].text.strip.blank_to_nil || ID_LESS_AIRPORTS.fetch(tds[1].text.strip)
58
+ AIXM.airport(
59
+ source: source(position: tr.line),
60
+ organisation: organisation_lf, # TODO: not yet implemented
61
+ id: id,
62
+ name: tds[1].text.strip,
63
+ xy: xy_from(tds[3].text)
64
+ ).tap do |airport|
65
+ airport.z = AIXM.z(tds[4].text.strip.to_i, :qnh)
66
+ airport.declination = tds[2].text.remove('°').strip.to_f
67
+ airport.transition_z = AIXM.z(5000, :qnh) # TODO: default - exceptions may exist
68
+ end
69
+ end
70
+
71
+ def add_usage_limitations_from(tr)
72
+ raw_limitation = tr.css('td:nth-of-type(8)').text.cleanup.downcase
73
+ raw_conditions = tr.css('td:nth-of-type(6)').text.cleanup.downcase.split(%r([\s/]+))
74
+ limitation = case raw_limitation
75
+ when /ouv.+cap|milit/ then :permitted
76
+ when /usa.+restr|priv/ then :reservation_required
77
+ end
78
+ @airport.add_usage_limitation(limitation) do |l|
79
+ l.add_condition do |c|
80
+ c.realm = :military if raw_limitation.match?(/milit/)
81
+ c.origin = :national if raw_conditions.include?('ntl')
82
+ c.origin = :international if raw_conditions.include?('intl')
83
+ c.rule = :ifr if raw_conditions.include?('ifr')
84
+ c.rule = :vfr if raw_conditions.include?('vfr')
85
+ c.purpose = :scheduled if raw_conditions.include?('s')
86
+ c.purpose = :not_scheduled if raw_conditions.include?('ns')
87
+ c.purpose = :private if raw_conditions.include?('p')
88
+ end
89
+ l.remarks = "Usage restreint (voir VAC) / restricted use (see VAC)" if raw_limitation.match?(/usa.+restr/)
90
+ l.remarks = "Propriété privée / privately owned" if raw_limitation.match?(/priv/)
91
+ end
92
+ end
93
+
94
+ def runway_from(tr)
95
+ tds = tr.css('td')
96
+ surface = tds[1].css('span[id*="SURFACE"]').text
97
+ AIXM.runway(
98
+ name: tds[0].text.strip.split.join('/')
99
+ ).tap do |runway|
100
+ @runway = runway # TODO: needed for now for surface composition patches to work
101
+ runway.length = AIXM.d(tds[1].css('span[id$="VAL_LEN"]').text.to_i, :m)
102
+ runway.width = AIXM.d(tds[1].css('span[id$="VAL_WID"]').text.to_i, :m)
103
+ runway.surface.composition = (COMPOSITIONS.fetch(surface)[:composition] unless surface.blank?)
104
+ runway.surface.preparation = (COMPOSITIONS.fetch(surface)[:preparation] unless surface.blank?)
105
+ runway.remarks = tds[7].text.cleanup.blank_to_nil
106
+ values = tds[2].text.remove('°').strip.split
107
+ runway.forth.geographic_orientation = AIXM.a(values.first.to_i)
108
+ runway.back.geographic_orientation = AIXM.a(values.last.to_i)
109
+ parts = tds[3].text.strip.split(/\n\s+\n\s+/)
110
+ runway.forth.xy = (xy_from(parts[0]) unless parts[0].blank?)
111
+ runway.back.xy = (xy_from(parts[1]) unless parts[1].blank?)
112
+ values = tds[4].text.strip.split
113
+ runway.forth.z = AIXM.z(values.first.to_i, :qnh)
114
+ runway.back.z = AIXM.z(values.last.to_i, :qnh)
115
+ displaced_thresholds = displaced_thresholds_from(tds[5])
116
+ runway.forth.displaced_threshold = displaced_thresholds.first
117
+ runway.back.displaced_threshold = displaced_thresholds.last
118
+ end
119
+ end
120
+
121
+ def displaced_thresholds_from(td)
122
+ values = td.text.strip.split
123
+ case values.count
124
+ when 1 then []
125
+ when 2 then [AIXM.xy(lat: values[0], long: values[1]), nil]
126
+ when 3 then [nil, AIXM.xy(lat: values[1], long: values[2])]
127
+ when 4 then [AIXM.xy(lat: values[0], long: values[1]), AIXM.xy(lat: values[2], long: values[3])]
128
+ else fail "cannot parse displaced thresholds"
129
+ end
130
+ end
131
+
132
+ patch AIXM::Component::Runway, :width do |parser, object, value|
133
+ throw :abort unless value.zero?
134
+ @fixtures ||= YAML.load_file(Pathname(__FILE__).dirname.join('AD-1.3.yml'))
135
+ airport_id = parser.instance_variable_get(:@airport).id
136
+ runway_name = object.name.to_s
137
+ throw :abort if (width = @fixtures.dig('runways', airport_id, runway_name, 'width')).nil?
138
+ AIXM.d(width.to_i, :m)
139
+ end
140
+
141
+ patch AIXM::Component::Runway::Direction, :xy do |parser, object, value|
142
+ throw :abort unless value.nil?
143
+ @fixtures ||= YAML.load_file(Pathname(__FILE__).dirname.join('AD-1.3.yml'))
144
+ airport_id = parser.instance_variable_get(:@airport).id
145
+ direction_name = object.name.to_s
146
+ throw :abort if (xy = @fixtures.dig('runways', airport_id, direction_name, 'xy')).nil?
147
+ lat, long = xy.split(/\s+/)
148
+ AIXM.xy(lat: lat, long: long)
149
+ end
150
+
151
+ patch AIXM::Component::Surface, :composition do |parser, object, value|
152
+ throw :abort unless value.blank?
153
+ @fixtures ||= YAML.load_file(Pathname(__FILE__).dirname.join('AD-1.3.yml'))
154
+ airport_id = parser.instance_variable_get(:@airport).id
155
+ runway_name = parser.instance_variable_get(:@runway).name
156
+ throw :abort if (composition = @fixtures.dig('runways', airport_id, runway_name, 'composition')).nil?
157
+ composition
158
+ end
159
+
160
+ end
161
+ end
162
+ end