aipp 1.0.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +2 -2
  3. data/CHANGELOG.md +17 -1
  4. data/README.md +269 -150
  5. data/exe/aip2aixm +2 -8
  6. data/exe/aip2ofmx +2 -8
  7. data/exe/notam2aixm +5 -0
  8. data/exe/notam2ofmx +5 -0
  9. data/lib/aipp/aip/README.md +10 -0
  10. data/lib/aipp/aip/executable.rb +40 -0
  11. data/lib/aipp/aip/parser.rb +9 -0
  12. data/lib/aipp/aip/runner.rb +85 -0
  13. data/lib/aipp/border.rb +2 -2
  14. data/lib/aipp/debugger.rb +14 -19
  15. data/lib/aipp/downloader/file.rb +57 -0
  16. data/lib/aipp/downloader/graphql.rb +29 -0
  17. data/lib/aipp/downloader/http.rb +48 -0
  18. data/lib/aipp/downloader.rb +78 -29
  19. data/lib/aipp/environment.rb +88 -0
  20. data/lib/aipp/executable.rb +36 -53
  21. data/lib/aipp/notam/README.md +25 -0
  22. data/lib/aipp/notam/executable.rb +27 -0
  23. data/lib/aipp/notam/parser.rb +9 -0
  24. data/lib/aipp/notam/runner.rb +28 -0
  25. data/lib/aipp/parser.rb +133 -160
  26. data/lib/aipp/patcher.rb +4 -5
  27. data/lib/aipp/regions/LF/README.md +6 -2
  28. data/lib/aipp/regions/LF/aip/aerodromes.rb +220 -0
  29. data/lib/aipp/regions/LF/aip/d_p_r_airspaces.rb +53 -0
  30. data/lib/aipp/regions/LF/aip/dangerous_activities.rb +48 -0
  31. data/lib/aipp/regions/LF/aip/designated_points.rb +44 -0
  32. data/lib/aipp/regions/LF/aip/helipads.rb +119 -0
  33. data/lib/aipp/regions/LF/aip/navigational_aids.rb +82 -0
  34. data/lib/aipp/regions/LF/aip/obstacles.rb +150 -0
  35. data/lib/aipp/regions/LF/aip/serviced_airspaces.rb +67 -0
  36. data/lib/aipp/regions/LF/aip/services.rb +169 -0
  37. data/lib/aipp/regions/LF/fixtures/aerodromes.yml +2 -2
  38. data/lib/aipp/regions/LF/helpers/base.rb +32 -32
  39. data/lib/aipp/regions/LS/README.md +59 -0
  40. data/lib/aipp/regions/LS/helpers/base.rb +111 -0
  41. data/lib/aipp/regions/LS/notam/ENR.rb +173 -0
  42. data/lib/aipp/runner.rb +152 -0
  43. data/lib/aipp/version.rb +1 -1
  44. data/lib/aipp.rb +30 -11
  45. data/lib/core_ext/array.rb +13 -0
  46. data/lib/core_ext/nokogiri.rb +56 -8
  47. data/lib/core_ext/string.rb +63 -1
  48. data.tar.gz.sig +0 -0
  49. metadata +115 -64
  50. metadata.gz.sig +0 -0
  51. data/lib/aipp/aip.rb +0 -166
  52. data/lib/aipp/regions/LF/aerodromes.rb +0 -223
  53. data/lib/aipp/regions/LF/d_p_r_airspaces.rb +0 -56
  54. data/lib/aipp/regions/LF/dangerous_activities.rb +0 -49
  55. data/lib/aipp/regions/LF/designated_points.rb +0 -47
  56. data/lib/aipp/regions/LF/helipads.rb +0 -122
  57. data/lib/aipp/regions/LF/navigational_aids.rb +0 -85
  58. data/lib/aipp/regions/LF/obstacles.rb +0 -153
  59. data/lib/aipp/regions/LF/serviced_airspaces.rb +0 -70
  60. data/lib/aipp/regions/LF/services.rb +0 -172
data/exe/aip2aixm CHANGED
@@ -1,11 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- require 'bundler/inline'
3
+ require 'aipp'
4
4
 
5
- gemfile do
6
- source 'https://rubygems.org'
7
- ruby '>= 3.0.0'
8
- gem 'aipp', '~> 1'
9
- end
10
-
11
- AIPP::Executable.new(schema: File.basename($0)[4..-1].to_sym).run
5
+ AIPP::AIP::Executable.new(File.basename($0)).run
data/exe/aip2ofmx CHANGED
@@ -1,11 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- require 'bundler/inline'
3
+ require 'aipp'
4
4
 
5
- gemfile do
6
- source 'https://rubygems.org'
7
- ruby '>= 3.0.0'
8
- gem 'aipp', '~> 1'
9
- end
10
-
11
- AIPP::Executable.new(schema: File.basename($0)[4..-1].to_sym).run
5
+ AIPP::AIP::Executable.new(File.basename($0)).run
data/exe/notam2aixm ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'aipp'
4
+
5
+ AIPP::NOTAM::Executable.new(File.basename($0)).run
data/exe/notam2ofmx ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'aipp'
4
+
5
+ AIPP::NOTAM::Executable.new(File.basename($0)).run
@@ -0,0 +1,10 @@
1
+ # AIPP AIP Module
2
+
3
+ ## Cache Time Window
4
+
5
+ The default time window for AIP is the AIRAC cycle. This means:
6
+
7
+ * Source data is downloaded and cached once for every AIRAC cycle.
8
+ * The effective date and time is rounded down to the first day midnight of the AIRAC cycle.
9
+
10
+ To force a rebuild within this time window, you have to clean the cache using the `-c` command line argument.
@@ -0,0 +1,40 @@
1
+ module AIPP
2
+ module AIP
3
+
4
+ class Executable < AIPP::Executable
5
+
6
+ def initialize(exe_file)
7
+ super
8
+ AIPP.options.merge(
9
+ module: 'AIP',
10
+ airac: AIRAC::Cycle.new,
11
+ region_options: []
12
+ )
13
+ OptionParser.new do |o|
14
+ o.banner = <<~END
15
+ Download online AIP and convert it to #{AIPP.options.schema.upcase}.
16
+ Usage: #{File.basename($0)} [options]
17
+ END
18
+ common_options(o)
19
+ o.on('-a', '--airac (DATE|INTEGER)', String, %Q[AIRAC date or delta e.g. "+1" (default: "#{AIPP.options.airac.date.xmlschema}")]) { AIPP.options.airac = airac_for(_1) }
20
+ if AIPP.options.schema == :ofmx
21
+ o.on('-g', '--[no-]grouped-obstacles', 'group obstacles (default: false)') { AIPP.options.grouped_obstacles = _1 }
22
+ end
23
+ o.on('-O', '--region-options STRING', String, %Q[comma separated region specific options]) { AIPP.options.region_options = _1.split(',') }
24
+ developer_options(o)
25
+ end.parse!
26
+ end
27
+
28
+ private
29
+
30
+ def airac_for(argument)
31
+ if argument.match?(/^[+-]\d+$/) # delta
32
+ AIRAC::Cycle.new + argument.to_i
33
+ else # date
34
+ AIRAC::Cycle.new(argument)
35
+ end
36
+ end
37
+
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,9 @@
1
+ module AIPP
2
+ module AIP
3
+
4
+ # @abstract
5
+ class Parser < AIPP::Parser
6
+ end
7
+
8
+ end
9
+ end
@@ -0,0 +1,85 @@
1
+ module AIPP
2
+ module AIP
3
+
4
+ class Runner < AIPP::Runner
5
+
6
+ def effective_at
7
+ AIPP.options.airac.effective.begin
8
+ end
9
+
10
+ def expiration_at
11
+ AIPP.options.airac.effective.end
12
+ end
13
+
14
+ def run
15
+ info("AIP AIRAC #{AIPP.options.airac.id} effective #{effective_at}", color: :green)
16
+ read_config
17
+ read_region
18
+ read_parsers
19
+ parse_sections
20
+ validate_aixm
21
+ write_build
22
+ write_aixm(AIPP.options.output_file || output_file)
23
+ write_config
24
+ end
25
+
26
+ private
27
+
28
+ # Parse AIP by invoking the parser classes for the current region.
29
+ def parse_sections
30
+ super
31
+ if AIPP.options.grouped_obstacles
32
+ info("grouping obstacles")
33
+ aixm.group_obstacles!
34
+ end
35
+ end
36
+
37
+ # Write the AIXM document and context information.
38
+ def write_build
39
+ if AIPP.options.section
40
+ super
41
+ else
42
+ info("writing build")
43
+ builds_dir.mkpath
44
+ build_file = builds_dir.join("#{AIPP.options.airac.date.xmlschema}.zip")
45
+ Dir.mktmpdir do |tmp_dir|
46
+ tmp_dir = Pathname(tmp_dir)
47
+ # AIXM/OFMX file
48
+ AIXM.config.mid = true
49
+ File.write(tmp_dir.join(output_file), aixm.to_xml)
50
+ # Build details
51
+ File.write(
52
+ tmp_dir.join('build.yaml'), {
53
+ version: AIPP::VERSION,
54
+ config: AIPP.config,
55
+ options: AIPP.options,
56
+ }.to_yaml
57
+ )
58
+ # Manifest
59
+ manifest = ['AIP','Feature', 'Comment', 'Short Uid Hash', 'Short Feature Hash'].to_csv
60
+ manifest += aixm.features.map do |feature|
61
+ xml = feature.to_xml
62
+ element = xml.first_match(/<(\w{3})\s/)
63
+ [
64
+ feature.source.split('|')[2],
65
+ element,
66
+ xml.match(/<!-- (.*?) -->/)[1],
67
+ AIXM::PayloadHash.new(xml.match(%r(<#{element}Uid\s.*?</#{element}Uid>)m).to_s).to_uuid[0,8],
68
+ AIXM::PayloadHash.new(xml).to_uuid[0,8]
69
+ ].to_csv
70
+ end.sort.join
71
+ File.write(tmp_dir.join('manifest.csv'), manifest)
72
+ # Zip it
73
+ build_file.delete if build_file.exist?
74
+ Zip::File.open(build_file, Zip::File::CREATE) do |zip|
75
+ tmp_dir.children.each do |entry|
76
+ zip.add(entry.basename.to_s, entry) unless entry.basename.to_s[0] == '.'
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ end
85
+ end
data/lib/aipp/border.rb CHANGED
@@ -35,7 +35,7 @@ module AIPP
35
35
  # }
36
36
  #
37
37
  # Please note that GeoJSON orders coordinate tuples in mathematical order
38
- # as `[longitude, latitude]`!
38
+ # as +[longitude, latitude]+!
39
39
  #
40
40
  # @param file [Pathname, String] GeoJSON file
41
41
  #
@@ -60,7 +60,7 @@ module AIPP
60
60
  # New border object from array of points
61
61
  #
62
62
  # The array must contain coordinate tuples in geographical order as
63
- # `latitude longitude` separated by whitespace and/or commas.
63
+ # +latitude longitude+ separated by whitespace and/or commas.
64
64
  #
65
65
  # @param array [Array<Array<String>>] one or more arrays of coordinate pairs
66
66
  #
data/lib/aipp/debugger.rb CHANGED
@@ -22,14 +22,14 @@ module AIPP
22
22
  # @param verbose [Boolean] print verbose info, print unsevere warnings
23
23
  # and re-raise rescued errors
24
24
  # @yield Block the debugger is watching
25
- def with_debugger(**options, &)
26
- DEBUGGER__.instance_variable_set(:@options__, options.merge(counter: 0))
25
+ def with_debugger(&)
26
+ AIPP.cache.debug_counter = 0
27
27
  case
28
- when id = debugger_options[:debug_on_warning]
28
+ when id = AIPP.options.debug_on_warning
29
29
  puts instructions_for(@id == true ? 'warning' : "warning #{id}")
30
30
  DEBUGGER__::start(no_sigint_hook: true, nonstop: true)
31
31
  call_with_rescue(&)
32
- when debugger_options[:debug_on_error]
32
+ when AIPP.options.debug_on_error
33
33
  puts instructions_for('error')
34
34
  DEBUGGER__::start(no_sigint_hook: true, nonstop: true, postmortem: true)
35
35
  call_without_rescue(&)
@@ -39,20 +39,17 @@ module AIPP
39
39
  end
40
40
  end
41
41
 
42
- def self.included(*)
43
- DEBUGGER__.instance_variable_set(:@options__, {})
44
- end
42
+ alias_method :original_warn, :warn
45
43
 
46
44
  # Issue a warning and maybe open a debug session.
47
45
  #
48
46
  # @param message [String] warning message
49
47
  # @param severe [Boolean] whether this problem must be fixed or not
50
- alias_method :original_warn, :warn
51
48
  def warn(message, severe: true)
52
- if severe || debugger_options[:verbose]
53
- debugger_options[:counter] += 1
54
- original_warn "WARNING #{debugger_options[:counter]}: #{message.upcase_first} #{'(unsevere)' unless severe}".red
55
- debugger if debugger_options[:debug_on_warning] == true || debugger_options[:debug_on_warning] == debugger_options[:counter]
49
+ if severe || AIPP.options.verbose
50
+ AIPP.cache.debug_counter += 1
51
+ original_warn "WARNING #{AIPP.cache.debug_counter}: #{message.upcase_first} #{'(unsevere)' unless severe}".red
52
+ debugger if AIPP.options.debug_on_warning == true || AIPP.options.debug_on_warning == AIPP.cache.debug_counter
56
53
  end
57
54
  end
58
55
 
@@ -61,7 +58,9 @@ module AIPP
61
58
  # @param message [String] informational message
62
59
  # @param color [Symbol] message color
63
60
  def info(message, color: nil)
64
- puts color ? message.upcase_first.send(color) : message.upcase_first
61
+ unless AIPP.options.quiet
62
+ puts color ? message.upcase_first.send(color) : message.upcase_first
63
+ end
65
64
  end
66
65
 
67
66
  # Issue a verbose informational message.
@@ -69,21 +68,17 @@ module AIPP
69
68
  # @param message [String] verbose informational message
70
69
  # @param color [Symbol] message color
71
70
  def verbose_info(message, color: :blue)
72
- info(message, color: color) if debugger_options[:verbose]
71
+ info(message, color: color) if AIPP.options.verbose
73
72
  end
74
73
 
75
74
  private
76
75
 
77
- def debugger_options
78
- DEBUGGER__.instance_variable_get(:@options__)
79
- end
80
-
81
76
  def call_with_rescue(&block)
82
77
  block.call
83
78
  rescue => error
84
79
  message = error.respond_to?(:original_message) ? error.original_message : error.message
85
80
  puts "ERROR: #{message}".magenta
86
- raise if debugger_options[:verbose]
81
+ raise if AIPP.options.verbose
87
82
  end
88
83
 
89
84
  def call_without_rescue(&block)
@@ -0,0 +1,57 @@
1
+ module AIPP
2
+ class Downloader
3
+
4
+ # Local file
5
+ class File
6
+ def initialize(archive: nil, file:, type: nil)
7
+ @archive = Pathname(archive) if archive
8
+ @file, @type = Pathname(file), type&.to_s
9
+ end
10
+
11
+ def fetch_to(path)
12
+ path.join(fetched_file).tap do |target|
13
+ if @archive
14
+ fail NotFoundError unless @archive.exist?
15
+ extract(@file, from: @archive, as: target)
16
+ else
17
+ fail NotFoundError unless @file.exist?
18
+ FileUtils.cp(@file, target)
19
+ end
20
+ end
21
+ self
22
+ end
23
+
24
+ def fetched_file
25
+ [name, type].join('.')
26
+ end
27
+
28
+ private
29
+
30
+ def name
31
+ @file.basename(@file.extname).to_s
32
+ end
33
+
34
+ def type
35
+ @type || @file.extname[1..] || fail("type must be declared")
36
+ end
37
+
38
+ def extract(file, from:, as:)
39
+ if respond_to?(extractor = 'un' + from.extname[1..], true)
40
+ send(extractor, file, from: from, as: as) or fail NotFoundError
41
+ else
42
+ fail "archive type not recognized"
43
+ end
44
+ end
45
+
46
+ # @return [Boolean] whether a file was extracted
47
+ def unzip(file, from:, as:)
48
+ Zip::File.open(from).inject(nil) do |_, entry|
49
+ if file.to_s == entry.name
50
+ break entry.extract(as)
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ end
57
+ end
@@ -0,0 +1,29 @@
1
+ module AIPP
2
+ class Downloader
3
+
4
+ # Remote file via HTTP
5
+ class GraphQL < File
6
+ def initialize(client:, query:, variables:)
7
+ @client, @query, @variables = client, query, variables
8
+ end
9
+
10
+ def fetch_to(path)
11
+ @client.query(@query, variables: @variables).tap do |result|
12
+ ::File.write(path.join(fetched_file), result.data.to_h.to_json)
13
+ end
14
+ self
15
+ end
16
+
17
+ private
18
+
19
+ def name
20
+ [@client, @query, @variables].map(&:to_s).join('|').to_digest
21
+ end
22
+
23
+ def type
24
+ :json
25
+ end
26
+ end
27
+
28
+ end
29
+ end
@@ -0,0 +1,48 @@
1
+ module AIPP
2
+ class Downloader
3
+
4
+ # Remote file via HTTP
5
+ class HTTP < File
6
+ ARCHIVE_MIME_TYPES = {
7
+ 'application/zip' => :zip
8
+ }.freeze
9
+
10
+ def initialize(archive: nil, file:, type: nil, headers: {})
11
+ @archive = URI(archive) if archive
12
+ @file, @type, @headers = URI(file), type&.to_s, headers
13
+ @digest = (archive || file).to_digest
14
+ end
15
+
16
+ # @param path [Pathname] directory where to write the fetched file
17
+ # @return [File] fetched file
18
+ def fetch_to(path)
19
+ response = Excon.get((@archive || @file).to_s, headers: @headers)
20
+ fail NotFoundError if response.status == 404
21
+ mime_type = ARCHIVE_MIME_TYPES.fetch(response.headers['Content-Type'], :dat)
22
+ downloaded_file = path.join([@digest, mime_type].join('.'))
23
+ ::File.write(downloaded_file, response.body)
24
+ path.join(fetched_file).tap do |target|
25
+ if @archive
26
+ extract(@file, from: downloaded_file, as: target)
27
+ ::File.delete(downloaded_file)
28
+ else
29
+ ::File.rename(downloaded_file, target)
30
+ end
31
+ end
32
+ self
33
+ end
34
+
35
+ private
36
+
37
+ def name
38
+ path = Pathname(@file.path)
39
+ path.basename(path.extname).to_s.blank_to_nil || @digest
40
+ end
41
+
42
+ def type
43
+ @type || Pathname(@file.path).extname[1..].blank_to_nil || fail("type must be declared")
44
+ end
45
+ end
46
+
47
+ end
48
+ end
@@ -4,28 +4,53 @@ module AIPP
4
4
  #
5
5
  # The downloader operates in the +storage+ directory where it creates two
6
6
  # subdirectories "sources" and "work". The initializer looks for the +source+
7
- # archive in "sources" and (if found) unzips its contents into "work". When
7
+ # archive in "sources" and (if found) unpacks its contents into "work". When
8
8
  # reading 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 the +source+ archive.
9
+ # (if not found or the clean option is set) downloads it from +origin+.
10
+ # Finally, the contents of "work" are packed back into the +source+ archive.
11
+ #
12
+ # Origins are defined as instances of downloader origin objects:
13
+ #
14
+ # * {AIXM::Downloader::File} – local file or archive
15
+ # * {AIXM::Downloader::HTTP} – remote file or archive via HTTP
16
+ # * {AIXM::Downloader::GraphQL} – GraphQL query
17
+ #
18
+ # The following archives are recognized:
19
+ #
20
+ # [.zip] ZIP archive
21
+ #
22
+ # The following file types are recognised:
23
+ #
24
+ # [.ofmx] Parsed by Nokogiri returning an instance of {Nokogiri::XML::Document}[https://www.rubydoc.info/gems/nokogiri/Nokogiri/XML/Document]
25
+ # [.xml] Parsed by Nokogiri returning an instance of {Nokogiri::XML::Document}[https://www.rubydoc.info/gems/nokogiri/Nokogiri/XML/Document]
26
+ # [.html] Parsed by Nokogiri returning an instance of {Nokogiri::HTML5::Document}[https://www.rubydoc.info/gems/nokogiri/Nokogiri/HTML5/Document]
27
+ # [.pdf] Converted to text – see {AIPP::PDF}
28
+ # [.json] Deserialized JSON e.g. as response to a GraphQL query
29
+ # [.xlsx] Parsed by Roo returning an instance of {Roo::Excelx}[https://www.rubydoc.info/gems/roo/Roo/Excelx]
30
+ # [.ods] Parsed by Roo returning an instance of {Roo::OpenOffice}[https://www.rubydoc.info/gems/roo/Roo/OpenOffice]
31
+ # [.csv] Parsed by Roo returning an instance of {Roo::CSV}[https://www.rubydoc.info/gems/roo/Roo/CSV]
32
+ # [.txt] Instance of +String+
12
33
  #
13
34
  # @example
14
- # AIPP::Downloader.new(storage: options[:storage], source: "2018-11-08") do |downloader|
35
+ # AIPP::Downloader.new(storage: AIPP.options.storage, source: "2018-11-08") do |downloader|
15
36
  # html = downloader.read(
16
37
  # 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'
38
+ # origin: AIPP::Downloader::HTTP.new(
39
+ # file: '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'
40
+ # )
18
41
  # )
19
42
  # pdf = downloader.read(
20
43
  # 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'
44
+ # origin: AIPP::Downloader::HTTP.new(
45
+ # file: 'https://www.sia.aviation-civile.gouv.fr/dvd/eAIP_08_NOV_2018/Atlas-VAC/PDF_AIPparSSection/VAC/AD/AD-2.LFMV.pdf'
46
+ # )
22
47
  # )
23
48
  # end
24
49
  class Downloader
25
- extend Forwardable
26
50
  include AIPP::Debugger
27
51
 
28
- # Error when URL results in "404 Not Found" HTTP status
52
+ # Error raised when any kind of downloader fails to find the resource e.g.
53
+ # because the local file does not exist or the remote file is unavailable.
29
54
  class NotFoundError < StandardError; end
30
55
 
31
56
  # @return [Pathname] directory to operate within
@@ -44,9 +69,15 @@ module AIPP
44
69
  fail(ArgumentError, 'bad storage directory') unless Dir.exist? storage
45
70
  @source_file = sources_path.join("#{@source}.zip")
46
71
  prepare
47
- unzip if @source_file.exist?
72
+ if @source_file.exist?
73
+ if AIPP.options.clean
74
+ @source_file.delete
75
+ else
76
+ unpack
77
+ end
78
+ end
48
79
  yield self
49
- zip
80
+ pack
50
81
  ensure
51
82
  teardown
52
83
  end
@@ -59,17 +90,14 @@ module AIPP
59
90
  # Download and read +document+
60
91
  #
61
92
  # @param document [String] document to read (without extension)
62
- # @param url [String] URL to download the document from
63
- # @param type [Symbol, nil] document type: +nil+ (default) to derive it from
64
- # the URL, :html, :pdf, :xlsx, :ods or :csv
65
- # @return [Nokogiri::HTML5::Document, AIPP::PDF, Roo::Spreadsheet]
66
- def read(document:, url:, type: nil)
67
- type ||= Pathname(URI(url).path).extname[1..-1].to_sym
68
- file = work_path.join([document, type].join('.'))
93
+ # @param origin [AIPP::Downloader::File, AIPP::Downloader::HTTP,
94
+ # AIPP::Downloader::GraphQL] origin to download the document from
95
+ # @return [Object]
96
+ def read(document:, origin:)
97
+ file = work_path.join(origin.fetched_file)
69
98
  unless file.exist?
70
99
  verbose_info "downloading #{document}"
71
- uri = URI.open(url)
72
- IO.copy_stream(uri, file)
100
+ origin.fetch_to(work_path)
73
101
  end
74
102
  convert file
75
103
  end
@@ -97,13 +125,11 @@ module AIPP
97
125
  end
98
126
  end
99
127
 
100
- def unzip
101
- Zip::File.open(source_file).each do |entry|
102
- entry.extract(work_path.join(entry.name))
103
- end
128
+ def unpack
129
+ extract(source_file) or fail
104
130
  end
105
131
 
106
- def zip
132
+ def pack
107
133
  backup_file = source_file.sub(/$/, '.old') if source_file.exist?
108
134
  source_file.rename(backup_file) if backup_file
109
135
  Zip::File.open(source_file, Zip::File::CREATE) do |zip|
@@ -114,15 +140,38 @@ module AIPP
114
140
  backup_file&.delete
115
141
  end
116
142
 
143
+ def extract(archive, only_entry: nil)
144
+ case archive.extname
145
+ when '.zip' then unzip(archive, only_entry: only_entry)
146
+ else fail(ArgumentError, "unrecognized archive type")
147
+ end
148
+ end
149
+
150
+ # @return [Boolean] whether at least one file was extracted
151
+ def unzip(archive, only_entry:)
152
+ Zip::File.open(archive).inject(false) do |_, entry|
153
+ case
154
+ when only_entry && only_entry == entry.name
155
+ break !!entry.extract(work_path.join(Pathname(entry.name).basename))
156
+ when !only_entry
157
+ !!entry.extract(work_path.join(entry.name))
158
+ else
159
+ false
160
+ end
161
+ end
162
+ end
163
+
117
164
  def convert(file)
118
165
  case file.extname
119
- when '.xml' then Nokogiri.XML(File.open(file))
120
- when '.html' then Nokogiri.HTML5(File.open(file))
166
+ when '.xml', '.ofmx' then Nokogiri.XML(::File.open(file), &:noblanks)
167
+ when '.html' then Nokogiri.HTML5(::File.open(file))
168
+ when '.json' then JSON.load_file(file)
121
169
  when '.pdf' then AIPP::PDF.new(file)
122
170
  when '.xlsx', '.ods', '.csv' then Roo::Spreadsheet.open(file.to_s)
123
- else
124
- fail(ArgumentError, "invalid document type")
171
+ when '.txt' then ::File.read(file)
172
+ else fail(ArgumentError, "unrecognized file type")
125
173
  end
126
174
  end
175
+
127
176
  end
128
177
  end
@@ -0,0 +1,88 @@
1
+ module AIPP
2
+
3
+ # Runtime environment
4
+ #
5
+ # Runtime environment objects inherit from OpenStruct but feature some
6
+ # extensions:
7
+ #
8
+ # * Use +replace+ to replace the current key/value table with the given hash.
9
+ # * Use +merge+ to merge the given hash into the current key/value hash.
10
+ # * When reading a value using square brackets, the key is implicitly
11
+ # converted to Symbol.
12
+ #
13
+ # @example
14
+ # AIPP.config # => AIPP::Environment::Config
15
+ # AIPP.config.foo # => nil
16
+ # AIPP.config.foo = :bar # => :bar
17
+ # AIPP.config.replace(fii: :bir)
18
+ # AIPP.config.foo # => nil
19
+ # AIPP.config.fii # => :bir
20
+ # AIPP.config.read! # method defined on Config class
21
+ class Environment
22
+ include Singleton
23
+
24
+ # Cache to store transient objects
25
+ class Cache < OpenStruct
26
+ def [](key)
27
+ super(key.to_s.to_sym)
28
+ end
29
+
30
+ def replace(hash)
31
+ @table = hash
32
+ end
33
+
34
+ def merge(hash)
35
+ @table.merge! hash
36
+ end
37
+ end
38
+
39
+ # Borders read from directory containing GeoJSON files
40
+ class Borders < Cache
41
+ def read!(dir)
42
+ @table.clear
43
+ dir.glob('*.geojson').each do |file|
44
+ @table[file.basename('.geojson').to_s.to_sym] = AIPP::Border.from_file(file)
45
+ end
46
+ end
47
+ end
48
+
49
+ # Fixtures read from directory containing YAML files
50
+ class Fixtures < Cache
51
+ def read!(dir)
52
+ @table.clear
53
+ dir.glob('*.yml').each do |file|
54
+ @table[file.basename('.yml').to_s.to_sym] = YAML.load_file(file)
55
+ end
56
+ end
57
+ end
58
+
59
+ # Options set via the CLI executable
60
+ class Options < Cache
61
+ end
62
+
63
+ # Config read from config.yml file
64
+ class Config < Cache
65
+ def read!(file)
66
+ @table = YAML.safe_load_file(file, symbolize_names: true, fallback: {}) if file.exist?
67
+ @table[:namespace] ||= SecureRandom.uuid
68
+ end
69
+
70
+ def write!(file)
71
+ File.write(file, @table.transform_keys(&:to_s).to_yaml)
72
+ end
73
+ end
74
+
75
+ def initialize
76
+ [Cache, Borders, Fixtures, Options, Config].each do |klass|
77
+ attribute = klass.to_s.split('::').last.downcase
78
+ instance_variable_set("@#{attribute}", klass.new)
79
+ AIPP.define_singleton_method(attribute) do
80
+ Environment.instance.instance_variable_get "@#{attribute}"
81
+ end
82
+ end
83
+ end
84
+
85
+ end
86
+ end
87
+
88
+ AIPP::Environment.instance