├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── .gitignore ├── .issuetracker ├── .ruby-version ├── .yardopts ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── aipp.gemspec ├── bin ├── console └── setup ├── certs └── svoop.pem ├── checksums ├── aipp-0.1.0.gem.sha512 ├── aipp-0.1.1.gem.sha512 ├── aipp-0.1.2.gem.sha512 ├── aipp-0.1.3.gem.sha512 ├── aipp-0.2.0.gem.sha512 ├── aipp-0.2.1.gem.sha512 ├── aipp-0.2.2.gem.sha512 ├── aipp-0.2.3.gem.sha512 ├── aipp-0.2.4.gem.sha512 ├── aipp-0.2.5.gem.sha512 ├── aipp-0.2.6.gem.sha512 ├── aipp-1.0.0.gem.sha512 ├── aipp-2.0.0.gem.sha512 ├── aipp-2.0.0.pre10.gem.sha512 ├── aipp-2.0.0.pre11.gem.sha512 ├── aipp-2.0.0.pre12.gem.sha512 ├── aipp-2.0.0.pre13.gem.sha512 ├── aipp-2.0.0.pre14.gem.sha512 ├── aipp-2.0.0.pre15.gem.sha512 ├── aipp-2.0.0.pre16.gem.sha512 ├── aipp-2.0.0.pre7.gem.sha512 ├── aipp-2.0.0.pre8.gem.sha512 ├── aipp-2.0.0.pre9.gem.sha512 ├── aipp-2.0.1.gem.sha512 ├── aipp-2.0.2.gem.sha512 ├── aipp-2.0.3.gem.sha512 ├── aipp-2.1.0.gem.sha512 ├── aipp-2.1.1.gem.sha512 ├── aipp-2.1.10.gem.sha512 ├── aipp-2.1.11.gem.sha512 ├── aipp-2.1.2.gem.sha512 ├── aipp-2.1.3.gem.sha512 ├── aipp-2.1.4.gem.sha512 ├── aipp-2.1.5.gem.sha512 ├── aipp-2.1.6.gem.sha512 ├── aipp-2.1.7.gem.sha512 ├── aipp-2.1.8.gem.sha512 ├── aipp-2.1.9.gem.sha512 ├── aipp-2.1.9.pre1.gem.sha512 ├── aipp-2.2.0.gem.sha512 ├── aipp-2.2.1.gem.sha512 ├── aipp-2.2.2.gem.sha512 ├── aipp-2.3.0.gem.sha512 └── aipp-2.3.1.gem.sha512 ├── exe ├── aip2aixm └── aip2ofmx ├── gems.rb ├── guardfile.rb ├── lib ├── aipp.rb ├── aipp │ ├── border.rb │ ├── debugger.rb │ ├── downloader.rb │ ├── downloader │ │ ├── file.rb │ │ ├── graphql.rb │ │ └── http.rb │ ├── environment.rb │ ├── executable.rb │ ├── parser.rb │ ├── patcher.rb │ ├── pdf.rb │ ├── regions │ │ ├── LF │ │ │ ├── README.md │ │ │ ├── aip │ │ │ │ ├── aerodromes.rb │ │ │ │ ├── d_p_r_airspaces.rb │ │ │ │ ├── dangerous_activities.rb │ │ │ │ ├── designated_points.rb │ │ │ │ ├── helipads.rb │ │ │ │ ├── navigational_aids.rb │ │ │ │ ├── obstacles.rb │ │ │ │ ├── serviced_airspaces.rb │ │ │ │ └── services.rb │ │ │ ├── borders │ │ │ │ ├── france_atlantic_coast.geojson │ │ │ │ ├── france_atlantic_territorial_sea.geojson │ │ │ │ ├── france_ecrins_national_park.geojson │ │ │ │ └── france_mediterranean_coast.geojson │ │ │ ├── fixtures │ │ │ │ └── aerodromes.yml │ │ │ └── helpers │ │ │ │ ├── base.rb │ │ │ │ ├── surface.rb │ │ │ │ └── usage_limitation.rb │ │ └── LS │ │ │ ├── README.md │ │ │ ├── helpers │ │ │ └── base.rb │ │ │ ├── notam │ │ │ └── ENR.rb │ │ │ └── shoot │ │ │ └── shooting_grounds.rb │ ├── runner.rb │ ├── scopes │ │ ├── aip │ │ │ ├── README.md │ │ │ ├── executable.rb │ │ │ ├── parser.rb │ │ │ └── runner.rb │ │ ├── notam │ │ │ ├── README.md │ │ │ ├── executable.rb │ │ │ ├── parser.rb │ │ │ └── runner.rb │ │ └── shoot │ │ │ ├── README.md │ │ │ ├── executable.rb │ │ │ ├── parser.rb │ │ │ └── runner.rb │ ├── t_hash.rb │ └── version.rb └── core_ext │ ├── array.rb │ ├── enumerable.rb │ ├── hash.rb │ ├── integer.rb │ ├── nil_class.rb │ ├── nokogiri.rb │ └── string.rb ├── rakefile.rb └── spec ├── fixtures ├── borders │ └── oggystan.geojson ├── config │ └── config.yml ├── downloader │ ├── .~lock.new.xml# │ ├── archive.zip │ ├── new.csv │ ├── new.html │ ├── new.json │ ├── new.ods │ ├── new.pdf │ ├── new.txt │ ├── new.xlsx │ ├── new.xml │ └── source.zip ├── fixtures │ └── aerodromes.yml └── pdf │ ├── document.pdf │ └── document.pdf.json ├── lib ├── aipp │ ├── border_spec.rb │ ├── downloader │ │ ├── file_spec.rb │ │ ├── graphql_spec.rb │ │ └── http_spec.rb │ ├── downloader_spec.rb │ ├── environment_spec.rb │ ├── patcher_spec.rb │ ├── pdf_spec.rb │ ├── t_hash_spec.rb │ └── version_spec.rb └── core_ext │ ├── array_spec.rb │ ├── enumberable_spec.rb │ ├── hash_spec.rb │ ├── integer_spec.rb │ ├── nil_class_spec.rb │ ├── nokogiri_spec.rb │ └── string_spec.rb └── spec_helper.rb /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: svoop 2 | custom: "https://donorbox.org/bitcetera" 3 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | os: [ubuntu-latest] 9 | ruby: ['3.1', '3.2', '3.3', '3.4'] 10 | runs-on: ${{ matrix.os }} 11 | name: test (Ruby ${{ matrix.ruby }} on ${{ matrix.os }}) 12 | steps: 13 | - name: Check out 14 | uses: actions/checkout@v2 15 | - name: Set up Ruby ${{ matrix.ruby }} 16 | uses: ruby/setup-ruby@v1 17 | with: 18 | ruby-version: ${{ matrix.ruby }} 19 | bundler-cache: true 20 | - name: Run tests 21 | run: SPEC_SCOPE=all bundle exec rake 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | 4 | # direnv 5 | .direnv 6 | .envrc 7 | 8 | # Editors 9 | .nova 10 | .vscode 11 | *.sublime* 12 | 13 | # Ruby 14 | gems.locked 15 | pkg/* 16 | *.gem 17 | .bundle 18 | .yardoc 19 | 20 | # Data 21 | /crossload 22 | /*.aixm 23 | /*.ofmx 24 | /*.xml 25 | /*.html 26 | /*.pdf 27 | /*.xlsx 28 | /*.ods 29 | /*.csv 30 | /*.zip 31 | -------------------------------------------------------------------------------- /.issuetracker: -------------------------------------------------------------------------------- 1 | # Integration with Issue Tracker 2 | # 3 | # (note that '\' need to be escaped). 4 | 5 | [issuetracker "GitHub"] 6 | regex = "(?:#|GH-)(\\d+)" 7 | url = "https://github.com/svoop/aipp/issues/$1" 8 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4 2 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --no-private 2 | lib/**/*.rb - 3 | README.md CHANGELOG.md LICENSE.txt 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Main 2 | 3 | Nothing so far 4 | 5 | ## 2.3.1 6 | 7 | #### Changes 8 | * Update Ruby to 3.4 9 | 10 | ## 2.3.0 11 | 12 | #### Additions 13 | * Include the date and time of the last upstream update for LS NOTAM and SHOOT 14 | 15 | ## 2.2.2 16 | 17 | #### Fix 18 | * Re-add accidentally removed RMZ and TMZ 19 | 20 | ## 2.2.1 21 | 22 | #### Changes 23 | * Adopt latest update of OFMX to accommodate new airspace types RMZ, TMZ and DRA 24 | 25 | ## 2.2.0 26 | 27 | #### Changes 28 | * Bump aixm gem to >=1.5.0 in order to bump to OFMX schema 0.2 29 | 30 | ## 2.1.11 31 | 32 | #### Changes 33 | * Adapt lookup for DPR zones which no longer contain dash in LS 34 | 35 | ## 2.1.10 36 | 37 | #### Additions 38 | * Show the gem version used in order to verify deployments 39 | 40 | #### Changes 41 | * Make dash between LS and DPR zone optional in NOTAM 42 | 43 | ## 2.1.9 44 | 45 | #### Additions 46 | * Support for Ruby 3.3 47 | 48 | ## 2.1.8 49 | 50 | #### Additions 51 | * Include contact name to remarks in LS SHOOT 52 | 53 | ## 2.1.7 54 | 55 | #### Fixes 56 | * Workaround for temporary upstream CSV quoting error 57 | 58 | ## 2.1.6 59 | 60 | #### Fixes 61 | * Update NOTAM gem 62 | 63 | ## 2.1.5 64 | 65 | #### Fixes 66 | * Use UTC date for DABS cross check 67 | * Update NOTAM gem 68 | 69 | ## 2.1.4 70 | 71 | #### Fixes 72 | * Update NOTAM gem 73 | 74 | ## 2.1.3 75 | 76 | #### Changes 77 | * Use uncompressed OFMX download for LS NOTAM 78 | 79 | ## 2.1.2 80 | 81 | #### Changes 82 | * Improve timesheet calculation for shooting grounds in LS 83 | * Improve safety margins for shooting grounds in LS 84 | * Switch to unzipped OFMX for now due to issues upstream 85 | 86 | ## 2.1.1 87 | 88 | #### Additions 89 | * Improve help when no scope is given 90 | * Add `-0` to write empty OFMX in case of no upstream data 91 | 92 | ## 2.1.0 93 | 94 | #### Breaking Changes 95 | * Unify all executables into `aip2aixm` and `aip2aixm` respectively 96 | 97 | #### Additions 98 | * Support for shooting grounds in LS 99 | 100 | ## 2.0.3 101 | 102 | #### Breaking Changes 103 | * THR/DTHR fixes from AIXM gem 104 | * Improve filters of delegated airspaces (region LF) 105 | 106 | ## 2.0.2 107 | 108 | #### Additions 109 | * Support for Ruby 3.2 110 | 111 | #### Fixes 112 | * Improve hack to fix braindead years on D-items 113 | 114 | ## 2.0.1 115 | 116 | #### Fixes 117 | * Fix ineffective rescue when parsing a NOTAM fails in force mode 118 | * Hack to fix braindead years on D-items 119 | 120 | ## 2.0.0 121 | 122 | #### Additions 123 | * Region LS NOTAM 124 | * CLI option to set a custom output file 125 | * `--quiet` option 126 | 127 | #### Breaking Changes 128 | * Drop support for Ruby 3.0 129 | * Rename `url_for` to `origin_for` and introduce origin structures which allow 130 | for more complex download scenarios such as HTTPS with session or GraphQL. 131 | * Overhaul file/class layout to accommodate other than AIP, implement NOTAM. 132 | * Cache, borders, fixtures, options and config are now dedicated objects 133 | accessible on `AIPP`. 134 | * Patches are no longer passed the parser instance. 135 | 136 | ## 1.0.0 137 | 138 | #### Breaking Changes 139 | * Switch from individual AIP HTML files to the comprehensive AIP XML 140 | database dump for the LF region reference implementation. 141 | * Drop the mandatory `URL` helper in favour of a mandatory `url_for` method. 142 | * Renamed default git branch to `main` 143 | * Improve calculation of short feature hash in manifest in order to include 144 | e.g. geometries of airspaces. 145 | 146 | #### Changes 147 | * Switch from `pry` to `debug` 148 | 149 | #### Additions 150 | * Unsevere warnings 151 | * Support for .xlsx, .ods and .csv files 152 | 153 | ## 0.2.6 154 | 155 | #### Additions 156 | * Detect duplicate features 157 | 158 | #### Changes 159 | * Require Ruby 2.7 160 | 161 | ## 0.2.5 162 | 163 | #### Additions 164 | * LF/AD-2>2.19 (AD navigational aids relevant to VFR) 165 | * Write build and manifest to `~/.aipp//builds` 166 | 167 | #### Changes 168 | * Renamed `~/.aipp//archive` to `~/.aipp//sources` 169 | 170 | ## 0.2.4 171 | 172 | #### Additions 173 | * LF/AD-3.1 174 | * Automatically load fixtures for patches 175 | 176 | ## 0.2.3 177 | 178 | #### Additons 179 | * Borders defined as GeoJSON (used by LF/ENR-2.1) 180 | * LF/AD-5.5 181 | 182 | #### Breaking Changes 183 | * Renamed `AIPP::AIP#write` method to `AIPP::AIP#add` 184 | 185 | ## 0.2.2 186 | 187 | #### Changes 188 | * Helper modules instead of one monolythic `helper.rb` 189 | 190 | #### Additions 191 | * LF/AD-1.3 192 | * LF/AD-1.6 193 | * LF/AD-2 194 | 195 | ## 0.2.1 196 | 197 | #### Changes 198 | * Require Ruby 2.6 199 | * Fix broken downloader 200 | 201 | #### Additions 202 | * Support for PDF files 203 | 204 | ## 0.2.0 205 | 206 | #### Changes 207 | * Complete rewrite of the framework in order to allow cross-AIP parsing made necessary due to recent changes in LF AIP. 208 | 209 | #### Additions 210 | * LF/ENR-2.1 211 | * Handling of errors and warnings optimized for parser development 212 | 213 | #### Removals 214 | * LF/AD-1.5 215 | 216 | ## 0.1.3 217 | 218 | #### Changes 219 | * Summary at end of run 220 | 221 | #### Additions 222 | * LF/AD-1.5 223 | * Source file line number evaluation 224 | 225 | ## 0.1.2 226 | 227 | #### Additions: 228 | * LF/ENR-4.3 229 | 230 | ## 0.1.1 231 | 232 | #### Additions: 233 | * LF/ENR-4.1 234 | * Helper modules 235 | 236 | ## 0.1.0 237 | 238 | #### Initial Implementation 239 | * Require Ruby 2.5 240 | * Framework and aip2aixm executable 241 | * LF/ENR-5.1 242 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Sven Schwyn 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /aipp.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/aipp/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'aipp' 7 | spec.version = AIPP::VERSION 8 | spec.summary = 'Parser for aeronautical information publications' 9 | spec.description = <<~END 10 | Parse public AIP (Aeronautical Information Publication) and convert the data 11 | to either AIXM (Aeronautical Information Exchange Model) or OFMX (Open 12 | FlightMaps eXchange). 13 | END 14 | spec.authors = ['Sven Schwyn'] 15 | spec.email = ['ruby@bitcetera.com'] 16 | spec.homepage = 'https://github.com/svoop/aipp' 17 | spec.license = 'MIT' 18 | 19 | spec.metadata = { 20 | 'homepage_uri' => spec.homepage, 21 | 'changelog_uri' => 'https://github.com/svoop/aipp/blob/main/CHANGELOG.md', 22 | 'source_code_uri' => 'https://github.com/svoop/aipp', 23 | 'documentation_uri' => 'https://www.rubydoc.info/gems/aipp', 24 | 'bug_tracker_uri' => 'https://github.com/svoop/aipp/issues' 25 | } 26 | 27 | spec.files = Dir['lib/**/*'] 28 | spec.require_paths = %w(lib) 29 | spec.bindir = 'exe' 30 | spec.executables = %w(aip2aixm aip2ofmx) 31 | 32 | spec.cert_chain = ["certs/svoop.pem"] 33 | spec.signing_key = File.expand_path(ENV['GEM_SIGNING_KEY']) if ENV['GEM_SIGNING_KEY'] 34 | 35 | spec.extra_rdoc_files = Dir['README.md', 'CHANGELOG.md', 'LICENSE.txt'] 36 | spec.rdoc_options += [ 37 | '--title', 'AIP Parser and Converter', 38 | '--main', 'README.md', 39 | '--line-numbers', 40 | '--inline-source', 41 | '--quiet' 42 | ] 43 | 44 | spec.required_ruby_version = '>= 3.1.0' 45 | 46 | spec.add_runtime_dependency 'airac', '~> 1.0', '>= 1.0.3' 47 | spec.add_runtime_dependency 'aixm', '~> 1', '>= 1.5.3' 48 | spec.add_runtime_dependency 'notam', '~> 1', '>=1.1.5' 49 | spec.add_runtime_dependency 'ostruct', '~> 0' 50 | spec.add_runtime_dependency 'activesupport', '~> 7' 51 | spec.add_runtime_dependency 'excon', '~> 0' 52 | spec.add_runtime_dependency 'graphql', '~> 2' 53 | spec.add_runtime_dependency 'graphql-client', '~> 0', '>= 0.19.0' 54 | spec.add_runtime_dependency 'nokogiri', '~> 1', '>= 1.12.0' 55 | spec.add_runtime_dependency 'roo', '~> 2' 56 | spec.add_runtime_dependency 'pdf-reader', '~> 2' 57 | spec.add_runtime_dependency 'csv', '~> 3' 58 | spec.add_runtime_dependency 'json', '~> 2' 59 | spec.add_runtime_dependency 'rubyzip', '~> 2' 60 | spec.add_runtime_dependency 'colorize', '~> 0' 61 | spec.add_runtime_dependency 'debug', '>= 1.0.0' 62 | 63 | spec.add_development_dependency 'rake' 64 | spec.add_development_dependency 'minitest' 65 | spec.add_development_dependency 'minitest-flash' 66 | spec.add_development_dependency 'minitest-focus' 67 | spec.add_development_dependency 'guard' 68 | spec.add_development_dependency 'guard-minitest' 69 | spec.add_development_dependency 'yard' 70 | end 71 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "aixm" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /certs/svoop.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDODCCAiCgAwIBAgIBATANBgkqhkiG9w0BAQsFADAjMSEwHwYDVQQDDBhydWJ5 3 | L0RDPWJpdGNldGVyYS9EQz1jb20wHhcNMjIxMTA2MTIzNjUwWhcNMjMxMTA2MTIz 4 | NjUwWjAjMSEwHwYDVQQDDBhydWJ5L0RDPWJpdGNldGVyYS9EQz1jb20wggEiMA0G 5 | CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDcLg+IHjXYaUlTSU7R235lQKD8ZhEe 6 | KMhoGlSUonZ/zo1OT3KXcqTCP1iMX743xYs6upEGALCWWwq+nxvlDdnWRjF3AAv7 7 | ikC+Z2BEowjyeCCT/0gvn4ohKcR0JOzzRaIlFUVInlGSAHx2QHZ2N8ntf54lu7nd 8 | L8CiDK8rClsY4JBNGOgH9UC81f+m61UUQuTLxyM2CXfAYkj/sGNTvFRJcNX+nfdC 9 | hM9r2kH1+7wsa8yG7wJ2IkrzNACD8v84oE6qVusN8OLEMUI/NaEPVPbw2LUM149H 10 | PVa0i729A4IhroNnFNmw4wOC93ARNbM1+LW36PLMmKjKudf5Exg8VmDVAgMBAAGj 11 | dzB1MAkGA1UdEwQCMAAwCwYDVR0PBAQDAgSwMB0GA1UdDgQWBBSfK8MtR62mQ6oN 12 | yoX/VKJzFjLSVDAdBgNVHREEFjAUgRJydWJ5QGJpdGNldGVyYS5jb20wHQYDVR0S 13 | BBYwFIEScnVieUBiaXRjZXRlcmEuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQAYG2na 14 | ye8OE2DANQIFM/xDos/E4DaPWCJjX5xvFKNKHMCeQYPeZvLICCwyw2paE7Otwk6p 15 | uvbg2Ks5ykXsbk5i6vxDoeeOLvmxCqI6m+tHb8v7VZtmwRJm8so0eSX0WvTaKnIf 16 | CAn1bVUggczVdNoBXw9WAILKyw9bvh3Ft740XZrR74sd+m2pGwjCaM8hzLvrVbGP 17 | DyYhlBeRWyQKQ0WDIsiTSRhzK8HwSTUWjvPwx7SEdIU/HZgyrk0ETObKPakVu6bH 18 | kAyiRqgxF4dJviwtqI7mZIomWL63+kXLgjOjMe1SHxfIPo/0ji6+r1p4KYa7o41v 19 | fwIwU1MKlFBdsjkd 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /checksums/aipp-0.1.0.gem.sha512: -------------------------------------------------------------------------------- 1 | ad347d17f80482a32728fc35c434d966aae03a291830209f4ce85f813e21759e2a32301c6c3b10ea641514b97eb1afb7f90cdc24b086d2ae329ecc2e20bc29d1 -------------------------------------------------------------------------------- /checksums/aipp-0.1.1.gem.sha512: -------------------------------------------------------------------------------- 1 | 3ba408bc741ff09957fba33bab58557bdcac857f3f1d0750b0a504de8022709aca5e33cc0bc322d375cb74c57222af0778576c725d3fe72b55eade35a8df95a8 -------------------------------------------------------------------------------- /checksums/aipp-0.1.2.gem.sha512: -------------------------------------------------------------------------------- 1 | 70b3917edff845bd5f950b0591b17b90ee6adc83f9ef6a33feea0e8e516b42c593e8c4b379f55af5c530e91b4e312f4af1f7b1a468a12556d5fe782985ecf3db -------------------------------------------------------------------------------- /checksums/aipp-0.1.3.gem.sha512: -------------------------------------------------------------------------------- 1 | 99901d6f5ba0548a3a587b4fe1cda0496cec30370e0fbec0b8ac7db0d3c909355962e41bc2b2247c1c43b13330df6c2b92fa662699be30d4159b51e007641ac5 -------------------------------------------------------------------------------- /checksums/aipp-0.2.0.gem.sha512: -------------------------------------------------------------------------------- 1 | 1575773c0c7b7ad63c9a8139ae805b8b0c39159b01046206dc855e9fcc4ac2b9c0d36da642abcfed07d956357c35b932c10d76587a0becac94be460b328fbccf -------------------------------------------------------------------------------- /checksums/aipp-0.2.1.gem.sha512: -------------------------------------------------------------------------------- 1 | fdc725ec655579bc5c0d9bbae962558277461c9fe37b3555f781e5ff90b9e030988eb0e5f63f5fd0a143c5a8254883b76ecdf7bd86e12cbd2231a9636f045737 -------------------------------------------------------------------------------- /checksums/aipp-0.2.2.gem.sha512: -------------------------------------------------------------------------------- 1 | 6a6f4977556395e2e66e564d65b70c09793c801611cd222da27618b3ae13db4bc7c522f00a55f615e9c746b7a62832de3be4dc21ad1033fd2060e5dae50cbbac -------------------------------------------------------------------------------- /checksums/aipp-0.2.3.gem.sha512: -------------------------------------------------------------------------------- 1 | 33543c6a3a922fae39d362385a7140323b24fc8046c730a72d9226f3d86ace7d51c68d23e012e1c97372176d406e76d8147e49210e829232c4d12485cfec177f -------------------------------------------------------------------------------- /checksums/aipp-0.2.4.gem.sha512: -------------------------------------------------------------------------------- 1 | c59a5fd5a2280959f6394c000ea4bab861345ba3bd548f8ff45b726eb9b02dd7f2aaa7151ba35ea04c941d1656b295fb69bc6e88c50a8888a8c039de8ebfbdfa -------------------------------------------------------------------------------- /checksums/aipp-0.2.5.gem.sha512: -------------------------------------------------------------------------------- 1 | 7847383434cac64800d862e32ffb6d56e8146ef98209b8b14438ca3e997c4ad40de8deb7107343ac1085b5b8ebb09edad09960f5a84114aa782e285cac14aa18 -------------------------------------------------------------------------------- /checksums/aipp-0.2.6.gem.sha512: -------------------------------------------------------------------------------- 1 | fc1cde442e164887c2d9f7446d2e9065c9087e664a8d8d7784009627f586703f65e309a4ae2195f46395d91624e74319766bd362e8db1b5e041938f08add1a50 -------------------------------------------------------------------------------- /checksums/aipp-1.0.0.gem.sha512: -------------------------------------------------------------------------------- 1 | a95578893e62bbbdaae3b69b3e4edba297e6c6b49f986df49127665fc4de91779346f219f1e0deefd23f41bd3f6ed5e4ab4fdd3890620a0bdd232156461b25b5 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.0.0.gem.sha512: -------------------------------------------------------------------------------- 1 | f79af42856b8725fa8acdabf7a7605ad961cd7914f228aa94b022252c04ec9f47110c40615248403214221bb22c77119a9fef2e9ace813127cc9b153f2f0a3b5 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.0.0.pre10.gem.sha512: -------------------------------------------------------------------------------- 1 | f6d56adc99707443c1d7513f572eff9563672d9bd410d2d317225bfddfc61cd7a2312be4949e17e1ce8c60eb70f747160de704cffed7cc89233575d8d47dae54 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.0.0.pre11.gem.sha512: -------------------------------------------------------------------------------- 1 | 3fa6113f7cc0fdd129eb6f903a9c80513a2623e9680eb4531c7a2dae7a71c811c29692f8b5df58eaf4cdc94fd5c21930d0397d57816f62e87f4468803f7795f7 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.0.0.pre12.gem.sha512: -------------------------------------------------------------------------------- 1 | 585f85b1edab00935a40be86a4eb013051fc832f1b08e4c4341501feeb0b5c10c1f984a55808aa46eea635510fbe912e3744fdcbbb7fe34a5215a1f17cf684d8 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.0.0.pre13.gem.sha512: -------------------------------------------------------------------------------- 1 | d9e33098b8f0e97007db3a986456895b59c379ee4fcedeb59663fcf6d9065d1519ed73e20a6a2ddd542b532302c20508b335c312299557e1f6cb922cb878c973 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.0.0.pre14.gem.sha512: -------------------------------------------------------------------------------- 1 | 348400422c37d453e43aa4a2e437324aaca3d1be3cc9c091ae4dee661b3e1150014a2a376fbc09af79f0bf2bfee37df4fae9a3b3a71ebc4660a0fc4ecaf23a49 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.0.0.pre15.gem.sha512: -------------------------------------------------------------------------------- 1 | 49fa0078a9a9323b4fda32c4e164b07774e476dcc4881bb42c4eb3cdd030ed343daa75f9849e69eca06c753c3df7d92558ad333cc8afd12f1a8bdf07dd5dbb9c 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.0.0.pre16.gem.sha512: -------------------------------------------------------------------------------- 1 | 2fa3ec4d1b97167a7e67856edaf796286fc1322a7131d7177928f1c0137cbd391c854f9dd26033a945ec3617e7ac93907fbce013e334b34bc8546016d64880f3 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.0.0.pre7.gem.sha512: -------------------------------------------------------------------------------- 1 | a7962c0de6f2679598c65008d5520d07dacb1df30e7475ea276ce545ea8d8653f9e1d844af7174fdca9d18d2eba11ba96604812135d72ebd198dff9a2621878b 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.0.0.pre8.gem.sha512: -------------------------------------------------------------------------------- 1 | 9010c2a65f949e61d03334aacd3bffb5a1d7afeaef7a3f88c27ea39e2ac8475874186b93466627815eb864077827433365bd833b47cbfb59f95c6e53501280a9 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.0.0.pre9.gem.sha512: -------------------------------------------------------------------------------- 1 | 7090a20a697e250add708f54642364d67f64715808a8850254e139596eacca96fe263ac9b5554f5a24f1cb705934cb3f0cd6802760425424f1f18c7d57e1b0e5 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.0.1.gem.sha512: -------------------------------------------------------------------------------- 1 | 0f08c572ec16220d240f41cb94cd66b06e57100d1bdb40d45a5968ff62d43d5be89d5e2217dd0aff678918df770b6391e7904495e01333c09aa05b665f54b8fb 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.0.2.gem.sha512: -------------------------------------------------------------------------------- 1 | e5f9024fb5b9b7b47f77991c995aefae2bf052b6a4423b2e0efacd9a230ea265e30d9db64ed756569454d4f4238651c425a9e535c3dfe9c730cbe59bb8f6039f 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.0.3.gem.sha512: -------------------------------------------------------------------------------- 1 | 7354c9be9334318c0661a6a27a207ea2204dc70e2042b1813208934f37f83fd4decd0361d193bcbb63d9f060f1235e58b0c5ea2eae53f6088728a1982ab95243 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.1.0.gem.sha512: -------------------------------------------------------------------------------- 1 | 10c76ea1a72621aa852b8748058fe812f20e5eecda86b87b0f76c44390b2f027f903316e66af53946b920f3c3e35a183d54c105810bd92c11c3fe054a8016d22 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.1.1.gem.sha512: -------------------------------------------------------------------------------- 1 | c0bf977638b42f8551f2bad3d2bb85335fedbca2c5cfe69d95f4ae802aebcd629db62cdc95a5fc3cd7ac231227f943f5177752299a0f7b4fccc5fb0a529da990 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.1.10.gem.sha512: -------------------------------------------------------------------------------- 1 | f9b8a23772075f9a33b9b3e7a7abc2a666a45453202717527167f8166c0becfc1fb95ac556530bd03fbfd382099b7d001ffba7692104f5d2b423b67315abda63 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.1.11.gem.sha512: -------------------------------------------------------------------------------- 1 | 9e9f905547d02d3f746eeb251839b0866cc0adb12cb5a05c3e2ba07f04f9a614743b4c63e22ad75c5f11e5230523210d86dc115e01dfec4fbd3bcb2ed5d41729 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.1.2.gem.sha512: -------------------------------------------------------------------------------- 1 | c11f2816acd426b9974b932abb57d49015730100d305ed3bc03733287233ded5aa35392a90ae73e2398e7f2e974500d22819e92c3eb84e11f682554d71c8fbda 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.1.3.gem.sha512: -------------------------------------------------------------------------------- 1 | c0c8433164a61ea729b3d6d83918c34ff6370639450f7f643b2e064b1dbe53ac073c84e7896c759103f79019aa747c39ee117b0898e3795742d756a3764e8798 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.1.4.gem.sha512: -------------------------------------------------------------------------------- 1 | cf7b1cc86c43a64b786a9c6bc56d79962ec055c6b9b469519d465dd87270a4ea6d44f260f1e35fdd2ed0bc0008032e2989daa8f0c629ac4ffaf92f30e4b5164c 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.1.5.gem.sha512: -------------------------------------------------------------------------------- 1 | 2c1aae35cced1bef8cbc8fc6aaa70db717b7b9fc036e907f69a8189860dbcb5a723d623e5963c62f1897310fc06403b1ea228e715411579344634a473785bb96 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.1.6.gem.sha512: -------------------------------------------------------------------------------- 1 | 8e65fb8791a46d6a84109c5b8ec1d4db0b418341202f5334312d42485720fa50cfbfd2027a54a5d67625f89686e2287691acd72688dc53fabe55869fde4e6971 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.1.7.gem.sha512: -------------------------------------------------------------------------------- 1 | 6e6dc62d8dc5d3c6794992b60f13ba62ba8927bc21ba66699cbdf69c66007e337e65c1fb1aaa31ea431b7736501df315ad32965805b0811b8704131376586631 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.1.8.gem.sha512: -------------------------------------------------------------------------------- 1 | d215b145e24ea2bfaf2f2bfb5f26fe38f35febec3b92e13efdb2a85f00944665d4ea2e02b685bc3bb4ff0d41fce5b1a0774c7a773a8eb3f8cded5bab434f7e07 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.1.9.gem.sha512: -------------------------------------------------------------------------------- 1 | ec887fecafb357a872deb5d788b645e015f0adb6abb7371627dd5f5a0c0d6289fedc2c72a65017ffda128aaff5d83a9a5683ee73f66d12e5bca59f44b273f8a8 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.1.9.pre1.gem.sha512: -------------------------------------------------------------------------------- 1 | 08cab5473f24587b7e3771665e5f12d02e3ca78a89fe1af8965839bea5d49394c38fb7e1c5fb4f442c5979ac72988163394dc4b18ddd9f6a134aed35b0669c41 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.2.0.gem.sha512: -------------------------------------------------------------------------------- 1 | 86c67730e89454595c229ae729ed32cf3f7432af72e00475e06ae71e8abec67bc8ef8a276f4af11c97d9d91b6874546e91c0887accaef65024cb95af30c07a00 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.2.1.gem.sha512: -------------------------------------------------------------------------------- 1 | 1d69783a6486138c306478f3ee966429f702204bd450df277b9a99a843fd091f1edfa5406f8357ec2e0485a566fd1c87c85fd04a2566b505689556fe89670a3f 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.2.2.gem.sha512: -------------------------------------------------------------------------------- 1 | 8db5b338ed0f0b17b757471932d8430045f76f405d50e0537148c365244f30bbc81982a32eec9399b34c049c2b4fde22775a360c6b7c7bd2c137d86b8e0d06b5 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.3.0.gem.sha512: -------------------------------------------------------------------------------- 1 | 8deccdfd3da3bfd955b9f971fa91e5a8a9d91480d2994337e52c7468a874bc3cfa7d41d597f455601c5e5f786d164e8641209eaea76b6a819fc8be4f140efb5c 2 | -------------------------------------------------------------------------------- /checksums/aipp-2.3.1.gem.sha512: -------------------------------------------------------------------------------- 1 | 879f9a70f98c6d2d28790b4e6335ff00ff8555bd6f572a0ff5a41cdd7dd887c23b5e2b20f500bbc605ff7db3b16e7ce0b68d39c9bf771e51c86c7da9595ba541 2 | -------------------------------------------------------------------------------- /exe/aip2aixm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'aipp' 4 | 5 | AIPP::Executable.new(File.basename($0)).run 6 | -------------------------------------------------------------------------------- /exe/aip2ofmx: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'aipp' 4 | 5 | AIPP::Executable.new(File.basename($0)).run 6 | -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /guardfile.rb: -------------------------------------------------------------------------------- 1 | clearing :on 2 | 3 | guard :minitest do 4 | watch(%r{^spec/(.+)_spec\.rb}) 5 | watch(%r{^lib/(.+)\.rb}) { "spec/lib/#{_1[1]}_spec.rb" } 6 | watch(%r{^spec/spec_helper\.rb}) { 'spec' } 7 | end 8 | -------------------------------------------------------------------------------- /lib/aipp.rb: -------------------------------------------------------------------------------- 1 | require 'debug/session' 2 | require 'singleton' 3 | require 'optparse' 4 | require 'yaml' 5 | require 'json' 6 | require 'pathname' 7 | require 'fileutils' 8 | require 'tmpdir' 9 | require 'securerandom' 10 | require 'tsort' 11 | require 'ostruct' 12 | require 'date' 13 | 14 | require 'colorize' 15 | require 'excon' 16 | require 'graphql/client' 17 | require 'graphql/client/http' 18 | require 'nokogiri' 19 | require 'csv' 20 | require 'roo' 21 | require 'pdf-reader' 22 | require 'zip' 23 | require 'airac' 24 | require 'aixm' 25 | require 'notam' 26 | 27 | require 'active_support' 28 | require 'active_support/core_ext/object/blank' 29 | require 'active_support/core_ext/string' 30 | require 'active_support/core_ext/date_time' 31 | 32 | require_relative 'core_ext/nil_class' 33 | require_relative 'core_ext/integer' 34 | require_relative 'core_ext/string' 35 | require_relative 'core_ext/array' 36 | require_relative 'core_ext/enumerable' 37 | require_relative 'core_ext/hash' 38 | require_relative 'core_ext/nokogiri' 39 | 40 | require_relative 'aipp/version' 41 | require_relative 'aipp/debugger' 42 | require_relative 'aipp/downloader' 43 | require_relative 'aipp/downloader/file' 44 | require_relative 'aipp/downloader/http' 45 | require_relative 'aipp/downloader/graphql' 46 | require_relative 'aipp/patcher' 47 | require_relative 'aipp/parser' 48 | 49 | require_relative 'aipp/environment' 50 | require_relative 'aipp/border' 51 | require_relative 'aipp/pdf' 52 | require_relative 'aipp/t_hash' 53 | 54 | require_relative 'aipp/executable' 55 | require_relative 'aipp/runner' 56 | 57 | require_relative 'aipp/scopes/aip/executable' 58 | require_relative 'aipp/scopes/aip/runner' 59 | require_relative 'aipp/scopes/aip/parser' 60 | 61 | require_relative 'aipp/scopes/notam/executable' 62 | require_relative 'aipp/scopes/notam/runner' 63 | require_relative 'aipp/scopes/notam/parser' 64 | 65 | -------------------------------------------------------------------------------- /lib/aipp/border.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | 3 | # Custom border geometries 4 | # 5 | # The border consists of one ore more open or closed geometries which are 6 | # defined by either a GeoJSON file or arrays of coordinate pairs. 7 | class Border 8 | 9 | # @return [Array] 10 | attr_reader :geometries 11 | 12 | def initialize(geometries) 13 | @geometries = geometries 14 | end 15 | 16 | class << self 17 | undef_method :new 18 | 19 | # New border object from GeoJSON file 20 | # 21 | # The border GeoJSON files must be a geometry collection of one or more 22 | # line strings: 23 | # 24 | # { 25 | # "type": "GeometryCollection", 26 | # "geometries": [ 27 | # { 28 | # "type": "LineString", 29 | # "coordinates": [ 30 | # [6.009531650000042, 45.12013319700009], 31 | # [6.015747738000073, 45.12006702600007] 32 | # ] 33 | # } 34 | # ] 35 | # } 36 | # 37 | # Please note that GeoJSON orders coordinate tuples in mathematical order 38 | # as +[longitude, latitude]+! 39 | # 40 | # @param file [Pathname, String] GeoJSON file 41 | # 42 | # @example 43 | # border = AIPP::Border.from_file("/path/to/national_park.geojson") 44 | # border.geometries 45 | # # => [[#, ]] 46 | def from_file(file) 47 | file = Pathname(file) unless file.is_a? Pathname 48 | fail(ArgumentError, "file must have extension .geojson") unless file.extname == '.geojson' 49 | geometries = JSON.load(file)['geometries'].map do |collection| 50 | collection['coordinates'].map do |long, lat| 51 | AIXM.xy(lat: lat, long: long) 52 | end 53 | end 54 | allocate.instance_eval do 55 | initialize(geometries) 56 | self 57 | end 58 | end 59 | 60 | # New border object from array of points 61 | # 62 | # The array must contain coordinate tuples in geographical order as 63 | # +latitude longitude+ separated by whitespace and/or commas. 64 | # 65 | # @param array [Array>] one or more arrays of coordinate pairs 66 | # 67 | # @example 68 | # border = AIPP::Border.from_array([["45.1201332 6.00953165", "45.12006703 6.01574774"]]) 69 | # border.geometries 70 | # # => [[#, ]] 71 | def from_array(array) 72 | geometries = array.map do |collection| 73 | collection.map do |coordinates| 74 | lat, long = coordinates.split(/[\s,]+/) 75 | AIXM.xy(lat: lat.to_f, long: long.to_f) 76 | end 77 | end 78 | allocate.instance_eval do 79 | initialize(geometries) 80 | self 81 | end 82 | end 83 | end 84 | 85 | # @return [String] 86 | def inspect 87 | %Q(#<#{self.class} #{@geometries.count} geometries>) 88 | end 89 | 90 | # Whether the given geometry is closed or not 91 | # 92 | # A geometry is considered closed when it's first coordinate equals the 93 | # last coordinate. 94 | # 95 | # @param geometry_index [Integer] geometry to check 96 | # @return [Boolean] true if the geometry is closed or false otherwise 97 | def closed?(geometry_index:) 98 | geometry = @geometries[geometry_index] 99 | geometry.first == geometry.last 100 | end 101 | 102 | # Find a position on a geometry nearest to the given coordinates 103 | # 104 | # @param geometry_index [Integer] index of the geometry on which to search 105 | # or +nil+ to search on all geometries 106 | # @param xy [AIXM::XY] coordinates to approximate 107 | # @return [AIPP::Border::Position] position nearest to the given coordinates 108 | def nearest(geometry_index: nil, xy:) 109 | position = nil 110 | min_distance = 21_000_000 # max distance on earth in meters 111 | @geometries.each.with_index do |geometry, g_index| 112 | next unless geometry_index.nil? || geometry_index == g_index 113 | geometry.each.with_index do |coordinates, c_index| 114 | distance = xy.distance(coordinates).dim 115 | if distance < min_distance 116 | position = Position.new(geometries: geometries, geometry_index: g_index, coordinates_index: c_index) 117 | min_distance = distance 118 | end 119 | end 120 | end 121 | position 122 | end 123 | 124 | # Get a segment of a geometry between the given starting and ending 125 | # positions 126 | # 127 | # The segment ends either at the given ending position or at the last 128 | # coordinates of the geometry. However, if the geometry is closed, the 129 | # segment always continues up to the given ending position. 130 | # 131 | # @param from_position [AIPP::Border::Position] starting position 132 | # @param to_position [AIPP::Border::Position] ending position 133 | # @return [Array] array of coordinates describing the segment 134 | def segment(from_position:, to_position:) 135 | fail(ArgumentError, "both positions must be on the same geometry") unless from_position.geometry_index == to_position.geometry_index 136 | geometry_index = from_position.geometry_index 137 | geometry = @geometries[geometry_index] 138 | if closed?(geometry_index: geometry_index) 139 | up = from_position.coordinates_index.upto(to_position.coordinates_index) 140 | down = from_position.coordinates_index.downto(0) + (geometry.count - 2).downto(to_position.coordinates_index) 141 | geometry.values_at(*(up.count < down.count ? up : down).to_a) 142 | else 143 | geometry.values_at(*from_position.coordinates_index.up_or_downto(to_position.coordinates_index).to_a) 144 | end 145 | end 146 | 147 | private 148 | 149 | # Position defines an exact point on a border 150 | # 151 | # @example 152 | # position = AIPP::Border::Position.new( 153 | # geometries: border.geometries, geometry_index: 0, coordinates_index: 0 154 | # ) 155 | # position.xy # => # 156 | class Position 157 | attr_accessor :geometry_index 158 | attr_accessor :coordinates_index 159 | 160 | def initialize(geometries:, geometry_index:, coordinates_index:) 161 | @geometries, @geometry_index, @coordinates_index = geometries, geometry_index, coordinates_index 162 | end 163 | 164 | # @return [String] 165 | def inspect 166 | %Q(#<#{self.class} xy=#{xy}>) 167 | end 168 | 169 | # Coordinates for this position 170 | # 171 | # @return [AIXM::XY, nil] coordinates or nil if the indexes don't exist 172 | def xy 173 | @geometries.dig(@geometry_index, @coordinates_index) 174 | end 175 | end 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /lib/aipp/debugger.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | module Debugger 3 | 4 | # Start a debugger session and watch for warnings etc 5 | # 6 | # @note The debugger session persists beyond the scope of the given block 7 | # because there's no +DEBUGGER__.stop+ as of now. 8 | # 9 | # @example 10 | # include AIPP::Debugger 11 | # with_debugger(verbose: true) do 12 | # (...) 13 | # warn("all hell broke loose", severe: true) 14 | # end 15 | # 16 | # @overload with_debugger(debug_on_warning:, debug_on_error:, verbose:, &block) 17 | # @param debug_on_warning [Boolean, Integer] start a debugger session 18 | # which opens a console on the warning with the given integer ID or on 19 | # all warnings if +true+ is given 20 | # @param debug_on_error [Boolean] start a debugger session which opens 21 | # a console when an error is raised (postmortem) 22 | # @param verbose [Boolean] print verbose info, print unsevere warnings 23 | # and re-raise rescued errors 24 | # @yield Block the debugger is watching 25 | def with_debugger(&) 26 | AIPP.cache.debug_counter = 0 27 | case 28 | when id = AIPP.options.debug_on_warning 29 | puts instructions_for(@id == true ? 'warning' : "warning #{id}") 30 | DEBUGGER__::start(no_sigint_hook: true, nonstop: true) 31 | call_with_rescue(&) 32 | when AIPP.options.debug_on_error 33 | puts instructions_for('error') 34 | DEBUGGER__::start(no_sigint_hook: true, nonstop: true, postmortem: true) 35 | call_without_rescue(&) 36 | else 37 | DEBUGGER__::start(no_sigint_hook: true, nonstop: true) 38 | call_with_rescue(&) 39 | end 40 | end 41 | 42 | alias_method :original_warn, :warn 43 | 44 | # Issue a warning and maybe open a debug session. 45 | # 46 | # @param message [String] warning message 47 | # @param severe [Boolean] whether this problem must be fixed or not 48 | def warn(message, severe: true) 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 53 | end 54 | end 55 | 56 | # Issue an informational message. 57 | # 58 | # @param message [String] informational message 59 | # @param color [Symbol] message color 60 | def info(message, color: nil) 61 | unless AIPP.options.quiet 62 | puts color ? message.upcase_first.send(color) : message.upcase_first 63 | end 64 | end 65 | 66 | # Issue a verbose informational message. 67 | # 68 | # @param message [String] verbose informational message 69 | # @param color [Symbol] message color 70 | def verbose_info(message, color: :blue) 71 | info(message, color: color) if AIPP.options.verbose 72 | end 73 | 74 | private 75 | 76 | def call_with_rescue(&block) 77 | block.call 78 | rescue => error 79 | message = error.respond_to?(:original_message) ? error.original_message : error.message 80 | puts "ERROR: #{message}".magenta 81 | if AIPP.options.verbose 82 | raise 83 | else 84 | exit 1 85 | end 86 | end 87 | 88 | def call_without_rescue(&block) 89 | block.call 90 | end 91 | 92 | def instructions_for(trigger) 93 | <<~END.strip.red 94 | Debug on #{trigger} enabled. 95 | Remember: Type "up" to enter caller frames. 96 | END 97 | end 98 | 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/aipp/downloader.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | 3 | # AIP downloader infrastructure 4 | # 5 | # The downloader operates in the +storage+ directory where it creates two 6 | # subdirectories "sources" and "work". The initializer looks for the +source+ 7 | # archive in "sources" and (if found) unpacks its contents into "work". When 8 | # reading a +document+, the downloader looks for the +document+ in "work" and 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] including the first header line 32 | # [.txt] Instance of +String+ 33 | # 34 | # @example 35 | # AIPP::Downloader.new(storage: AIPP.options.storage, source: "2018-11-08") do |downloader| 36 | # html = downloader.read( 37 | # document: 'ENR-5.1', 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 | # ) 41 | # ) 42 | # pdf = downloader.read( 43 | # document: 'VAC-LFMV', 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 | # ) 47 | # ) 48 | # end 49 | class Downloader 50 | include AIPP::Debugger 51 | 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. 54 | class NotFoundError < StandardError; end 55 | 56 | # @return [Pathname] directory to operate within 57 | attr_reader :storage 58 | 59 | # @return [String] name of the source archive (without extension ".zip") 60 | attr_reader :source 61 | 62 | # @return [Pathname] full path to the source archive 63 | attr_reader :source_file 64 | 65 | # @param storage [Pathname] directory to operate within 66 | # @param source [String] name of the source archive (without extension ".zip") 67 | def initialize(storage:, source:) 68 | @storage, @source = storage, source 69 | fail(ArgumentError, 'bad storage directory') unless Dir.exist? storage 70 | @source_file = sources_path.join("#{@source}.zip") 71 | prepare 72 | if @source_file.exist? 73 | if AIPP.options.clean 74 | @source_file.delete 75 | else 76 | unpack 77 | end 78 | end 79 | yield self 80 | pack 81 | ensure 82 | teardown 83 | end 84 | 85 | # @return [String] 86 | def inspect 87 | "#" 88 | end 89 | 90 | # Download and read +document+ 91 | # 92 | # @param document [String] document to read (without extension) 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) 98 | unless file.exist? 99 | verbose_info "downloading #{document}" 100 | origin.fetch_to(work_path) 101 | end 102 | convert file 103 | end 104 | 105 | private 106 | 107 | def sources_path 108 | @storage.join('sources') 109 | end 110 | 111 | def work_path 112 | @storage.join('work') 113 | end 114 | 115 | def prepare 116 | teardown 117 | sources_path.mkpath 118 | work_path.mkpath 119 | end 120 | 121 | def teardown 122 | if work_path.exist? 123 | work_path.children.each(&:delete) 124 | work_path.delete 125 | end 126 | end 127 | 128 | def unpack 129 | extract(source_file) or fail 130 | end 131 | 132 | def pack 133 | backup_file = source_file.sub(/$/, '.old') if source_file.exist? 134 | source_file.rename(backup_file) if backup_file 135 | Zip::File.open(source_file, Zip::File::CREATE) do |zip| 136 | work_path.children.each do |entry| 137 | zip.add(entry.basename.to_s, entry) unless entry.basename.to_s[0] == '.' 138 | end 139 | end 140 | backup_file&.delete 141 | end 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 | 164 | def convert(file) 165 | case file.extname 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, symbolize_names: true) 169 | when '.pdf' then AIPP::PDF.new(file) 170 | when '.xlsx', '.ods' then Roo::Spreadsheet.open(file.to_s) 171 | when '.csv' then Roo::Spreadsheet.open(file.to_s, csv_options: { encoding: 'bom|utf-8', headers: false, col_sep: separator(file) }) 172 | when '.txt' then ::File.read(file) 173 | else fail(ArgumentError, "unrecognized file type") 174 | end 175 | end 176 | 177 | # @return [String] most likely separator character of CSV and similar files 178 | def separator(file) 179 | content = file.read 180 | %W(, ; \t).map { [content.scan(_1).count, _1] }.sort.last.last 181 | end 182 | 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /lib/aipp/downloader/file.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | class Downloader 3 | 4 | # Local file 5 | class File 6 | attr_reader :file 7 | 8 | def initialize(archive: nil, file:, type: nil) 9 | @archive = Pathname(archive) if archive 10 | @file, @type = Pathname(file), type&.to_s 11 | end 12 | 13 | def fetch_to(path) 14 | path.join(fetched_file).tap do |target| 15 | if @archive 16 | fail NotFoundError unless @archive.exist? 17 | extract(file, from: @archive, as: target) 18 | else 19 | fail NotFoundError unless file.exist? 20 | FileUtils.cp(file, target) 21 | end 22 | end 23 | self 24 | end 25 | 26 | def fetched_file 27 | [name, type].join('.') 28 | end 29 | 30 | private 31 | 32 | def name 33 | file.basename(file.extname).to_s 34 | end 35 | 36 | def type 37 | @type || file.extname[1..] || fail("type must be declared") 38 | end 39 | 40 | def extract(file, from:, as:) 41 | if respond_to?(extractor = 'un' + from.extname[1..], true) 42 | send(extractor, file, from: from, as: as) or fail NotFoundError 43 | else 44 | fail "archive type not recognized" 45 | end 46 | end 47 | 48 | # @return [Boolean] whether a file was extracted 49 | def unzip(file, from:, as:) 50 | Zip::File.open(from).inject(nil) do |_, entry| 51 | if file.to_s == entry.name 52 | break entry.extract(as) 53 | end 54 | end 55 | end 56 | end 57 | 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/aipp/downloader/graphql.rb: -------------------------------------------------------------------------------- 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 30 | -------------------------------------------------------------------------------- /lib/aipp/downloader/http.rb: -------------------------------------------------------------------------------- 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 | 'application/x-zip-compressed' => :zip 9 | }.freeze 10 | 11 | def initialize(archive: nil, file:, type: nil, headers: {}) 12 | @archive = URI(archive) if archive 13 | @file, @type, @headers = URI(file), type&.to_s, headers 14 | @digest = (archive || file).to_digest 15 | end 16 | 17 | # @param path [Pathname] directory where to write the fetched file 18 | # @return [File] fetched file 19 | def fetch_to(path) 20 | response = Excon.get((@archive || file).to_s, headers: @headers) 21 | fail NotFoundError if response.status == 404 22 | mime_type = ARCHIVE_MIME_TYPES.fetch(response.headers['Content-Type'], :dat) 23 | downloaded_file = path.join([@digest, mime_type].join('.')) 24 | ::File.write(downloaded_file, response.body) 25 | path.join(fetched_file).tap do |target| 26 | if @archive 27 | extract(file, from: downloaded_file, as: target) 28 | ::File.delete(downloaded_file) 29 | else 30 | ::File.rename(downloaded_file, target) 31 | end 32 | end 33 | self 34 | end 35 | 36 | private 37 | 38 | def name 39 | path = Pathname(file.path) 40 | path.basename(path.extname).to_s.blank_to_nil || @digest 41 | end 42 | 43 | def type 44 | @type || Pathname(file.path).extname[1..].blank_to_nil || fail("type must be declared") 45 | end 46 | end 47 | 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/aipp/environment.rb: -------------------------------------------------------------------------------- 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 89 | -------------------------------------------------------------------------------- /lib/aipp/executable.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | 3 | # @abstract 4 | class Executable 5 | include AIPP::Debugger 6 | 7 | def initialize(exe_file) 8 | @exe_file = exe_file 9 | help if ARGV.none? || ARGV.first == '--help' 10 | require_scope 11 | AIPP.options.replace( 12 | scope: scope, 13 | schema: schema, 14 | storage: Pathname(Dir.home).join('.aipp'), 15 | check_links: false, 16 | clean: false, 17 | force: false, 18 | mid: false, 19 | write_empty: false, 20 | quiet: false, 21 | verbose: false, 22 | debug_on_warning: false, 23 | debug_on_error: false 24 | ) 25 | options if respond_to? :options 26 | OptionParser.new do |o| 27 | o.on('-r', '--region STRING', String, 'region (e.g. "LF")') { AIPP.options.region = _1.upcase } 28 | o.on('-s', '--section STRING', String, 'process this section only') { AIPP.options.section = _1.classify } 29 | o.on('-d', '--storage DIR', String, 'storage directory (default: "~/.aipp")') { AIPP.options.storage = Pathname(_1) } 30 | o.on('-o', '--output FILE', String, 'output file') { AIPP.options.output_file = _1 } 31 | option_parser(o) 32 | if schema == :ofmx 33 | o.on('-m', '--[no-]mid', 'insert mid attributes into all Uid elements (default: false)') { AIPP.options.mid = _1 } 34 | o.on('-0', '--[no-]empty', 'write empty OFMX files in case of no upstream data (default: false)') { AIPP.options.write_empty = _1 } 35 | end 36 | o.on('-h', '--[no-]check-links', 'check all links with HEAD requests (default: false)') { AIPP.options.check_links = _1 } 37 | o.on('-c', '--[no-]clean', 'clean cache and download from sources anew (default: false)') { AIPP.options.clean = _1 } 38 | o.on('-f', '--[no-]force', 'continue on non-fatal errors (default: false)') { AIPP.options.force = _1 } 39 | o.on('-q', '--[no-]quiet', 'suppress all informational output (default: false)') { AIPP.options.quiet = _1 } 40 | o.on('-v', '--[no-]verbose', 'verbose output including unsevere warnings (default: false)') { AIPP.options.verbose = _1 } 41 | o.on('-w', '--debug-on-warning [ID]', Integer, 'open debug session on warning with ID (default: false)') { AIPP.options.debug_on_warning = _1 || true } 42 | o.on('-e', '--[no-]debug-on-error', 'open debug session on error (default: false)') { AIPP.options.debug_on_error = _1 } 43 | o.on('-A', '--about', 'show author/license information and exit') { about } 44 | o.on('-R', '--readme', 'show README and exit') { readme } 45 | o.on('-L', '--list', 'list implemented regions') { list } 46 | o.on('-V', '--version', 'show version and exit') { version } 47 | end.parse! 48 | guard if respond_to? :guard 49 | end 50 | 51 | def run 52 | with_debugger do 53 | String.disable_colorization = !STDOUT.tty? 54 | starting = Process.clock_gettime(Process::CLOCK_MONOTONIC) 55 | [:AIPP, AIPP.options.scope, :Runner].constantize.new.run 56 | ending = Process.clock_gettime(Process::CLOCK_MONOTONIC) 57 | info("finished after %s" % Time.at(ending - starting).utc.strftime("%H:%M:%S")) 58 | end 59 | end 60 | 61 | private 62 | 63 | def help 64 | puts <<~END 65 | Download online aeronautical data and convert it to #{schema.upcase}. 66 | Usage: #{File.basename($0)} [aip|notam|shoot] --help 67 | END 68 | exit 69 | end 70 | 71 | def about 72 | puts 'Written by Sven Schwyn (bitcetera.com) and distributed under MIT license.' 73 | exit 74 | end 75 | 76 | def readme 77 | puts IO.read(Pathname(__dir__).join('README.md')) 78 | exit 79 | end 80 | 81 | def list 82 | puts "Available scopes -> regions -> sections:" 83 | lib_dir.join('scopes').each_child do |scope_dir| 84 | next unless scope_dir.directory? 85 | puts "\n#{scope_dir.basename} ->".upcase 86 | lib_dir.join('regions').each_child do |region_dir| 87 | next unless region_dir.directory? && region_dir.join(scope_dir.basename).exist? 88 | puts " #{region_dir.basename} ->" 89 | region_dir.join(scope_dir.basename).glob('*.rb') do |section_file| 90 | puts " #{section_file.basename('.rb')}" 91 | end 92 | end 93 | end 94 | exit 95 | end 96 | 97 | def version 98 | puts AIPP::VERSION 99 | exit 100 | end 101 | 102 | def lib_dir 103 | Pathname(__FILE__).dirname 104 | end 105 | 106 | def schema 107 | @schema ||= @exe_file.split('2').last.to_sym 108 | end 109 | 110 | def scope 111 | @scope ||= ARGV.first.match?(/^-/) ? 'AIP' : ARGV.shift.upcase 112 | end 113 | 114 | def require_scope 115 | lib_dir.join('scopes', scope.downcase).glob('*.rb').each { require _1 } 116 | extend [:AIPP, scope, :Executable].constantize 117 | rescue NameError 118 | puts "ERROR: unknown scope `#{scope}'".magenta 119 | exit 1 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/aipp/parser.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | 3 | # @abstract 4 | class Parser 5 | include AIPP::Debugger 6 | include AIPP::Patcher 7 | 8 | # @return [AIXM::Document] AIXM document instance 9 | attr_reader :aixm 10 | 11 | class << self 12 | # Declare a dependency 13 | # 14 | # @param dependencies [Array] class names of other parsers this 15 | # parser depends on 16 | def depends_on(*dependencies) 17 | @dependencies = dependencies.map(&:to_s) 18 | end 19 | 20 | # Declared dependencies 21 | # 22 | # @return [Array] class names of other parsers this parser 23 | # depends on 24 | def dependencies 25 | @dependencies || [] 26 | end 27 | end 28 | 29 | def initialize(downloader:, aixm:) 30 | @downloader, @aixm = downloader, aixm 31 | setup if respond_to? :setup 32 | end 33 | 34 | # @return [String] 35 | def inspect 36 | "#" 37 | end 38 | 39 | # @return [String] 40 | def section 41 | self.class.to_s.sectionize 42 | end 43 | 44 | # @abstract 45 | def origin_for(*) 46 | fail "origin_for method must be implemented in parser" 47 | end 48 | 49 | # Read a source document 50 | # 51 | # Read the cached document if it exists in the source archive. Otherwise, 52 | # download and cache it. 53 | # 54 | # An origin builder method +origin_for+ must be implemented by the parser 55 | # definition. 56 | # 57 | # @param document [String] e.g. "ENR-2.1" or "aerodromes" (default: current 58 | # +section+) 59 | # @return [Nokogiri::XML::Document, Nokogiri::HTML5::Document, 60 | # Roo::Spreadsheet, String] document 61 | def read(document=section) 62 | @downloader.read( 63 | document: document, 64 | origin: origin_for(document) 65 | ) 66 | end 67 | 68 | # Add feature to AIXM 69 | # 70 | # @param feature [AIXM::Feature] e.g. airport or airspace 71 | # @return [AIXM::Feature] added feature 72 | def add(feature) 73 | verbose_info "adding #{feature.inspect}" 74 | aixm.add_feature feature 75 | feature 76 | end 77 | 78 | # @!method find_by(klass, attributes={}) 79 | # Find objects of the given class and optionally with the given attribute 80 | # values previously written to AIXM. 81 | # 82 | # @note This method is delegated to +AIXM::Association::Array+. 83 | # @see https://www.rubydoc.info/gems/aixm/AIXM/Association/Array#find_by-instance_method 84 | # 85 | # @!method find(object) 86 | # Find equal objects previously written to AIXM. 87 | # 88 | # @note This method is delegated to +AIXM::Association::Array+. 89 | # @see https://www.rubydoc.info/gems/aixm/AIXM/Association/Array#find-instance_method 90 | %i(find_by find).each do |method| 91 | define_method method do |*args| 92 | aixm.features.send(method, *args) 93 | end 94 | end 95 | 96 | # @overload given(*objects) 97 | # Return +objects+ unless at least one of them equals nil 98 | # 99 | # @example 100 | # # Instead of this: 101 | # first, last = unless ((first = expensive_first).nil? || (last = expensive_last).nil?) 102 | # [first, last] 103 | # end 104 | # 105 | # # Use the following: 106 | # first, last = given(expensive_first, expensive_last) 107 | # 108 | # @param *objects [Array] any objects really 109 | # @return [Object] nil if at least one of the objects is nil, given 110 | # objects otherwise 111 | # 112 | # @overload given(*objects) 113 | # Yield +objects+ unless at least one of them equals nil 114 | # 115 | # @example 116 | # # Instead of this: 117 | # name = unless ((first = expensive_first.nil? || (last = expensive_last.nil?) 118 | # "#{first} #{last}" 119 | # end 120 | # 121 | # # Use any of the following: 122 | # name = given(expensive_first, expensive_last) { |f, l| "#{f} #{l}" } 123 | # name = given(expensive_first, expensive_last) { "#{_1} #{_2}" } 124 | # 125 | # @param *objects [Array] any objects really 126 | # @yield [Array] objects passed as parameter 127 | # @return [Object] nil if at least one of the objects is nil, return of 128 | # block otherwise 129 | def given(*objects) 130 | if objects.none?(&:nil?) 131 | block_given? ? yield(*objects) : objects 132 | end 133 | end 134 | 135 | # Build and optionally check a Markdown link 136 | # 137 | # @example 138 | # AIPP.options.check_links = false 139 | # link_to('foo', 'https://bar.com/exists') # => "[foo](https://bar.com/exists)" 140 | # link_to('foo', 'https://bar.com/not-found') # => "[foo](https://bar.com/not-found)" 141 | # AIPP.options.check_links = true 142 | # link_to('foo', 'https://bar.com/exists') # => "[foo](https://bar.com/exists)" 143 | # link_to('foo', 'https://bar.com/not-found') # => nil 144 | # 145 | # @param body [String] body text of the link 146 | # @param url [String] URL of the link 147 | # @return [String, nil] Markdown link 148 | def link_to(body, url) 149 | "[#{body}](#{url})" if !AIPP.options.check_links || url_exists?(url) 150 | end 151 | 152 | private 153 | 154 | def url_exists?(url) 155 | uri = URI.parse(url) 156 | Net::HTTP.new(uri.host, uri.port).tap do |request| 157 | request.use_ssl = (uri.scheme == 'https') 158 | path = uri.path.present? ? uri.path : '/' 159 | result = request.request_head(path) 160 | if result.kind_of? Net::HTTPRedirection 161 | url_exist?(result['location']) 162 | else 163 | result.code == '200' 164 | end 165 | end 166 | end 167 | 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /lib/aipp/patcher.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | module Patcher 3 | 4 | def self.included(base) 5 | base.extend(ClassMethods) 6 | base.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 | verbose_info_method = method(:verbose_info) 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 | error = catch :abort do 26 | value = block.call(self, value) 27 | verbose_info_method.call("Patching #{self.inspect} with #{attribute}=#{value.inspect}", color: :magenta) 28 | end 29 | fail "patching #{self.inspect} with #{attribute}=#{value.inspect} failed: #{error}" if error 30 | send(:"original_#{attribute}=", value) 31 | end 32 | end 33 | end 34 | self 35 | end 36 | 37 | def detach_patches 38 | self.class.patches[self.class]&.each do |(klass, attribute, _)| 39 | klass.instance_eval do 40 | remove_method :"#{attribute}=" 41 | alias_method :"#{attribute}=", :"original_#{attribute}=" 42 | remove_method :"original_#{attribute}=" 43 | end 44 | end 45 | self 46 | end 47 | 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/aipp/pdf.rb: -------------------------------------------------------------------------------- 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 # => # 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] 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 = Pathname.new("#{@file}.json") 101 | if cache_file.exist? && (@file.stat.mtime - cache_file.stat.mtime).abs < 1 102 | JSON.load cache_file 103 | else 104 | read.tap do |data| 105 | cache_file.write data.to_json 106 | FileUtils.touch(cache_file, mtime: @file.stat.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 { _1 >= index }) + 1 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /lib/aipp/regions/LF/README.md: -------------------------------------------------------------------------------- 1 | # LF – France Mainland 2 | 3 | ## Prerequisites 4 | 5 | This parser requires the XML data dump from SIA. It is available free of charge, but has to be ordered before it can be downloaded. It's therefore necessary to perform the following steps before running the parser for any given AIRAC for the first time: 6 | 7 | 1. Browse to the [SIA web shop](https://www.sia.aviation-civile.gouv.fr/produits-numeriques-en-libre-disposition/les-bases-de-donnees-sia.html). 8 | 2. Shop the desired dump named «données aéronautiques XML AIRAC ii/yy». 9 | 3. Browse to [your customer page](https://www.sia.aviation-civile.gouv.fr/customer/account/#orders-and-proposals). 10 | 4. On page «mes produits téléchargeables» download the desired dump. 11 | 5. Unzip the downloaded ZIP archive. 12 | 6. Move the file «XML_SIA_yyyy-mm-dd.xml» to the directory in which you will execute the parser. 13 | 14 | ⚠️ The SIA web shop misbehaves with some browsers, you should try Brave or Chrome. 15 | 16 | ## Region Options 17 | 18 | ### Obstacles XLSX 19 | 20 | While the XML data dump contains all obstacles, some details of the source XLSX file are omitted. Unfortunately, the latter is only available for the current AIRAC cycle, therefore the XML data dump is used by default. Add `-o lf_obstacles_xlsx` to use the source XLSX file instead. 21 | 22 | ## Charset 23 | 24 | The XML data dump from SIA is ISO-8859-1 encoded. Nokogiri which parses the XML converts this to UTF-8 on the fly, however, when grepping the dump on a shell, you might run into trouble: 25 | 26 | ```shell 27 | grep "" XML_SIA_2021-12-02.xml | sort | uniq 28 | 29 | sort: Illegal byte sequence 30 | ``` 31 | 32 | For this to work, you have to convert the dump to UTF-8 and use this converted dump for grepping: 33 | 34 | ```shell 35 | iconv -f ISO-8859-1 -t UTF-8 XML_SIA_2021-12-02.xml >XML_SIA_2021-12-02_UTF.xml 36 | grep "" XML_SIA_2021-12-02_UTF.xml | sort | uniq 37 | 38 | Aluminium 39 | Asphalte 40 | Béton ( 4t ) 41 | (...) 42 | ``` 43 | 44 | ## Error Reports 45 | 46 | Feedback and error reports should be sent to SIA. However, their [contact form](https://www.sia.aviation-civile.gouv.fr/contact) is sometimes broken. If you don't receive an automatic reception confirmation, you should send it directly to sia-qualite@aviation-civile.gouv.fr instead. 47 | 48 | ## References 49 | 50 | * [SIA – AIP publisher](https://www.sia.aviation-civile.gouv.fr) 51 | * [SIA XML usage guide](https://www.sia.aviation-civile.gouv.fr/faqs) 52 | * [OpenData – public data files](https://www.data.gouv.fr) 53 | * [Protected Planet – protected area data files](https://www.protectedplanet.net) 54 | -------------------------------------------------------------------------------- /lib/aipp/regions/LF/aip/d_p_r_airspaces.rb: -------------------------------------------------------------------------------- 1 | module AIPP::LF::AIP 2 | class DPRAirspaces < AIPP::AIP::Parser 3 | 4 | include AIPP::LF::Helpers::Base 5 | 6 | # Map source types to type and optional local type 7 | SOURCE_TYPES = { 8 | 'D' => { type: 'D' }, 9 | 'P' => { type: 'P' }, 10 | 'R' => { type: 'R' }, 11 | 'ZIT' => { type: 'P', local_type: 'ZIT' } 12 | }.freeze 13 | 14 | # Radius to use for zones consisting of one point only 15 | POINT_RADIUS = AIXM.d(1, :km).freeze 16 | 17 | def parse 18 | SOURCE_TYPES.each do |source_type, target| 19 | verbose_info("processing #{source_type}") 20 | AIPP.cache.espace.css(%Q(Espace[lk^="[LF][#{source_type} "])).each do |espace_node| 21 | # UPSTREAM: Espace[pk=300343] has no Partie/Volume (reported) 22 | next if espace_node['pk'] == '300343' 23 | partie_node = AIPP.cache.partie.at_css(%Q(Partie:has(Espace[pk="#{espace_node['pk']}"]))) 24 | volume_node = AIPP.cache.volume.at_css(%Q(Volume:has(Partie[pk="#{partie_node['pk']}"]))) 25 | name = "#{AIPP.options.region}-#{source_type}#{espace_node.(:Nom)}".remove(/\s/) 26 | add( 27 | AIXM.airspace( 28 | source: source(part: 'ENR', position: espace_node.line), 29 | name: "#{name} #{partie_node.(:NomUsuel)}".strip, 30 | type: target[:type], 31 | local_type: target[:local_type] 32 | ).tap do |airspace| 33 | airspace.geometry = geometry_from(partie_node.(:Contour)) 34 | if airspace.geometry.point? # convert point to circle 35 | airspace.geometry = AIXM.geometry( 36 | AIXM.circle( 37 | center_xy: airspace.geometry.segments.first.xy, 38 | radius: POINT_RADIUS 39 | ) 40 | ) 41 | end 42 | fail("geometry is not closed") unless airspace.geometry.closed? 43 | airspace.add_layer layer_from(volume_node) 44 | airspace.layers.first.timetable = timetable_from(volume_node.(:HorCode)) 45 | airspace.layers.first.remarks = volume_node.(:Activite) 46 | end 47 | ) 48 | end 49 | end 50 | end 51 | 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/aipp/regions/LF/aip/dangerous_activities.rb: -------------------------------------------------------------------------------- 1 | module AIPP::LF::AIP 2 | class DangerousActivities < AIPP::AIP::Parser 3 | 4 | include AIPP::LF::Helpers::Base 5 | 6 | # Map raw activities to type of activity airspace 7 | ACTIVITIES = { 8 | 'AP' => { activity: :other, airspace: :dangerous_activities_area }, 9 | 'Aer' => { activity: :aeromodelling, airspace: :dangerous_activities_area }, 10 | 'Bal' => { activity: :balloon, airspace: :dangerous_activities_area }, 11 | 'Pje' => { activity: :parachuting, airspace: :dangerous_activities_area }, 12 | 'TrPVL' => { activity: :glider_winch, airspace: :dangerous_activities_area }, 13 | 'TrPla' => { activity: :glider_winch, airspace: :dangerous_activities_area }, 14 | 'TrVL' => { activity: :glider_winch, airspace: :dangerous_activities_area }, 15 | 'Vol' => { activity: :acrobatics, airspace: :dangerous_activities_area } 16 | }.freeze 17 | 18 | def parse 19 | ACTIVITIES.each do |code, type| 20 | verbose_info("processing #{code}") 21 | AIPP.cache.espace.css(%Q(Espace[lk^="[LF][#{code} "])).each do |espace_node| 22 | # HACK: Missing partie/volume as of AIRAC 2312 (reported) 23 | next if espace_node['pk'] == '302508' 24 | partie_node = AIPP.cache.partie.at_css(%Q(Partie:has(Espace[pk="#{espace_node['pk']}"]))) 25 | volume_node = AIPP.cache.volume.at_css(%Q(Volume:has(Partie[pk="#{partie_node['pk']}"]))) 26 | add( 27 | AIXM.airspace( 28 | source: source(part: 'ENR', position: espace_node.line), 29 | id: espace_node.(:Nom), 30 | type: type[:airspace], 31 | local_type: code.upcase, 32 | name: [espace_node.(:Nom), partie_node.(:NomUsuel)].join(' ') 33 | ).tap do |airspace| 34 | airspace.geometry = geometry_from partie_node.(:Contour) 35 | layer_from(volume_node).then do |layer| 36 | layer.activity = type[:activity] 37 | airspace.add_layer layer 38 | end 39 | airspace.layers.first.timetable = timetable_from(volume_node.(:HorCode)) 40 | airspace.layers.first.remarks = volume_node.(:Remarque) 41 | end 42 | ) 43 | end 44 | end 45 | end 46 | 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/aipp/regions/LF/aip/designated_points.rb: -------------------------------------------------------------------------------- 1 | module AIPP::LF::AIP 2 | class DesignatedPoints < AIPP::AIP::Parser 3 | 4 | include AIPP::LF::Helpers::Base 5 | 6 | depends_on :Aerodromes 7 | 8 | SOURCE_TYPES = { 9 | 'VFR' => :vfr_reporting_point, 10 | 'WPT' => :icao 11 | }.freeze 12 | 13 | def parse 14 | SOURCE_TYPES.each do |source_type, type| 15 | verbose_info("processing #{source_type}") 16 | AIPP.cache.navfix.css(%Q(NavFix[lk^="[LF][#{source_type} "])).each do |navfix_node| 17 | ident = navfix_node.(:Ident) 18 | add( 19 | AIXM.designated_point( 20 | source: source(part: 'ENR', position: navfix_node.line), 21 | type: type, 22 | id: ident.split('-').last.remove(/[^a-z\d]/i), # only use last segment of ID 23 | name: ident, 24 | xy: xy_from(navfix_node.(:Geometrie)) 25 | ).tap do |designated_point| 26 | designated_point.remarks = navfix_node.(:Description) 27 | if ident.match? /-/ 28 | airport = find_by(:airport, id: "LF#{ident.split('-').first}").first 29 | designated_point.airport = airport 30 | end 31 | end 32 | ) 33 | end 34 | end 35 | AIXM::Concerns::Memoize.method :to_uid do 36 | aixm.features.find_by(:designated_point).duplicates.each do |duplicates| 37 | duplicates.first.name += '/' + duplicates[1..].map(&:name).join('/') 38 | aixm.remove_features(duplicates[1..]) 39 | end 40 | end 41 | end 42 | 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/aipp/regions/LF/aip/helipads.rb: -------------------------------------------------------------------------------- 1 | module AIPP::LF::AIP 2 | class Helipads < AIPP::AIP::Parser 3 | 4 | include AIPP::LF::Helpers::Base 5 | include AIPP::LF::Helpers::UsageLimitation 6 | include AIPP::LF::Helpers::Surface 7 | 8 | depends_on :Aerodromes 9 | 10 | HOSTILITIES = { 11 | 'hostile habitée' => 'Zone hostile habitée / hostile populated area', 12 | 'hostile non habitée' => 'Zone hostile non habitée / hostile unpopulated area', 13 | 'non hostile' => 'Zone non hostile / non-hostile area' 14 | }.freeze 15 | 16 | ELEVATED = { 17 | true => 'En terrasse / on deck', 18 | false => 'En surface / on ground' 19 | }.freeze 20 | 21 | def parse 22 | AIPP.cache.helistation.css(%Q(Helistation[lk^="[LF]"])).each do |helistation_node| 23 | # Build airport if necessary 24 | next unless limitation_type = LIMITATION_TYPES.fetch(helistation_node.(:Statut)) 25 | name = helistation_node.(:Nom) 26 | airport = find_by(:airport, name: name).first || add( 27 | AIXM.airport( 28 | source: source(part: 'AD', position: helistation_node.line), 29 | organisation: organisation_lf, 30 | id: AIPP.options.region, 31 | name: name, 32 | xy: xy_from(helistation_node.(:Geometrie)) 33 | ).tap do |airport| 34 | airport.z = AIXM.z(helistation_node.(:AltitudeFt).to_i, :qnh) 35 | airport.add_usage_limitation(type: limitation_type.fetch(:limitation)) do |limitation| 36 | limitation.remarks = limitation_type[:remarks] 37 | [:private].each do |purpose| # TODO: check and simplify 38 | limitation.add_condition do |condition| 39 | condition.realm = limitation_type.fetch(:realm) 40 | condition.origin = :any 41 | condition.rule = case 42 | when helistation_node.(:Ifr?) then :ifr_and_vfr 43 | else :vfr 44 | end 45 | condition.purpose = purpose 46 | end 47 | end 48 | end 49 | end 50 | ) 51 | # TODO: link to VAC once supported downstream 52 | # # Link to VAC 53 | # if helistation_node.(:Atlas?) 54 | # vac = "VAC-#{airport.id}" if airport.id.match?(/^LF[A-Z]{2}$/) 55 | # vac ||= "VACH-H#{airport.name[0, 3].upcase}" 56 | # airport.remarks = [ 57 | # airport.remarks.to_s, 58 | # link_to('VAC-HP', origin_for(vac).file) 59 | # ].join("\n") 60 | # end 61 | # Add helipad and FATO 62 | airport.add_helipad( 63 | AIXM.helipad( 64 | name: 'TLOF', 65 | xy: xy_from(helistation_node.(:Geometrie)) 66 | ).tap do |helipad| 67 | helipad.z = AIXM.z(helistation_node.(:AltitudeFt).to_i, :qnh) 68 | helipad.dimensions = dimensions_from(helistation_node.(:DimTlof)) 69 | end.tap do |helipad| 70 | airport.add_helipad(helipad) 71 | helipad.performance_class = performance_class_from(helistation_node.(:ClassePerf)) 72 | helipad.surface = surface_from(helistation_node) 73 | helipad.marking = helistation_node.(:Balisage) unless helistation_node.(:Balisage)&.match?(/^nil$/i) 74 | helipad.add_lighting(AIXM.lighting(position: :other)) if helistation_node.(:Nuit?) || helistation_node.(:Balisage)&.match?(/feu/i) 75 | helipad.remarks = { 76 | 'position/positioning' => [ 77 | (HOSTILITIES.fetch(helistation_node.(:ZoneHabitee)) if helistation_node.(:ZoneHabitee)), 78 | (ELEVATED.fetch(helistation_node.(:EnTerrasse?)) if helistation_node.(:EnTerrasse)), 79 | ].compact.join("\n"), 80 | 'hauteur/height' => given(helistation_node.(:HauteurFt)) { "#{_1} ft" }, 81 | 'exploitant/operator' => helistation_node.(:Exploitant) 82 | }.to_remarks 83 | if fato_dimensions = dimensions_from(helistation_node.(:DimFato)) 84 | AIXM.fato(name: 'FATO').tap do |fato| 85 | fato.dimensions = fato_dimensions 86 | airport.add_fato(fato) 87 | helipad.fato = fato 88 | end 89 | end 90 | end 91 | ) 92 | end 93 | end 94 | 95 | private 96 | 97 | def dimensions_from(content) 98 | if content 99 | dims = content.remove(/[^x\d.,]/i).split(/x/i).map { _1.to_ff.floor } 100 | case dims.size 101 | when 1 102 | AIXM.r(AIXM.d(dims[0], :m)) 103 | when 2 104 | AIXM.r(AIXM.d(dims[0], :m), AIXM.d(dims[1], :m)) 105 | when 4 106 | AIXM.r(AIXM.d(dims.min, :m)) 107 | else 108 | warn("ignoring dimensions `#{content}'", severe: false) 109 | nil 110 | end 111 | end 112 | end 113 | 114 | def performance_class_from(content) 115 | content.remove(/\d{2,}/).scan(/\d/).map(&:to_i).min&.to_s if content 116 | end 117 | 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/aipp/regions/LF/aip/navigational_aids.rb: -------------------------------------------------------------------------------- 1 | module AIPP::LF::AIP 2 | class NavigationalAids < AIPP::AIP::Parser 3 | 4 | include AIPP::LF::Helpers::Base 5 | 6 | SOURCE_TYPES = { 7 | 'DME-ATT' => [:dme], 8 | 'TACAN' => [:tacan], 9 | 'VOR' => [:vor], 10 | 'VOR-DME' => [:vor, :dme], 11 | 'VORTAC' => [:vor, :tacan], 12 | 'NDB' => [:ndb] 13 | }.freeze 14 | 15 | def parse 16 | SOURCE_TYPES.each do |source_type, (primary_type, secondary_type)| 17 | verbose_info("processing #{source_type}") 18 | AIPP.cache.navfix.css(%Q(NavFix[lk^="[LF][#{source_type} "])).each do |navfix_node| 19 | attributes = { 20 | source: source(part: 'ENR', position: navfix_node.line), 21 | organisation: organisation_lf, 22 | id: navfix_node.(:Ident), 23 | xy: xy_from(navfix_node.(:Geometrie)) 24 | } 25 | if radionav_node = AIPP.cache.radionav.at_css(%Q(RadioNav:has(NavFix[pk="#{navfix_node.attr(:pk)}"]))) 26 | attributes.merge! send(primary_type, radionav_node) 27 | add( 28 | AIXM.send(primary_type, **attributes).tap do |navigational_aid| 29 | navigational_aid.name = radionav_node.(:NomPhraseo) || radionav_node.(:Station) 30 | navigational_aid.timetable = timetable_from(radionav_node.(:HorCode)) 31 | navigational_aid.remarks = { 32 | "location/situation" => radionav_node.(:Situation), 33 | "range/portée" => range_from(radionav_node) 34 | }.to_remarks 35 | navigational_aid.send("associate_#{secondary_type}") if secondary_type 36 | end 37 | ) 38 | else 39 | verbose_info("skipping incomplete #{source_type} #{attributes[:id]}") 40 | end 41 | end 42 | end 43 | end 44 | 45 | private 46 | 47 | def dme(radionav_node) 48 | { 49 | ghost_f: AIXM.f(radionav_node.(:Frequence).to_f, :mhz), 50 | z: AIXM.z(radionav_node.(:AltitudeFt).to_i, :qnh) 51 | } 52 | end 53 | alias_method :tacan, :dme 54 | 55 | def vor(radionav_node) 56 | { 57 | type: :conventional, 58 | north: :magnetic, 59 | name: radionav_node.(:Station), 60 | f: AIXM.f(radionav_node.(:Frequence).to_f, :mhz), 61 | z: AIXM.z(radionav_node.(:AltitudeFt).to_i, :qnh), 62 | } 63 | end 64 | 65 | def ndb(radionav_node) 66 | { 67 | type: :en_route, 68 | f: AIXM.f(radionav_node.(:Frequence).to_f, :khz), 69 | z: AIXM.z(radionav_node.(:AltitudeFt).to_i, :qnh) 70 | } 71 | end 72 | 73 | def range_from(radionav_node) 74 | [ 75 | radionav_node.(:Portee).blank_to_nil&.concat('NM'), 76 | radionav_node.(:FlPorteeVert).blank_to_nil&.prepend('FL'), 77 | radionav_node.(:Couverture).blank_to_nil 78 | ].compact.join(' / ') 79 | end 80 | 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/aipp/regions/LF/aip/obstacles.rb: -------------------------------------------------------------------------------- 1 | module AIPP::LF::AIP 2 | class Obstacles < AIPP::AIP::Parser 3 | 4 | include AIPP::LF::Helpers::Base 5 | 6 | # Map type descriptions to AIXM types and remarks 7 | TYPES = { 8 | 'Antenne' => [:antenna], 9 | 'Autre' => [:other], 10 | 'Bâtiment' => [:building], 11 | 'Câble' => [:other, 'Cable / Câble'], 12 | 'Centrale thermique' => [:building, 'Thermal power plant / Centrale thermique'], 13 | "Château d'eau" => [:tower, "Water tower / Château d'eau"], 14 | 'Cheminée' => [:chimney], 15 | 'Derrick' => [:tower, 'Derrick'], 16 | 'Eglise' => [:tower, 'Church / Eglise'], 17 | 'Eolienne' => [:wind_turbine], 18 | 'Eolienne(s)' => [:wind_turbine], 19 | 'Grue' => [:tower, 'Crane / Grue'], 20 | 'Mât' => [:mast], 21 | 'Phare marin' => [:tower, 'Lighthouse / Phare marin'], 22 | 'Pile de pont' => [:other, 'Bridge piers / Pile de pont'], 23 | 'Portique' => [:building, 'Arch / Portique'], 24 | 'Pylône' => [:mast, 'Pylon / Pylône'], 25 | 'Silo' => [:tower, 'Silo'], 26 | 'Terril' => [:other, 'Spoil heap / Teril'], 27 | 'Torchère' => [:chimney, 'Flare / Torchère'], 28 | 'Tour' => [:tower], 29 | 'Treillis métallique' => [:other, 'Metallic grid / Treillis métallique'] 30 | }.freeze 31 | 32 | def parse 33 | if AIPP.options.region_options.include? 'lf_obstacles_xlsx' 34 | info("reading obstacles from XLSX") 35 | @xlsx = read('Obstacles') 36 | parse_from_xlsx 37 | else 38 | parse_from_xml 39 | end 40 | end 41 | 42 | private 43 | 44 | def parse_from_xlsx 45 | # Build obstacles 46 | @xlsx.sheet(@xlsx.sheets.find(/^data/i).first).each( 47 | name: 'IDENTIFICATEUR', 48 | type: 'TYPE', 49 | count: 'NOMBRE', 50 | longitude: 'LONGITUDE DECIMALE', 51 | latitude: 'LATITUDE DECIMALE', 52 | elevation: 'ALTITUDE AU SOMMET', 53 | height: 'HAUTEUR HORS SOL', 54 | height_unit: 'UNITE', 55 | horizontal_accuracy: 'PRECISION HORIZONTALE', 56 | vertical_accuracy: 'PRECISION VERTICALE', 57 | visibility: 'BALISAGE', 58 | remarks: 'REMARK', 59 | effective_on: 'DATE DE MISE EN VIGUEUR' 60 | ).with_index(0) do |row, index| 61 | next unless row[:effective_on].to_s.match? /\d{8}/ 62 | type, type_remarks = TYPES.fetch(row[:type]) 63 | count = row[:count].to_i 64 | obstacle = AIXM.obstacle( 65 | source: source(part: 'ENR', position: index), 66 | name: row[:name], 67 | type: type, 68 | xy: AIXM.xy(lat: row[:latitude].to_f, long: row[:longitude].to_f), 69 | z: AIXM.z(row[:elevation].to_i, :qnh) 70 | ).tap do |obstacle| 71 | obstacle.height = AIXM.d(row[:height].to_i, row[:height_unit]) 72 | if row[:horizontal_accuracy] 73 | accuracy = row[:horizontal_accuracy].split 74 | obstacle.xy_accuracy = AIXM.d(accuracy.first.to_i, accuracy.last) 75 | end 76 | if row[:vertical_accuracy] 77 | accuracy = row[:horizontal_accuracy].split 78 | obstacle.z_accuracy = AIXM.d(accuracy.first.to_i, accuracy.last) 79 | end 80 | obstacle.marking = row[:visibility].match?(/jour/i) 81 | obstacle.lighting = row[:visibility].match?(/nuit/i) 82 | obstacle.remarks = { 83 | 'type' => type_remarks, 84 | 'number/nombre' => (count if count > 1), 85 | 'details' => row[:remarks], 86 | 'effective/mise en vigueur' => (row[:effective_on].to_s.unpack("a4a2a2").join("-") if row[:updated_on]) 87 | }.to_remarks 88 | # Group obstacles 89 | if aixm.features.find_by(:obstacle, xy: obstacle.xy).any? 90 | warn("duplicate obstacle #{obstacle.name}", severe: false) 91 | else 92 | if count > 1 93 | obstacle_group = AIXM.obstacle_group( 94 | source: obstacle.source, 95 | name: obstacle.name 96 | ).tap do |obstacle_group| 97 | obstacle_group.remarks = "#{count} obstacles" 98 | end 99 | obstacle_group.add_obstacle obstacle 100 | add obstacle_group 101 | else 102 | add obstacle 103 | end 104 | end 105 | end 106 | end 107 | end 108 | 109 | def parse_from_xml 110 | AIPP.cache.obstacle.css(%Q(Obstacle[lk^="[LF]"])).each do |node| 111 | # Build obstacles 112 | type, type_remarks = TYPES.fetch(node.(:TypeObst)) 113 | count = node.(:Combien).to_i 114 | obstacle = AIXM.obstacle( 115 | source: source(part: 'ENR', position: node.line), 116 | name: node.(:NumeroNom), 117 | type: type, 118 | xy: xy_from(node.(:Geometrie)), 119 | z: AIXM.z(node.(:AmslFt).to_i, :qnh) 120 | ).tap do |obstacle| 121 | obstacle.height = AIXM.d(node.(:AglFt).to_i, :ft) 122 | obstacle.marking = node.(:Balisage).match?(/jour/i) 123 | obstacle.lighting = node.(:Balisage).match?(/nuit/i) 124 | obstacle.remarks = { 125 | 'type' => type_remarks, 126 | 'number/nombre' => (count if count > 1) 127 | }.to_remarks 128 | end 129 | # Group obstacles 130 | if aixm.features.find_by(:obstacle, xy: obstacle.xy).any? 131 | warn("duplicate obstacle #{obstacle.name}", severe: false) 132 | else 133 | if count > 1 134 | obstacle_group = AIXM.obstacle_group( 135 | source: obstacle.source, 136 | name: obstacle.name 137 | ).tap do |obstacle_group| 138 | obstacle_group.remarks = "#{count} obstacles" 139 | end 140 | obstacle_group.add_obstacle obstacle 141 | add obstacle_group 142 | else 143 | add obstacle 144 | end 145 | end 146 | end 147 | end 148 | 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /lib/aipp/regions/LF/aip/serviced_airspaces.rb: -------------------------------------------------------------------------------- 1 | module AIPP::LF::AIP 2 | class ServicedAirspaces < AIPP::AIP::Parser 3 | 4 | include AIPP::LF::Helpers::Base 5 | 6 | depends_on :Aerodromes 7 | 8 | # Map source types to type and optional local type and skip regexp 9 | SOURCE_TYPES = { 10 | 'FIR' => { type: 'FIR' }, 11 | 'UIR' => { type: 'UIR' }, 12 | 'UTA' => { type: 'UTA' }, 13 | 'CTA' => { type: 'CTA' }, 14 | 'LTA' => { type: 'CTA', local_type: 'LTA' }, 15 | 'TMA' => { type: 'TMA', skip: /geneve/i }, # Geneva listed FYI only 16 | 'SIV' => { type: 'SECTOR', local_type: 'FIZ/SIV' }, # providing FIS 17 | 'CTR' => { type: 'CTR' }, 18 | 'RMZ' => { type: 'RMZ' }, 19 | 'TMZ' => { type: 'TMZ' }, 20 | 'RMZ-TMZ' => { type: ['RMZ', 'TMZ'] } # two separate airspaces 21 | }.freeze 22 | 23 | # Map airspace " " to location indicator 24 | FIR_LOCATION_INDICATORS = { 25 | 'BORDEAUX' => 'LFBB', 26 | 'BREST' => 'LFRR', 27 | 'MARSEILLE' => 'LFMM', 28 | 'PARIS' => 'LFFF', 29 | 'REIMS' => 'LFRR' 30 | }.freeze 31 | 32 | DELEGATED_RE = /(?:deleg\.|delegated|delegation)/i.freeze 33 | 34 | def parse 35 | SOURCE_TYPES.each do |source_type, target| 36 | verbose_info("processing #{source_type}") 37 | AIPP.cache.espace.css(%Q(Espace[lk^="[LF][#{source_type} "])).each do |espace_node| 38 | next if espace_node.(:Nom).match? DELEGATED_RE 39 | next if (re = target[:skip]) && espace_node.(:Nom).match?(re) 40 | # Build airspaces and layers 41 | partie_nodes = AIPP.cache.partie.css(%Q(Partie:has(Espace[pk="#{espace_node['pk']}"]))) 42 | partie_nodes.each_with_index do |partie_node, index| 43 | next if partie_node.(:NomPartie).match? DELEGATED_RE 44 | partie_nom = partie_node.(:NomPartie).remove(/^\.$/).blank_to_nil 45 | partie_index = if partie_nodes.count > 1 46 | if partie_nom.match?(/^\d+$/) 47 | partie_nom.to_i # use declared index if numerical... 48 | else 49 | index # ...or positional index otherwise 50 | end 51 | end 52 | [target[:type]].flatten.each do |type| 53 | add( 54 | AIXM.airspace( 55 | source: source(part: 'ENR', position: espace_node.line), 56 | id: id_from(espace_node, partie_index), 57 | name: name_from(espace_node, partie_nom), 58 | type: type, 59 | local_type: target[:local_type] 60 | ).tap do |airspace| 61 | airspace.meta = espace_node.attr('pk') 62 | airspace.geometry = geometry_from(partie_node.(:Contour)) 63 | fail("geometry is not closed") unless airspace.geometry.closed? 64 | AIPP.cache.volume.css(%Q(Volume:has(Partie[pk="#{partie_node['pk']}"]))).each do |volume_node| 65 | airspace.add_layer( 66 | layer_from(volume_node).tap do |layer| 67 | layer.location_indicator = FIR_LOCATION_INDICATORS.fetch(airspace.name) if airspace.type == :flight_information_region 68 | end 69 | ) 70 | end 71 | end 72 | ) 73 | end 74 | end 75 | end 76 | end 77 | end 78 | 79 | private 80 | 81 | def id_from(espace_node, partie_index) 82 | if espace_node.(:TypeEspace) == 'CTR' && 83 | (ad_pk = espace_node.at_css(:AdAssocie)&.attr('pk')) && 84 | (airport = find_by(:airport, meta: ad_pk).first) 85 | then 86 | [airport.id, partie_index].join 87 | end 88 | end 89 | 90 | def name_from(espace_node, partie_nom) 91 | [espace_node.(:Nom), partie_nom].compact.join(' ') 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/aipp/regions/LF/helpers/surface.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | module LF 3 | module Helpers 4 | module Surface 5 | 6 | # Map surface to OFMX composition, preparation and remarks 7 | SURFACES = { 8 | /^revêtue?$/ => { preparation: :paved }, 9 | /^non revêtue?$/ => { preparation: :natural }, 10 | 'macadam' => { composition: :macadam }, 11 | /^bitume ?(traité|psp)?$/ => { composition: :bitumen }, 12 | 'ciment' => { composition: :concrete, preparation: :paved }, 13 | /^b[eéè]ton ?(armé|bitume|bitumeux|bitumineux)?$/ => { composition: :concrete, preparation: :paved }, 14 | /^béton( de)? ciment$/ => { composition: :concrete, preparation: :paved }, 15 | 'béton herbe' => { composition: :concrete_and_grass }, 16 | 'béton avec résine' => { composition: :concrete, preparation: :paved, remarks: 'Avec résine / with resin' }, 17 | "béton + asphalte d'étanchéité sablé" => { composition: :concrete_and_asphalt, preparation: :paved, remarks: 'Étanchéité sablé / sandblasted waterproofing' }, 18 | 'béton armé + support bitumastic' => { composition: :concrete, preparation: :paved, remarks: 'Support bitumastic / bitumen support' }, 19 | /résine (époxy )?su[er] béton/ => { composition: :concrete, preparation: :paved, remarks: 'Avec couche résine / with resin seal coat' }, 20 | /^(asphalte|tarmac)$/ => { composition: :asphalt, preparation: :paved }, 21 | 'enrobé' => { preparation: :other, remarks: 'Enrobé / coated' }, 22 | 'enrobé anti-kérozène' => { preparation: :other, remarks: 'Enrobé anti-kérozène / anti-kerosene coating' }, 23 | /^enrobé bitum(e|iné|ineux)$/ => { composition: :bitumen, preparation: :paved, remarks: 'Enrobé / coated' }, 24 | 'enrobé béton' => { composition: :concrete, preparation: :paved, remarks: 'Enrobé / coated' }, 25 | /^résine( époxy)?$/ => { composition: :other, remarks: 'Résine / resin' }, 26 | 'tole acier larmé' => { composition: :metal, preparation: :grooved }, 27 | /^(structure métallique|structure et caillebotis métallique|aluminium)$/ => { composition: :metal }, 28 | 'matériaux composites ignifugés' => { composition: :other, remarks: 'Matériaux composites ignifugés / fire resistant mixed materials' }, 29 | /^(gazon|herbe)$/ => { composition: :grass }, 30 | 'neige' => { composition: :snow }, 31 | 'neige damée' => { composition: :snow, preparation: :rolled }, 32 | 'surface en bois' => { composition: :wood } 33 | }.freeze 34 | 35 | def surface_from(node) 36 | AIXM.surface.tap do |surface| 37 | SURFACES.metch(node.(:Revetement), default: {}).tap do |surface_attributes| 38 | surface.composition = surface_attributes[:composition] 39 | surface.preparation = surface_attributes[:preparation] 40 | surface.remarks = surface_attributes[:remarks] 41 | end 42 | surface.pcn = node.(:Resistance)&.first_match(AIXM::PCN_RE) 43 | end 44 | end 45 | 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/aipp/regions/LF/helpers/usage_limitation.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | module LF 3 | module Helpers 4 | module UsageLimitation 5 | 6 | # Map limitation type descriptions to AIXM limitation, realm and remarks 7 | LIMITATION_TYPES = { 8 | 'OFF' => nil, # skip decommissioned aerodromes/helistations 9 | 'CAP' => { limitation: :permitted, realm: :civilian }, 10 | 'ADM' => { limitation: :permitted, realm: :other, remarks: "Goverment ACFT only / Réservé aux ACFT de l'État" }, 11 | 'MIL' => { limitation: :permitted, realm: :military }, 12 | 'PRV' => { limitation: :reservation_required, realm: :civilian }, 13 | 'RST' => { limitation: :reservation_required, realm: :civilian }, 14 | 'TPD' => { limitation: :reservation_required, realm: :civilian } 15 | }.freeze 16 | 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/aipp/regions/LS/README.md: -------------------------------------------------------------------------------- 1 | # LS – Switzerland 2 | 3 | ## NOTAM API 4 | 5 | The NOTAM messages are fetched from the Neway API which is a **non-public** 6 | GraphQL API. 7 | 8 | You have to set the following environment variables: 9 | 10 | * `NEWAY_API_URL` – API endpoint 11 | * `NEWAY_API_AUTHORIZATION` – the bearer authentication token 12 | 13 | The following query object shows all parameters and columns: 14 | 15 | ``` 16 | { 17 | queryNOTAMs( 18 | filter: { 19 | country: "CHE", # country as detected by ICAO 20 | series: ["W", "B"], # NOTAM series (first letter of name) 21 | region: "LS", # FIR region (extracted from name) 22 | start: 1651449600, # time window begins (UTC timestamp) 23 | end: 1651535999 # time window ends (UTC timestamp) 24 | } 25 | ) { 26 | id # internal ID 27 | name # NOTAM name 28 | notamRaw # raw NOTAM message 29 | series # NOTAM series (first letter of name) 30 | region # FIR region (extracted from name) 31 | country # country as detected by ICAO 32 | area # NOTAM topic area 33 | effectiveFrom # validity begins (UTC timestamp) 34 | validUntil # validity ends (UTC timestamp) 35 | } 36 | } 37 | ``` 38 | 39 | ## SHOOT API 40 | 41 | Geometries and most shooting details are available on the [geoinformation portal](https://geo.admin.ch) managed by GKG/swisstopo. However, all details as well as a list of active shooting ranges is only available in the original `schiessanzeigen.csv` file compiled by the Swiss army and distributed on the portal as well: 42 | 43 | * [geo.admin.ch JSON API](https://api.geo.admin.ch/services/sdiservices.html) [(example)](https://api3.geo.admin.ch/rest/services/api/MapServer/ch.vbs.schiessanzeigen/1201.050?sr=4326&geometryFormat=geojson) 44 | * [schiessanzeigen.csv](https://data.geo.admin.ch/ch.vbs.schiessanzeigen/schiessanzeigen/schiessanzeigen.csv) 45 | 46 | As of Feburary 2023, the structure of the CSV is as follows: 47 | 48 | ### Shooting Ground Description Records 49 | 50 | Row | Col | Attribute | Content | Mand | Remarks 51 | ----|-----|------------|-----------|------|-------- 52 | SPL | 0 | Row | text(3) | yes | **"SPL" (aka: Schiessplatz Stammdaten)** 53 | SPL | 1 | Belplan ID | text(8) | yes | **ID of the shooting ground from Belplan as "nnnn.nnn" (equal to module# and object#) e.g. "3104.010"** 54 | SPL | 2 | arimmo ID | text(10) | yes | ID of the shooting ground from arimmo e.g. "04.203" 55 | SPL | 3 | Name | text(50) | yes | **Name of the shooting ground, e.g. "DAMMASTOCK / SUSTENHORN"** 56 | SPL | 4 | URL DE | text(100) | yes | Info URL in DE from CMS-VBS 57 | SPL | 5 | URL FR | text(100) | yes | Info URL in FR from CMS-VBS 58 | SPL | 6 | URL IT | text(100) | yes | Info URL in IT from CMS-VBS 59 | SPL | 7 | URL EN | text(100) | yes | **Info URL in EN from CMS-VBS** 60 | SPL | 8 | Info name | text(100) | yes | Name of the info point e.g. "Koordinationsstelle Terreg 3, Altdorf" 61 | SPL | 9 | Info phone | text(20) | yes | **Phone of the info point** 62 | SPL | 10 | Info email | text(100) | no   | **Email of the info point** 63 | 64 | ### Shooting Ground Activity Records 65 | 66 | Row | Col | Attribute | Content | Mand | Remarks 67 | ----|-----|----------------|----------------|------|-------- 68 | BSZ | 0 | Row | text(3) | yes | **"BSZ" (aka: Belegungszeiten eines SPL)** 69 | BSZ | 1 | Belplan ID | text(8) | yes | **ID of the shooting ground from Belplan as "nnnn.nnn" (equal to module# and object#) e.g. "3104.010"** 70 | BSZ | 2 | Act date | date(yyyymmdd) | yes | **Datum of activity** 71 | BSZ | 3 | Act time from | time(hhmm) | no   | **Time when activity begins** 72 | BSZ | 4 | Act time until | time(hhmm) | no   | **Time when activity ends** 73 | BSZ | 5 | Locations | text(50) | no   | Locations of shooting activity [R2, (max 50 char)] 74 | BSZ | 6 | Remarks | text(100) | no   | Remarks [R2] 75 | BSZ | 7 | URL DE | text(200) | no   | Announcement URL DE [R2] 76 | BSZ | 8 | URL FR | text(200) | no   | Announcement URL FR [R2] 77 | BSZ | 9 | URL IT | text(200) | no   | Announcement URL IT [R2] 78 | BSZ | 10 | URL EN | text(200) | no   | **Announcement URL EN [R2]** 79 | BSZ | 11 | Unit | text(120) | no   | Military unit involved [R2] 80 | BSZ | 12 | Weapons | text(50) | no   | Weapons and ammunition involved [R2] 81 | BSZ | 13 | Positions | text(50) | no   | Shooting positions [R2] 82 | BSZ | 14 | Coordinates | text(25) | no   | Shooting coordinates [R2] 83 | BSZ | 15 | Vertex height | number | no   | **Max height of activity [R2]** 84 | BSZ | 16 | DABS | boolean | no   | **Relevant for DABS (formerly KOSIF): 0=no, 1=yes [R2]** 85 | BSZ | 17 | No shooting | boolean | no   | **0=no, 1=yes [R2]** 86 | 87 | ### Total Records 88 | 89 | Row | Col | Attribute | Content | Mand | Remarks 90 | ----|-----|------------|---------------------------|------|-------- 91 | TOT | 0 | Row | text(3) | yes | "TOT" (aka: Total) 92 | TOT | 1 | Created at | datetime(yyyymmddhhmmss) | yes | Date and time of CSV creation 93 | TOT | 2 | SPL count | number | yes | Number of SPL records 94 | 95 | ### Safety Margins 96 | 97 | The max height (BSZ col 15) has to be treated as an advisory value, not a guarantee: 98 | 99 | If no value is given, the ammunition *should* not exceed 250m above ground. However, regulations for certain ammunition types allow for higher peaks. As of March 2023, 6cm mortars *may* reach up to 500m and future weapon systems *may* even go beyond that. 100 | 101 | To account for this, the two constants `DEFAULT_Z` and `SAFETY` should be revisited from time to time. 102 | 103 | ## Asynchronous Use 104 | 105 | ### Command Line Arguments 106 | 107 | When used asynchronously, the following command line arguments should be considered: 108 | 109 | * `-c` – clear cache (mandatory to create builds more often than once per hour) 110 | * `-f` – continue on non-fatal errors such as if the validation fails 111 | * `-q` – suppress all informational output 112 | * `-x` – crossload fixed versions of malformed NOTAM 113 | 114 | ### Monitoring 115 | 116 | Any output on STDERR should trigger an alert. 117 | 118 | ## References 119 | 120 | * [skybriefing](https://www.skybriefing.com) 121 | * [skybriefing contact (aka: LS NOF)](https://www.skybriefing.com/support) 122 | * [AIM Data Catalogue](https://www.aerodatacat.ch) 123 | * [DABS](https://www.skybriefing.com/de/dabs) 124 | * [NOTAM Info](https://notaminfo.com/switzerlandmap) 125 | -------------------------------------------------------------------------------- /lib/aipp/regions/LS/helpers/base.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | module NewayAPI 3 | HttpAdapter = GraphQL::Client::HTTP.new(ENV['NEWAY_API_URL']) do 4 | def headers(context) 5 | { "Authorization": "Bearer #{ENV['NEWAY_API_AUTHORIZATION']}" } 6 | end 7 | end 8 | Schema = GraphQL::Client.load_schema(HttpAdapter) 9 | Client = GraphQL::Client.new(schema: Schema, execute: HttpAdapter) 10 | 11 | class Notam 12 | Query = Client.parse <<~END 13 | query ($region: String!, $series: [String!], $start: Int!, $end: Int!) { 14 | latestUpdate, 15 | queryNOTAMs( 16 | filter: {region: $region, series: $series, start: $start, end: $end} 17 | ) { 18 | notamRaw 19 | } 20 | } 21 | END 22 | end 23 | end 24 | 25 | module LS 26 | module Helpers 27 | module Base 28 | 29 | using AIXM::Refinements 30 | 31 | # Mandatory Interface 32 | 33 | def origin_for(document) 34 | case document 35 | when 'ENR' 36 | variables = { 37 | region: 'LS', 38 | series: %w(W B), 39 | start: aixm.effective_at.beginning_of_day.to_i, 40 | end: aixm.expiration_at.to_i 41 | } 42 | verbose_info("Querying API with #{variables}") 43 | AIPP::Downloader::GraphQL.new( 44 | client: AIPP::NewayAPI::Client, 45 | query: AIPP::NewayAPI::Notam::Query, 46 | variables: variables 47 | ) 48 | when 'AD' 49 | fail "not yet implemented" 50 | when 'AIP' 51 | AIPP::Downloader::HTTP.new( 52 | file: "https://snapshots.openflightmaps.org/live/#{AIRAC::Cycle.new.id}/ofmx/lsas/latest/isolated/ofmx_ls.xml" 53 | ) 54 | when 'DABS' 55 | if aixm.effective_at.to_date == Time.now.utc.to_date # DABS cross check works reliably for today only 56 | AIPP::Downloader::HTTP.new( 57 | file: "https://www.skybriefing.com/o/dabs?today", 58 | type: :pdf 59 | ) 60 | end 61 | when 'shooting_grounds' 62 | AIPP::Downloader::HTTP.new( 63 | file: "https://data.geo.admin.ch/ch.vbs.schiessanzeigen/schiessanzeigen/schiessanzeigen.csv", 64 | type: :csv 65 | ) 66 | when /^shooting_grounds-(\d+\.\d+)/ 67 | AIPP::Downloader::HTTP.new( 68 | file: "https://api3.geo.admin.ch/rest/services/api/MapServer/ch.vbs.schiessanzeigen/#{$1}?sr=4326&geometryFormat=geojson", 69 | type: :json 70 | ) 71 | else 72 | fail "document not recognized" 73 | end 74 | end 75 | 76 | # Templates 77 | 78 | def organisation_ls 79 | unless AIPP.cache.organisation_ls 80 | AIPP.cache.organisation_ls = AIXM.organisation( 81 | source: source(position: 1, document: "GEN-3.1"), 82 | name: 'SWITZERLAND', 83 | type: 'S' 84 | ).tap do |organisation| 85 | organisation.id = 'LS' 86 | end 87 | add AIPP.cache.organisation_ls 88 | end 89 | AIPP.cache.organisation_ls 90 | end 91 | 92 | # Parserettes 93 | 94 | def timetable_from(schedules) 95 | AIXM.timetable.tap do |timetable| 96 | schedules&.each do |schedule| 97 | schedule.actives.each do |actives| 98 | schedule.times.each do |times| 99 | timesheet = AIXM.timesheet( 100 | adjust_to_dst: false, 101 | dates: (actives.instance_of?(Range) ? actives : (actives..actives)) 102 | # TODO: transform to... 103 | # dates: actives 104 | ) 105 | timesheet.times = times 106 | timetable.add_timesheet timesheet 107 | end 108 | end 109 | end 110 | end 111 | end 112 | 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/aipp/regions/LS/notam/ENR.rb: -------------------------------------------------------------------------------- 1 | using AIXM::Refinements 2 | 3 | module AIPP::LS::NOTAM 4 | class ENR < AIPP::NOTAM::Parser 5 | 6 | include AIPP::LS::Helpers::Base 7 | 8 | def parse 9 | AIPP.cache.aip ||= read('AIP').css('Ase') 10 | AIPP.cache.dabs ||= read('DABS') 11 | json = read 12 | fail "malformed JSON received from API" unless json.has_key?(:queryNOTAMs) 13 | added_notam_ids = [] 14 | aixm.sourced_at = Time.at(json[:latestUpdate]) 15 | json[:queryNOTAMs].each do |row| 16 | next unless row[:notamRaw].match? /^Q\) LS/ # only parse national NOTAM 17 | 18 | # HACK: try to add missing commas to D-item of A- and B-series NOTAM 19 | # if row[:notamRaw].match? /\A[AB]/ 20 | # if row[:notamRaw].gsub!(/(#{NOTAM::Schedule::HOUR_RE.decapture}-#{NOTAM::Schedule::HOUR_RE.decapture})/, '\1,') 21 | # row[:notamRaw].gsub!(/,+/, ',') 22 | # row[:notamRaw].sub!(/,\n/, "\n") 23 | # warn("HACK: added missing commas to D item") 24 | # end 25 | # end 26 | 27 | # HACK: remove braindead years from D-item of W-series NOTAM 28 | if row[:notamRaw].match? /\AW/ 29 | year = Time.now.year 30 | if row[:notamRaw].gsub!(/\s*(?:#{year}|#{year+1})\s*(#{NOTAM::Schedule::MONTH_RE})/, ' \1') 31 | warn("HACK: removed braindead years from D item") 32 | end 33 | end 34 | 35 | (notam = notam_for(row[:notamRaw])) or next 36 | if respect? notam 37 | next if notam.data[:five_day_schedules] == [] 38 | added_notam_ids << notam.data[:id] 39 | add( 40 | case notam.data[:content] 41 | when /\A[DR].AREA.+ACT/, /TMA.+ACT/ 42 | if fragment = fragment_for(notam) 43 | AIXM.generic(fragment: fragment_for(notam)).tap do |airspace| 44 | element = airspace.fragment.children.first 45 | element.prepend_child([''].join("\n")) 46 | content = ["NOTAM #{notam.data[:id]}", element.at_css('txtName').content].join(": ").strip 47 | element.at_css('txtName').content = content 48 | content = [element.at_css('txtRmk')&.text, notam.data[:translated_content]].join("\n").strip 49 | element.find_or_add_child('txtRmk').content = content 50 | if schedule = notam.data[:five_day_schedules] 51 | timetable = timetable_from(schedule) 52 | element 53 | .find_or_add_child('Att', before_css: %w(codeSelAvbl txtRmk)) 54 | .replace(timetable.to_xml(as: :Att).chomp) 55 | end 56 | end 57 | else 58 | warn "no feature found for `#{notam.data[:content]}' - fallback to point and radius" 59 | airspace_from(notam).tap do |airspace| 60 | airspace.geometry = geometry_from_q_item(notam) 61 | end 62 | end 63 | when /\ATEMPO [DR].AREA.+(?:ACT|EST|ESTABLISHED) WI AREA/ 64 | airspace_from(notam).tap do |airspace| 65 | airspace.geometry = geometry_from_content(notam) 66 | end 67 | else 68 | airspace_from(notam).tap do |airspace| 69 | airspace.geometry = geometry_from_q_item(notam) 70 | end 71 | end 72 | ) 73 | else 74 | verbose_info("Skipping NOTAM #{notam.data[:id]}") 75 | end 76 | end 77 | dabs_cross_check(added_notam_ids) 78 | end 79 | 80 | private 81 | 82 | def notam_for(raw_notam) 83 | notam_id = raw_notam.strip.split(/\s+/, 2).first 84 | if AIPP.options.crossload 85 | crossload_file = AIPP.options.crossload.join('LS', "#{notam_id.sub('/', '_')}.txt") 86 | if File.exist? crossload_file 87 | info("crossloading #{crossload_file}") 88 | return NOTAM.parse(crossload_file.read) 89 | end 90 | end 91 | NOTAM.parse(raw_notam) 92 | rescue 93 | warn "cannot parse #{notam_id}" 94 | raise unless AIPP.options.force 95 | end 96 | 97 | # @return [Boolean] whether to respect this NOTAM or ignore it 98 | def respect?(notam) 99 | notam.data[:condition] != :checklist && ( 100 | notam.data[:scope].include?(:navigation_warning) || 101 | %i(terminal_control_area).include?(notam.data[:subject]) # TODO: include :obstacle as well 102 | ) 103 | end 104 | 105 | def fragment_for(notam) 106 | case notam.data[:content] 107 | when /(?TMA) ((SECT )?(?
\d+) )?ACT/ 108 | 'Ase:has(codeType:contains("%s") + codeId:contains("%s %s"))' % [$~['type'], notam.data[:locations].first, $~['section']] 109 | when /[DR].AREA LS-?(?[DR]\d+[A-Z]?).+ACT/ 110 | 'Ase:has(codeId:matches("^LS%s( .+)?$"))' % [$~['name']] 111 | else 112 | return 113 | end.then do |selector| 114 | AIPP.cache.aip.at_css(selector, Nokogiri::MATCHES) 115 | end 116 | end 117 | 118 | def airspace_from(notam) 119 | AIXM.airspace( 120 | id: notam.data[:id], 121 | type: :regulated_airspace, 122 | name: "NOTAM #{notam.data[:id]}" 123 | ).tap do |airspace| 124 | airspace.add_layer( 125 | AIXM.layer( 126 | vertical_limit: AIXM.vertical_limit( 127 | upper_z: notam.data[:upper_limit], 128 | lower_z: notam.data[:lower_limit] 129 | ) 130 | ).tap do |layer| 131 | layer.selective = true 132 | if schedule = notam.data[:five_day_schedules] 133 | layer.timetable = timetable_from(schedule) 134 | end 135 | layer.remarks = notam.data[:translated_content] 136 | end 137 | ) 138 | airspace.comment = notam.text 139 | end 140 | end 141 | 142 | def geometry_from_content(notam) 143 | if notam.data[:content].squish.match(/WI AREA(?(?: \d{6}N\d{7}E)+)/) 144 | AIXM.geometry.tap do |geometry| 145 | $~['coordinates'].split.each do |coordinate| 146 | xy = AIXM.xy(lat: coordinate[0, 7], long: coordinate[7, 8]) 147 | geometry.add_segment(AIXM.point(xy: xy)) 148 | end 149 | end 150 | else 151 | warn "cannot parse WI AREA - fallback to point and radius" 152 | geometry_from_q_item(notam) 153 | end 154 | end 155 | 156 | def geometry_from_q_item(notam) 157 | AIXM.geometry.tap do |geometry| 158 | geometry.add_segment AIXM.circle( 159 | center_xy: notam.data[:center_point], 160 | radius: notam.data[:radius] 161 | ) 162 | end 163 | end 164 | 165 | def obstacle_from(notam) 166 | # TODO: implement obstacle 167 | end 168 | 169 | def dabs_cross_check(added_notam_ids) 170 | dabs_date = aixm.effective_at.to_date.strftime("DABS Date: %Y %^b %d") 171 | case 172 | when AIPP.cache.dabs.nil? 173 | warn("DABS not available - skipping cross check") 174 | when !AIPP.cache.dabs.text.include?(dabs_date) 175 | warn("DABS date mismatch - skippping cross check") 176 | else 177 | dabs_notam_ids = AIPP.cache.dabs.text.scan(NOTAM::Item::ID_RE.decapture).uniq 178 | missing_notam_ids = dabs_notam_ids - added_notam_ids 179 | warn("DABS disagrees: #{missing_notam_ids.join(', ')} missing") if missing_notam_ids.any? 180 | end 181 | end 182 | 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /lib/aipp/regions/LS/shoot/shooting_grounds.rb: -------------------------------------------------------------------------------- 1 | using AIXM::Refinements 2 | 3 | module AIPP::LS::SHOOT 4 | class ShootingGrounds < AIPP::SHOOT::Parser 5 | 6 | include AIPP::LS::Helpers::Base 7 | 8 | DEFAULT_Z = AIXM.z(2000, :qfe) # fallback if no max height is defined 9 | SAFETY = 100 # safety margin in meters added to max height 10 | 11 | def parse 12 | effective_date = AIPP.options.local_effective_at.strftime('%Y%m%d') 13 | airac_date = AIRAC::Cycle.new(aixm.effective_at).to_s('%Y-%m-%d') 14 | shooting_grounds = {} 15 | spl_rows = 0 16 | read.each_with_index do |row, line| 17 | case row[0].strip 18 | when 'TOT' 19 | date, spl_count = row[1], row[2].to_i 20 | fail "malformed CSV: #{spl_count} SPL records announced but #{spl_rows} SPL records found" unless spl_count == spl_rows 21 | aixm.sourced_at = [date, TZInfo::Timezone.get('Europe/Zurich').abbr].join(' ') 22 | next 23 | when 'SPL' 24 | spl_rows += 1 25 | next 26 | when 'BSZ' 27 | id, date, no_shooting = row[1], row[2], (row[17] == "1") 28 | next if no_shooting || date != effective_date 29 | next if AIPP.options.id && AIPP.options.id != id 30 | # TODO: Several BSZ lines may exist for the same shooting area with 31 | # different location codes (aka: partial activations). The geometries 32 | # of those location codes are not currently available, we therefore 33 | # have to merge the data into one record. The geometries should become 34 | # available by the end of 2023 which will make it possible to map 35 | # each line to one geometry and remove the merging logic. 36 | upper_z = row[15] ? AIXM.z(AIXM.d(row[15].to_i + SAFETY, :m).to_ft.dim.round, :qfe) : DEFAULT_Z 37 | (shooting_grounds[id] ||= { schedules: [], upper_z: AIXM::GROUND }).then do |s| 38 | s[:feature] ||= read("shooting_grounds-#{id}").fetch(:feature) 39 | s[:csv_line] ||= line 40 | s[:url] ||= row[10].blank_to_nil 41 | s[:details] = [s[:details], row[6].blank_to_nil].compact.join("\n") 42 | s[:dabs] ||= (row[16] == '1') 43 | s[:upper_z] = upper_z if upper_z.alt > s[:upper_z].alt 44 | s[:schedules] += schedules_for(row) 45 | end 46 | end 47 | end 48 | fail "malformed CSV: TOT record missing" unless aixm.sourced_at 49 | shooting_grounds.each do |id, data| 50 | data in csv_line:, details:, url:, upper_z:, schedules:, dabs:, feature: { geometry: polygons, properties: { bezeichnung: name, infobezeichnung: contact, infotelefonnr: phone, infoemail: email } } 51 | schedules = consolidate(schedules) 52 | if schedules.any? 53 | geometries = geometries_for polygons 54 | indexed = geometries.count > 1 55 | geometries.each_with_index do |geometry, index| 56 | remarks = { 57 | details: details, 58 | contact: contact, 59 | phone: phone, 60 | email: email, 61 | bulletin: url 62 | }.to_remarks 63 | add( 64 | AIXM.airspace( 65 | source: "LS|OTHER|schiessgebiete.csv|#{airac_date}|#{csv_line}", 66 | region: 'LS', 67 | type: :dangerous_activities_area, 68 | name: "LS-S#{id} #{name} #{index if indexed}".strip 69 | ).tap do |airspace| 70 | airspace.add_layer layer_for(upper_z, schedules, remarks) 71 | airspace.geometry = geometry 72 | airspace.comment = "DABS: marked for publication" if dabs 73 | end 74 | ) 75 | end 76 | end 77 | end 78 | end 79 | 80 | private 81 | 82 | def schedules_for(row) 83 | from, to = time_for(row[3]), time_for(row[4], ending: true) 84 | if from.to_date == to.to_date 85 | [ 86 | [AIXM.date(from), (AIXM.time(from)..AIXM.time(to))] 87 | ] 88 | else 89 | [ 90 | [AIXM.date(from), (AIXM.time(from)..AIXM::END_OF_DAY)], 91 | [AIXM.date(to), (AIXM::BEGINNING_OF_DAY..AIXM.time(to))] 92 | ] 93 | end 94 | end 95 | 96 | def time_for(string, ending: false) 97 | hour, min = case string.strip 98 | when /(\d{2})(\d{2})/ 99 | [$1.to_i, $2.to_i] 100 | when '', '0' 101 | [0, 0] 102 | else 103 | warn("ignoring malformed time `#{string}'") 104 | [0, 0] 105 | end 106 | hour = 24 if hour.zero? && min.zero? && ending 107 | AIPP.options.local_effective_at.change(hour: hour, min: min).utc 108 | end 109 | 110 | def geometries_for(polygons) 111 | fail "only type MultiPolygon supported" unless polygons[:type] == 'MultiPolygon' 112 | fail "polygon coordinates missing" unless polygons[:coordinates] 113 | polygons[:coordinates].map do |(outer_polygon, inner_polygon)| 114 | warn "hole in polygon is ignored" if inner_polygon 115 | AIXM.geometry( 116 | *outer_polygon.map { AIXM.point(xy: AIXM.xy(long: _1.first , lat: _1.last)) } 117 | ) 118 | end 119 | end 120 | 121 | def layer_for(upper_z, schedules, remarks) 122 | AIXM.layer( 123 | vertical_limit: AIXM.vertical_limit( 124 | upper_z: (upper_z || DEFAULT_Z), 125 | lower_z: AIXM::GROUND 126 | ) 127 | ).tap do |layer| 128 | layer.activity = :shooting_from_ground 129 | layer.timetable = timetable_for(schedules) 130 | layer.remarks = remarks 131 | end 132 | end 133 | 134 | def timetable_for(schedules) 135 | AIXM.timetable.tap do |timetable| 136 | schedules.each do |date, times_array| 137 | times_array.each do |times| 138 | timetable.add_timesheet( 139 | AIXM.timesheet( 140 | # HACK: Temporarily produce UTC instead of UTCW 141 | # adjust_to_dst: true, 142 | adjust_to_dst: false, 143 | dates: (date..date) 144 | # TODO: transform to... 145 | # dates: AIXM.date(date) 146 | ).tap do |timesheet| 147 | timesheet.times = times 148 | end 149 | ) 150 | end 151 | end 152 | end 153 | end 154 | 155 | # TODO: Consolidate will become obsolete once location code geometries 156 | # are available. 157 | def consolidate(schedules) 158 | schedules 159 | .group_by(&:first) 160 | .transform_values { _1.map(&:last).consolidate_ranges(:time) } 161 | end 162 | 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /lib/aipp/runner.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | 3 | # @abstract 4 | class Runner 5 | include AIPP::Debugger 6 | using AIXM::Refinements 7 | 8 | # @return [AIXM::Document] target document 9 | attr_reader :aixm 10 | 11 | def initialize 12 | AIPP.options.storage = AIPP.options.storage.join(AIPP.options.region, AIPP.options.scope.downcase) 13 | AIPP.options.storage.mkpath 14 | @dependencies = THash.new 15 | @aixm = AIXM.document(effective_at: effective_at, expiration_at: expiration_at) 16 | AIXM.send("#{AIPP.options.schema}!") 17 | AIXM.config.region = AIPP.options.region 18 | end 19 | 20 | # @return [String] 21 | def inspect 22 | "#<#{self.class}>" 23 | end 24 | 25 | # @abstract 26 | def effective_at 27 | fail "effective_at method must be implemented by module runner" 28 | end 29 | 30 | # @abstract 31 | def expiration_at 32 | nil 33 | end 34 | 35 | # @abstract 36 | def run 37 | fail "run method must be implemented by module runner" 38 | end 39 | 40 | # @return [Pathname] directory containing all files for the current region 41 | def region_dir 42 | Pathname(__FILE__).dirname.join('regions', AIPP.options.region) 43 | end 44 | 45 | # @return [String] sources file name (default: xmlschema representation 46 | # of effective_at date/time) 47 | def sources_file 48 | effective_at.xmlschema 49 | end 50 | 51 | def output_file 52 | "#{AIPP.options.region}_#{AIPP.options.scope}_#{effective_at.strftime('%F_%HZ')}.#{AIPP.options.schema}" 53 | end 54 | 55 | # @return [Pathname] directory containing the builds 56 | def builds_dir 57 | AIPP.options.storage.join('builds') 58 | end 59 | 60 | # @return [Pathname] config file for the current region 61 | def config_file 62 | AIPP.options.storage.join('config.yml') 63 | end 64 | 65 | private 66 | 67 | # Read the configuration from config.yml. 68 | def read_config 69 | info("using gem aipp-#{AIPP::VERSION}") 70 | info("reading config.yml") 71 | AIPP.config.read! config_file 72 | @aixm.namespace = AIPP.config.namespace 73 | end 74 | 75 | # Read the region directory. 76 | def read_region 77 | info("reading region #{AIPP.options.region}") 78 | fail("unknown region `#{AIPP.options.region}'") unless region_dir.exist? 79 | verbose_info "reading fixtures" 80 | AIPP.fixtures.read! region_dir.join('fixtures') 81 | verbose_info "reading borders" 82 | AIPP.borders.read! region_dir.join('borders') 83 | verbose_info "reading helpers" 84 | region_dir.glob('helpers/*.rb').each { |f| require f } 85 | end 86 | 87 | # Read parser files. 88 | def read_parsers 89 | verbose_info("reading parsers") 90 | region_dir.join(AIPP.options.scope.downcase).glob('*.rb').each do |file| 91 | verbose_info "requiring #{file.basename}" 92 | require file 93 | section = file.basename('.*').to_s.classify 94 | @dependencies[section] = class_for(section).dependencies 95 | end 96 | end 97 | 98 | # Parse sections by invoking the parser classes. 99 | def parse_sections 100 | AIPP::Downloader.new(storage: AIPP.options.storage, source: sources_file) do |downloader| 101 | @dependencies.tsort(AIPP.options.section).each do |section| 102 | info("parsing #{section.sectionize}") 103 | class_for(section).new( 104 | downloader: downloader, 105 | aixm: aixm 106 | ).attach_patches.tap(&:parse).detach_patches 107 | end 108 | end 109 | end 110 | 111 | # Validate the AIXM document. 112 | # 113 | # @raise [RuntimeError] if the document is not valid 114 | def validate_aixm 115 | info("detecting duplicates") 116 | if (duplicates = aixm.features.duplicates).any? 117 | message = "duplicates found" 118 | details = duplicates.map do |group| 119 | group.map.with_index do |member, index| 120 | "#{member.inspect} from #{member.source} #{'has duplicate(s)...' if index.zero?}" 121 | end 122 | end.join("\n") 123 | AIPP.options.force ? warn(message) : fail([message, details].join(":\n")) 124 | end 125 | info("validating #{AIPP.options.schema.upcase}") 126 | unless aixm.valid? 127 | message = "invalid #{AIPP.options.schema.upcase} document" 128 | details = aixm.errors.map(&:message).join("\n") 129 | AIPP.options.force ? warn(message) : fail([message, details].join(":\n")) 130 | end 131 | info("counting #{aixm.features.count} feature(s)") 132 | end 133 | 134 | # Write the AIXM document. 135 | def write_aixm(file) 136 | if aixm.features.any? || AIPP.options.write_empty 137 | info("writing #{file}") 138 | AIXM.config.mid = AIPP.options.mid 139 | File.write(file, aixm.to_xml) 140 | else 141 | info("no features to write") 142 | end 143 | end 144 | 145 | # Write build information. 146 | def write_build 147 | info("skipping build") 148 | end 149 | 150 | # Write the configuration to config.yml. 151 | def write_config 152 | info("writing config.yml") 153 | AIPP.config.write! config_file 154 | end 155 | 156 | def class_for(section) 157 | [:AIPP, AIPP.options.region, AIPP.options.scope, section.classify].constantize 158 | end 159 | end 160 | 161 | end 162 | -------------------------------------------------------------------------------- /lib/aipp/scopes/aip/README.md: -------------------------------------------------------------------------------- 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. 11 | -------------------------------------------------------------------------------- /lib/aipp/scopes/aip/executable.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | module AIP 3 | 4 | module Executable 5 | 6 | def options 7 | AIPP.options.merge( 8 | scope: 'AIP', 9 | airac: AIRAC::Cycle.new, 10 | region_options: [] 11 | ) 12 | end 13 | 14 | def option_parser(o) 15 | o.banner = <<~END 16 | Download online AIP and convert it to #{AIPP.options.schema.upcase}. 17 | Usage: #{File.basename($0)} [aip] [options] 18 | END 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 | end 25 | 26 | private 27 | 28 | def airac_for(argument) 29 | if argument.match?(/^[+-]\d+$/) # delta 30 | AIRAC::Cycle.new + argument.to_i 31 | else # date 32 | AIRAC::Cycle.new(argument) 33 | end 34 | end 35 | 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/aipp/scopes/aip/parser.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | module AIP 3 | 4 | # @abstract 5 | class Parser < AIPP::Parser 6 | end 7 | 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/aipp/scopes/aip/runner.rb: -------------------------------------------------------------------------------- 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 | if aixm.features.any? 21 | validate_aixm 22 | write_build 23 | end 24 | write_aixm(AIPP.options.output_file || output_file) 25 | write_config 26 | end 27 | 28 | private 29 | 30 | # Parse AIP by invoking the parser classes for the current region. 31 | def parse_sections 32 | super 33 | if AIPP.options.grouped_obstacles 34 | info("grouping obstacles") 35 | aixm.group_obstacles! 36 | end 37 | end 38 | 39 | # Write the AIXM document and context information. 40 | def write_build 41 | if AIPP.options.section 42 | super 43 | else 44 | info("writing build") 45 | builds_dir.mkpath 46 | build_file = builds_dir.join("#{AIPP.options.airac.date.xmlschema}.zip") 47 | Dir.mktmpdir do |tmp_dir| 48 | tmp_dir = Pathname(tmp_dir) 49 | # AIXM/OFMX file 50 | AIXM.config.mid = true 51 | File.write(tmp_dir.join(output_file), aixm.to_xml) 52 | # Build details 53 | File.write( 54 | tmp_dir.join('build.yaml'), { 55 | version: AIPP::VERSION, 56 | config: AIPP.config, 57 | options: AIPP.options, 58 | }.to_yaml 59 | ) 60 | # Manifest 61 | manifest = ['AIP','Feature', 'Comment', 'Short Uid Hash', 'Short Feature Hash'].to_csv 62 | manifest += aixm.features.map do |feature| 63 | xml = feature.to_xml 64 | element = xml.first_match(/<(\w{3})\s/) 65 | [ 66 | feature.source.split('|')[2], 67 | element, 68 | xml.match(//)[1], 69 | AIXM::PayloadHash.new(xml.match(%r(<#{element}Uid\s.*?)m).to_s).to_uuid[0,8], 70 | AIXM::PayloadHash.new(xml).to_uuid[0,8] 71 | ].to_csv 72 | end.sort.join 73 | File.write(tmp_dir.join('manifest.csv'), manifest) 74 | # Zip it 75 | build_file.delete if build_file.exist? 76 | Zip::File.open(build_file, Zip::File::CREATE) do |zip| 77 | tmp_dir.children.each do |entry| 78 | zip.add(entry.basename.to_s, entry) unless entry.basename.to_s[0] == '.' 79 | end 80 | end 81 | end 82 | end 83 | end 84 | end 85 | 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/aipp/scopes/notam/README.md: -------------------------------------------------------------------------------- 1 | # AIPP NOTAM Module 2 | 3 | ## Cache Time Window 4 | 5 | The default time window for NOTAM is the hour of day. This means: 6 | 7 | * Source data is downloaded and cached based on the hour of the day. 8 | * The effective date and time is rounded down to the previous full hour. 9 | 10 | To force a rebuild within this time window, you have to clean the cache using the `-c` command line argument. 11 | 12 | ### Soft Fail and Crossload 13 | 14 | Malformed NOTAM which cannot be processed normally cause the build to fail. The `-f` command line argument changes this behaviour to skip the malformed NOTAM, issue a warning and continue. 15 | 16 | To fix broken NOTAM, you can set a crossload directory with `-x`. The contents of this directory must adhere to the following convention: 17 | 18 | ``` 19 | / ⬅︎ custom crossload directory 20 | ├── LS ⬅︎ region 21 | │   └── W2479_22.txt ⬅︎ NOTAM ID (replace "/" with "_") 22 | └── ED ⬅︎ other region 23 | ``` 24 | 25 | If a matching file is found in the crossload directory, it will be used in place of the original, malformed NOTAM. 26 | -------------------------------------------------------------------------------- /lib/aipp/scopes/notam/executable.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | module NOTAM 3 | 4 | module Executable 5 | 6 | def options 7 | AIPP.options.merge( 8 | module: 'NOTAM', 9 | effective_at: Time.now.change(min: 0, sec: 0) 10 | ) 11 | end 12 | 13 | def option_parser(o) 14 | o.banner = <<~END 15 | Download online NOTAM and convert it to #{AIPP.options.schema.upcase}. 16 | Usage: #{File.basename($0)} notam [options] 17 | END 18 | o.on('-t', '--effective (TIME)', String, %Q[effective at this time (default: "#{AIPP.options.effective_at}")]) { AIPP.options.effective_at = Time.parse(_1) } 19 | o.on('-x', '--crossload DIR', String, 'crossload directory') { AIPP.options.crossload = Pathname(_1) } 20 | end 21 | 22 | def guard 23 | AIPP.options.effective_at = AIPP.options.effective_at.change(min: 0, sec: 0).utc 24 | end 25 | 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/aipp/scopes/notam/parser.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | module NOTAM 3 | 4 | # @abstract 5 | class Parser < AIPP::Parser 6 | end 7 | 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/aipp/scopes/notam/runner.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | module NOTAM 3 | 4 | class Runner < AIPP::Runner 5 | 6 | def effective_at 7 | AIPP.options.effective_at 8 | end 9 | 10 | def expiration_at 11 | effective_at.end_of_day.round - 1 12 | end 13 | 14 | def run 15 | info("NOTAM effective #{effective_at}", color: :green) 16 | read_config 17 | read_region 18 | read_parsers 19 | parse_sections 20 | if aixm.features.any? 21 | validate_aixm 22 | end 23 | write_aixm(AIPP.options.output_file || output_file) 24 | write_config 25 | end 26 | 27 | end 28 | 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/aipp/scopes/shoot/README.md: -------------------------------------------------------------------------------- 1 | # AIPP Shoot Module 2 | 3 | ## Cache Time Window 4 | 5 | The default time window for SHOOT is the day. This means: 6 | 7 | * Source data is downloaded and cached based on the day. 8 | * The effective date and time is rounded down to the previous midnight. 9 | 10 | To force a rebuild within this time window, you have to clean the cache using the `-c` command line argument. 11 | -------------------------------------------------------------------------------- /lib/aipp/scopes/shoot/executable.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | module SHOOT 3 | 4 | module Executable 5 | 6 | def options 7 | AIPP.options.merge( 8 | module: 'Shoot', 9 | local_effective_at: Time.now.at_midnight, 10 | id: nil 11 | ) 12 | end 13 | 14 | def option_parser(o) 15 | o.banner = <<~END 16 | Download online shooting activities and convert them to #{AIPP.options.schema.upcase}. 17 | Usage: #{File.basename($0)} shoot [options] 18 | END 19 | o.on('-t', '--effective (DATE)', String, %Q[effective on this date (default: "#{AIPP.options.local_effective_at.to_date}")]) { AIPP.options.local_effective_at = Time.parse("#{_1} CET") } 20 | o.on('-i', '--id ID', String, %Q[process shooting ground with this ID only]) { AIPP.options.id = _1 } 21 | end 22 | 23 | def guard 24 | AIPP.options.time_zone = AIPP.options.local_effective_at.at_noon.strftime('%z') 25 | AIPP.options.effective_at = AIPP.options.local_effective_at.at_midnight.utc 26 | end 27 | 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/aipp/scopes/shoot/parser.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | module SHOOT 3 | 4 | # @abstract 5 | class Parser < AIPP::Parser 6 | end 7 | 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/aipp/scopes/shoot/runner.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | module SHOOT 3 | 4 | class Runner < AIPP::Runner 5 | 6 | def effective_at 7 | AIPP.options.effective_at 8 | end 9 | 10 | def expiration_at 11 | effective_at + 86399 12 | end 13 | 14 | def run 15 | info("SHOOT effective #{effective_at}", color: :green) 16 | read_config 17 | read_region 18 | read_parsers 19 | parse_sections 20 | if aixm.features.any? 21 | validate_aixm 22 | end 23 | write_aixm(AIPP.options.output_file || output_file) 24 | write_config 25 | end 26 | 27 | end 28 | 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/aipp/t_hash.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | 3 | # Topologically sortable hash for dealing with dependencies 4 | # 5 | # Example: 6 | # dependency_hash = THash[ 7 | # dns: %i(net), 8 | # webserver: %i(dns logger), 9 | # net: [], 10 | # logger: [] 11 | # ] 12 | # # Sort to resolve dependencies of the entire hash 13 | # dependency_hash.tsort # => [:net, :dns, :logger, :webserver] 14 | # # Sort to resolve dependencies of one node only 15 | # dependency_hash.tsort(:dns) # => [:net, :dns] 16 | class THash < Hash 17 | include TSort 18 | 19 | alias_method :tsort_each_node, :each_key 20 | 21 | def tsort_each_child(node, &) 22 | fetch(node).each(&) 23 | end 24 | 25 | def tsort(node=nil) 26 | if node 27 | subhash = subhash_for node 28 | super().select { subhash.include? _1 } 29 | else 30 | super() 31 | end 32 | end 33 | 34 | private 35 | 36 | def subhash_for(node, memo=[]) 37 | memo.tap do |m| 38 | m << node 39 | (fetch(node) - m).each { subhash_for(_1, m) } 40 | end 41 | end 42 | 43 | end 44 | 45 | end 46 | -------------------------------------------------------------------------------- /lib/aipp/version.rb: -------------------------------------------------------------------------------- 1 | module AIPP 2 | VERSION = "2.3.1".freeze 3 | end 4 | -------------------------------------------------------------------------------- /lib/core_ext/array.rb: -------------------------------------------------------------------------------- 1 | class Array 2 | 3 | # Convert array of namespaces to constant. 4 | # 5 | # @example 6 | # %i(AIPP AIP Base).constantize # => AIPP::AIP::Base 7 | # 8 | # @return [Class, Module] converted array 9 | def constantize 10 | map(&:to_s).join('::').constantize 11 | end 12 | 13 | # Consolidate array of possibly overlapping ranges. 14 | # 15 | # @example 16 | # [15..17, 7..11, 12..13, 8..12, 12..13].consolidate_ranges 17 | # # => [7..13, 15..17] 18 | # 19 | # @param [Symbol, nil] method to call on range members for comparison 20 | # @return [Array] consolidated array 21 | def consolidate_ranges(method=:itself) 22 | uniq.sort_by { [_1.begin, _1.end] }.then do |ranges| 23 | consolidated, a = [], ranges.first 24 | Array(ranges[1..]).each do |b| 25 | if a.end.send(method) >= b.begin.send(method) # overlapping 26 | a = (a.begin..(a.end.send(method) > b.end.send(method) ? a.end : b.end)) 27 | else # not overlapping 28 | consolidated << a 29 | a = b 30 | end 31 | end 32 | consolidated << a 33 | end.compact 34 | end 35 | 36 | end 37 | -------------------------------------------------------------------------------- /lib/core_ext/enumerable.rb: -------------------------------------------------------------------------------- 1 | module Enumerable 2 | 3 | # !method split(object=nil, &block) 4 | # Divides an enumerable into sub-enumerables based on a delimiter, 5 | # returning an array of these sub-enumerables. 6 | # 7 | # @example 8 | # [1, 2, 0, 3, 4].split { _1 == 0 } # => [[1, 2], [3, 4]] 9 | # [1, 2, 0, 3, 4].split(0) # => [[1, 2], [3, 4]] 10 | # [0, 0, 1, 0, 2].split(0) # => [[], [] [1], [2]] 11 | # [1, 0, 0, 2, 3].split(0) # => [[1], [], [2], [3]] 12 | # [1, 0, 2, 0, 0].split(0) # => [[1], [2]] 13 | # 14 | # @note While similar to +Array#split+ from ActiveSupport, this core 15 | # extension works for all enumerables and therefore works fine with. 16 | # Nokogiri. Also, it behaves more like +String#split+ by ignoring any 17 | # trailing zero-length sub-enumerators. 18 | # 19 | # @param object [Object] element at which to split 20 | # @yield [Object] element to analyze 21 | # @yieldreturn [Boolean] whether to split at this element or not 22 | # @return [Array] 23 | def split(*args, &block) 24 | [].tap do |array| 25 | while index = slice((start ||= 0)...length).find_index(*args, &block) 26 | array << slice(start...start+index) 27 | start += index + 1 28 | end 29 | array << slice(start..-1) if start < length 30 | end 31 | end 32 | 33 | # !method group_by_chunks(&block) 34 | # Build a hash which maps elements matching the chunk condition to 35 | # an array of subsequent elements which don't match the chunk condition. 36 | # 37 | # @example 38 | # [1, 10, 11, 12, 2, 20, 21, 3, 30, 31, 32].group_by_chunks { _1 < 10 } 39 | # # => { 1 => [10, 11, 12], 2 => [20, 21], 3 => [30, 31, 32] } 40 | # 41 | # @note The first element must match the chunk condition. 42 | # 43 | # @yield [Object] object to analyze 44 | # @yieldreturn [Boolean] chunk condition: begin a new chunk with this 45 | # object as key if the condition returns true 46 | # @return [Hash] 47 | def group_by_chunks 48 | fail(ArgumentError, "first element must match chunk condition") unless yield(first) 49 | slice_when { yield(_2) }.map { [_1.first, _1[1..]] }.to_h 50 | end 51 | 52 | end 53 | -------------------------------------------------------------------------------- /lib/core_ext/hash.rb: -------------------------------------------------------------------------------- 1 | class Hash 2 | 3 | # Returns a value from the hash for the matching key 4 | # 5 | # Similar to +fetch+, search the hash keys for the search string and return 6 | # the corresponding value. Unlike +fetch+, however, if a hash key is a Regexp, 7 | # the search argument is matched against this Regexp. The hash is searched 8 | # in its natural order. 9 | # 10 | # @example 11 | # h = { /aa/ => :aa, /a/ => :a, 'b' => :b } 12 | # h.metch('abc') # => :a 13 | # h.metch('bcd') # => KeyError 14 | # h.metch('b') # => :b 15 | # h.metch('x', :foobar) # => :foobar 16 | # 17 | # @param search [String] string to search or matche against 18 | # @param default [Object] fallback value if no key matched 19 | # @return [Object] hash value 20 | # @raise [KeyError] no key matched and no default given 21 | def metch(search, default=:__n_o_n_e__) 22 | fetch search 23 | rescue KeyError 24 | each do |key, value| 25 | next unless key.is_a? Regexp 26 | return value if key.match? search 27 | end 28 | raise(KeyError, "no match found: #{search.inspect}") if default == :__n_o_n_e__ 29 | default 30 | end 31 | 32 | # Compile a titles/texts hash to remarks Markdown string 33 | # 34 | # @example 35 | # { name: 'foobar', ignore: => nil, 'count/quantité' => 3 }.to_remarks 36 | # # => "NAME\nfoobar\n\nCOUNT/QUANTITÉ\n3" 37 | # { ignore: nil, ignore_as_well: "" }.to_remarks 38 | # # => nil 39 | # 40 | # @return [String, nil] compiled remarks 41 | def to_remarks 42 | map { |k, v| "**#{k.to_s.upcase}**\n#{v}" unless v.blank? }. 43 | compact. 44 | join("\n\n"). 45 | blank_to_nil 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/core_ext/integer.rb: -------------------------------------------------------------------------------- 1 | class Integer 2 | 3 | # Iterates the given block, passing in increasing or decreasing values to and 4 | # including limit 5 | # 6 | # If no block is given, an Enumerator is returned instead. 7 | # 8 | # @example 9 | # 10.up_or_downto(12).to_a # => [10, 11, 12] 10 | # 10.upto(12).to_a # => [10, 11, 12] 11 | # 10.up_or_downto(8).to_a # => [10, 9, 8] 12 | # 10.downto(8).to_a # => [10, 9, 8] 13 | def up_or_downto(limit) 14 | self > limit ? self.downto(limit) : self.upto(limit) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/core_ext/nil_class.rb: -------------------------------------------------------------------------------- 1 | class NilClass 2 | 3 | # Always returns +nil+, companion to +String#blank_to_nil+. 4 | # 5 | # @return [nil] 6 | def blank_to_nil 7 | nil 8 | end 9 | 10 | end 11 | -------------------------------------------------------------------------------- /lib/core_ext/nokogiri.rb: -------------------------------------------------------------------------------- 1 | module Nokogiri 2 | 3 | module PseudoClasses 4 | class Matches 5 | def matches(node_set, regexp) 6 | node_set.find_all { _1.content.match?(/#{regexp}/) } 7 | end 8 | end 9 | end 10 | 11 | # Pseudo class which matches the content of each node set member against the 12 | # given regular expression. 13 | # 14 | # @example 15 | # node.css('title:matches("\w+")', Nokogiri::MATCHES) 16 | MATCHES = PseudoClasses::Matches.new 17 | 18 | module XML 19 | class Element 20 | 21 | BOOLEANIZE_AS_TRUE_RE = /^(true|yes|oui|ja)$/i.freeze 22 | BOOLEANIZE_AS_FALSE_RE = /^(false|no|non|nein)$/i.freeze 23 | 24 | # Shortcut to query +contents+ array which accepts both String or 25 | # Symbol queries as well as query postfixes. 26 | # 27 | # @example query optional content for :key 28 | # element.(:key) # same as element.contents[:key] 29 | # 30 | # @example query mandatory content for :key 31 | # element.(:key!) # fails if the key does not exist 32 | # 33 | # @example query boolean content for :key 34 | # element.(:key?) # returns true or false 35 | # 36 | # @see +BOOLEANIZE_AS_TRUE_RE+ and +BOOLEANIZE_AS_FALSE_RE+ define the 37 | # regular expressions which convert the content to boolean. Furthermore, 38 | # nil is interpreted as false as well. 39 | # 40 | # @raise KeyError mandatory or boolean content not found 41 | # @return [String, Boolean] 42 | def call(query) 43 | case query 44 | when /\?$/ then booleanize(contents.fetch(query[...-1].to_sym)) 45 | when /\!$/ then contents.fetch(query[...-1].to_sym) 46 | else contents[query.to_sym] 47 | end 48 | end 49 | 50 | # Traverse all child elements and build a hash mapping the symbolized 51 | # child node name to the child content. 52 | # 53 | # @return [Hash] 54 | def contents 55 | @contents ||= elements.to_h { [_1.name.to_sym, _1.content] } 56 | end 57 | 58 | # Find this child element or add a new such element if none is found. 59 | # 60 | # The position to add is determined as follows: 61 | # 62 | # * If +after_css+ is given, its rules are applied in reverse order and 63 | # the last matching rule defines the predecessor of the added child. 64 | # * If only +before_css+ is given, its rules are applied in order and 65 | # the first matching rule defines the successor of the added child. 66 | # * If none of the above are given, the child is added at the end. 67 | # 68 | # @param name [Array] name of the child element 69 | # @param after_css [Array] array of CSS rules 70 | # @param before_css [Array] array of CSS rules 71 | # @return [Nokogiri::XML::Element, nil] element or +nil+ if none found 72 | # and no position to add a new one could be determined 73 | def find_or_add_child(name, after_css: nil, before_css: nil) 74 | at_css(name) or begin 75 | case 76 | when after_css 77 | at_css(*after_css.reverse).then do |predecessor| 78 | predecessor&.add_next_sibling("<#{name}/>") 79 | end&.first 80 | when before_css 81 | at_css(*before_css).then do |successor| 82 | successor&.add_previous_sibling("<#{name}/>") 83 | end&.first 84 | else 85 | add_child("<#{name}/>").first 86 | end 87 | end 88 | end 89 | 90 | private 91 | 92 | def booleanize(content) 93 | case content 94 | when nil then false 95 | when BOOLEANIZE_AS_TRUE_RE then true 96 | when BOOLEANIZE_AS_FALSE_RE then false 97 | else fail(KeyError, "`#{content}' not recognized as boolean") 98 | end 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/core_ext/string.rb: -------------------------------------------------------------------------------- 1 | class String 2 | remove_method :classify 3 | 4 | # Convert (underscored) file name to (camelcased) class name 5 | # 6 | # Similar to +classify+ from ActiveSupport, however, with a few differences: 7 | # 8 | # * Namespaces are ignored. 9 | # * Plural strings are not singularized. 10 | # * Characters other than A-Z, a-z, 0-9 and _ are removed. 11 | # 12 | # Use +sectionize+ to reverse this method. 13 | # 14 | # @example 15 | # "navigational_aids".classify # => "NavigationalAids" 16 | # "ENR".classify # => "ENR" 17 | # "ENR-4.1".classify # => "ENR41" 18 | # "AIPP/LF/AIP/ENR-4.1".classify # => "ENR41" 19 | # 20 | # @return [String] converted string 21 | def classify 22 | split('/').last.remove(/\W/).camelcase 23 | end 24 | 25 | # Convert (camelcased) class name to (underscored) file name 26 | # 27 | # Similar to +underscore+ from ActiveSupport, however, with a few differences: 28 | # 29 | # * Namespaces are ignored. 30 | # * AIP naming conventions are honored. 31 | # 32 | # Use +classify+ to reverse this method. 33 | # 34 | # @example 35 | # "NavigationalAids".sectionize # => "navigational_aids" 36 | # "ENR".sectionize # => "ENR" 37 | # "ENR41".sectionize # => "ENR-4.1" 38 | # "AIPP::LF::AIP::ENR41".sectionize # => "ENR-4.1" 39 | # 40 | # @return [String] converted string 41 | def sectionize 42 | case klass = self.split('::').last 43 | when /\A([A-Z]{2,3})\z/ then $1 44 | when /\A([A-Z]{2,3})(\d)\z/ then "#{$1}-#{$2}" 45 | when /\A([A-Z]{2,3})(\d)(\d+)\z/ then "#{$1}-#{$2}.#{$3}" 46 | else klass.underscore 47 | end 48 | end 49 | 50 | # Convert blank strings to +nil+ 51 | # 52 | # @example 53 | # "foobar".blank_to_nil # => "foobar" 54 | # " ".blank_to_nil # => nil 55 | # "".blank_to_nil # => nil 56 | # nil.blank_to_nil # => nil 57 | # 58 | # @return [String, nil] converted string 59 | def blank_to_nil 60 | self if present? 61 | end 62 | 63 | # Fix messy oddities such as the use of two apostrophes instead of a quote 64 | # 65 | # @example 66 | # "the ''Terror'' was a fine ship".cleanup # => "the \"Terror\" was a fine ship" 67 | # 68 | # @return [String] cleaned string 69 | def cleanup 70 | gsub(/[#{AIXM::MIN}]{2}|[#{AIXM::SEC}]/, '"'). # unify quotes 71 | gsub(/[#{AIXM::MIN}]/, "'"). # unify apostrophes 72 | gsub(/"[[:blank:]]*(.*?)[[:blank:]]*"/m, '"\1"'). # remove whitespace within quotes 73 | split(/\r?\n/).map { _1.strip.blank_to_nil }.compact.join("\n") # remove blank lines 74 | end 75 | 76 | # Strip and collapse unnecessary whitespace 77 | # 78 | # @note While similar to +String#squish+ from ActiveSupport, newlines +\n+ 79 | # are preserved and not collapsed into one space. 80 | # 81 | # @example 82 | # " foo\n\nbar \r".compact # => "foo\nbar" 83 | # 84 | # @return [String] compacted string 85 | def compact 86 | split("\n").map { _1.squish.blank_to_nil }.compact.join("\n") 87 | end 88 | 89 | # Similar to +strip+, but remove any leading or trailing non-letters/numbers 90 | # which includes whitespace 91 | def full_strip 92 | remove(/\A[^\p{L}\p{N}]*|[^\p{L}\p{N}]*\z/) 93 | end 94 | 95 | # Similar to +scan+, but remove matches from the string 96 | def extract(pattern) 97 | scan(pattern).tap { remove! pattern } 98 | end 99 | 100 | # Apply the patterns in the given order and return... 101 | # * first capture group - if a pattern matches and contains a capture group 102 | # * entire match - if a pattern matches and contains no capture group 103 | # * +default+ - if no pattern matches and a +default+ is set 104 | # * +nil+ - if no pattern matches and no +default+ is set 105 | # 106 | # @example 107 | # "A/A: 123.5 mhz".first_match(/123\.5/) # => "123.5" 108 | # "A/A: 123.5 mhz".first_match(/:\s+([\d.]+)/) # => "123.5" 109 | # "A/A: 123.5 mhz".first_match(/121\.5/) # nil 110 | # "A/A: 123.5 mhz".first_match(/(121\.5)/) # nil 111 | # "A/A: 123.5 mhz".first_match(/121\.5/, default: "123") # "123" 112 | # 113 | # @param patterns [Array] one or more patterns to apply in order 114 | # @param default [String] string to return instead of +nil+ if the pattern 115 | # doesn't match 116 | # @return [String, nil] 117 | def first_match(*patterns, default: nil) 118 | patterns.each do |pattern| 119 | if captures = match(pattern) 120 | return captures[1] || captures[0] 121 | end 122 | end 123 | default 124 | end 125 | 126 | # Remove all XML/HTML tags and entities from the string 127 | # 128 | # @example 129 | # "this is a
test".strip_markup # => "this is a test" 130 | # 131 | # @return [String] 132 | def strip_markup 133 | self.gsub(/<.*?>|&[#\da-z]+;/i, '') 134 | end 135 | 136 | # Builds the MD5 hash as hex and returns the first eight characters. 137 | # 138 | # @example 139 | # "this is a test".to_digest # => "54b0c58c" 140 | # 141 | # @return [String] 142 | def to_digest 143 | Digest::MD5.hexdigest(self)[0,8] 144 | end 145 | 146 | # Same as +to_f+ but accept both dot and comma as decimal separator 147 | # 148 | # @example 149 | # "5.5".to_ff # => 5.5 150 | # "5,6".to_ff # => 5.6 151 | # "5,6".to_f # => 5.0 (sic!) 152 | # 153 | # @return [Float] number parsed from text 154 | def to_ff 155 | sub(/,/, '.').to_f 156 | end 157 | 158 | end 159 | -------------------------------------------------------------------------------- /rakefile.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | 3 | require 'rake/testtask' 4 | 5 | Rake::TestTask.new do |t| 6 | t.libs << 'lib' 7 | t.test_files = FileList['spec/lib/**/*_spec.rb'] 8 | t.verbose = false 9 | t.warning = !ENV['RUBYOPT']&.match?(/-W0/) 10 | end 11 | 12 | Rake::Task[:test].enhance do 13 | if ENV['RUBYOPT']&.match?(/-W0/) 14 | puts "⚠️ Ruby warnings are disabled, remove -W0 from RUBYOPT to enable." 15 | end 16 | end 17 | 18 | desc "Serve documentation on http://localhost:8808" 19 | task :yard do 20 | server = Thread.new do 21 | `rm -rf .yardoc` 22 | `yard server -r` 23 | end 24 | sleep 1 25 | `open http://localhost:8808` 26 | server.join 27 | end 28 | 29 | task default: :test 30 | -------------------------------------------------------------------------------- /spec/fixtures/borders/oggystan.geojson: -------------------------------------------------------------------------------- 1 | { 2 | "type": "GeometryCollection", 3 | "geometries": [ 4 | { 5 | "type": "LineString", 6 | "coordinates": [ 7 | [ 8 | 4.757294654846191, 9 | 43.989202079482276 10 | ], 11 | [ 12 | 4.764375686645508, 13 | 43.99701327763528 14 | ], 15 | [ 16 | 4.7519731521606445, 17 | 44.00269350325321 18 | ], 19 | [ 20 | 4.745192527770996, 21 | 43.99506829280446 22 | ], 23 | [ 24 | 4.751286506652832, 25 | 43.99235138187902 26 | ], 27 | [ 28 | 4.750943183898926, 29 | 43.99198088438825 30 | ], 31 | [ 32 | 4.757294654846191, 33 | 43.989202079482276 34 | ] 35 | ] 36 | }, 37 | { 38 | "type": "LineString", 39 | "coordinates": [ 40 | [ 41 | 4.777421951293944, 42 | 44.00115001749186 43 | ], 44 | [ 45 | 4.78205680847168, 46 | 43.994111213373934 47 | ], 48 | [ 49 | 4.784030914306641, 50 | 43.99818641226534 51 | ], 52 | [ 53 | 4.787635803222656, 54 | 44.00077957493397 55 | ], 56 | [ 57 | 4.785747528076171, 58 | 44.00448389642906 59 | ], 60 | [ 61 | 4.790983200073242, 62 | 44.004669106432225 63 | ], 64 | [ 65 | 4.798364639282227, 66 | 44.008373185063874 67 | ], 68 | [ 69 | 4.793901443481445, 70 | 44.01528684632061 71 | ], 72 | [ 73 | 4.787378311157227, 74 | 44.01584237340163 75 | ], 76 | [ 77 | 4.785575866699219, 78 | 44.01960747533136 79 | ], 80 | [ 81 | 4.768667221069335, 82 | 44.01831131968508 83 | ], 84 | [ 85 | 4.763689041137695, 86 | 44.01460786170962 87 | ], 88 | [ 89 | 4.760427474975586, 90 | 44.01065725159039 91 | ], 92 | [ 93 | 4.770212173461914, 94 | 44.002940457248556 95 | ], 96 | [ 97 | 4.777421951293944, 98 | 44.00115001749186 99 | ] 100 | ] 101 | }, 102 | { 103 | "type": "LineString", 104 | "coordinates": [ 105 | [ 106 | 4.752960205078125, 107 | 43.93721446391471 108 | ], 109 | [ 110 | 4.744377136230469, 111 | 43.950068873803815 112 | ], 113 | [ 114 | 4.737510681152343, 115 | 43.97033364196856 116 | ], 117 | [ 118 | 4.7344207763671875, 119 | 43.98713332912919 120 | ], 121 | [ 122 | 4.7371673583984375, 123 | 44.00516299694704 124 | ], 125 | [ 126 | 4.743347167968749, 127 | 44.02195282780904 128 | ], 129 | [ 130 | 4.749870300292969, 131 | 44.037503870182896 132 | ], 133 | [ 134 | 4.755706787109375, 135 | 44.05379106204314 136 | ], 137 | [ 138 | 4.7646331787109375, 139 | 44.070073775703484 140 | ] 141 | ] 142 | }, 143 | { 144 | "type": "LineString", 145 | "coordinates": [ 146 | [ 147 | 4.7948455810546875, 148 | 43.95328204198018 149 | ], 150 | [ 151 | 4.801368713378906, 152 | 43.956989327857265 153 | ], 154 | [ 155 | 4.815788269042969, 156 | 43.9646503190861 157 | ], 158 | [ 159 | 4.82025146484375, 160 | 43.98614524381678 161 | ], 162 | [ 163 | 4.840850830078125, 164 | 43.98491011404692 165 | ], 166 | [ 167 | 4.845314025878906, 168 | 43.99479043262446 169 | ], 170 | [ 171 | 4.8538970947265625, 172 | 43.98367495857784 173 | ], 174 | [ 175 | 4.851493835449218, 176 | 43.967121395851485 177 | ], 178 | [ 179 | 4.8442840576171875, 180 | 43.96069638244953 181 | ], 182 | [ 183 | 4.829521179199219, 184 | 43.96069638244953 185 | ], 186 | [ 187 | 4.819221496582031, 188 | 43.95501213750488 189 | ], 190 | [ 191 | 4.805145263671875, 192 | 43.955506441260546 193 | ], 194 | [ 195 | 4.799995422363281, 196 | 43.952046228624724 197 | ] 198 | ] 199 | } 200 | ] 201 | } 202 | -------------------------------------------------------------------------------- /spec/fixtures/config/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | namespace: 11111111-2222-3333-4444-555555555555 3 | foo: bar 4 | -------------------------------------------------------------------------------- /spec/fixtures/downloader/.~lock.new.xml#: -------------------------------------------------------------------------------- 1 | ,sschwyn,samba.local,19.03.2022 17:20,file:///Users/sschwyn/Library/Application%20Support/LibreOffice/4; -------------------------------------------------------------------------------- /spec/fixtures/downloader/archive.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svoop/aipp/8d1f45e176f4b7ec9a736211461acf117554857c/spec/fixtures/downloader/archive.zip -------------------------------------------------------------------------------- /spec/fixtures/downloader/new.csv: -------------------------------------------------------------------------------- 1 | fixture-csv-new,two,three 2 | uno,dos,tres 3 | -------------------------------------------------------------------------------- /spec/fixtures/downloader/new.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

fixture-html-new

5 | 6 | 7 | -------------------------------------------------------------------------------- /spec/fixtures/downloader/new.json: -------------------------------------------------------------------------------- 1 | "fixture-json-new" 2 | -------------------------------------------------------------------------------- /spec/fixtures/downloader/new.ods: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svoop/aipp/8d1f45e176f4b7ec9a736211461acf117554857c/spec/fixtures/downloader/new.ods -------------------------------------------------------------------------------- /spec/fixtures/downloader/new.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svoop/aipp/8d1f45e176f4b7ec9a736211461acf117554857c/spec/fixtures/downloader/new.pdf -------------------------------------------------------------------------------- /spec/fixtures/downloader/new.txt: -------------------------------------------------------------------------------- 1 | fixture-txt-new 2 | -------------------------------------------------------------------------------- /spec/fixtures/downloader/new.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svoop/aipp/8d1f45e176f4b7ec9a736211461acf117554857c/spec/fixtures/downloader/new.xlsx -------------------------------------------------------------------------------- /spec/fixtures/downloader/new.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | fixture-xml-new 4 | 5 | -------------------------------------------------------------------------------- /spec/fixtures/downloader/source.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svoop/aipp/8d1f45e176f4b7ec9a736211461acf117554857c/spec/fixtures/downloader/source.zip -------------------------------------------------------------------------------- /spec/fixtures/fixtures/aerodromes.yml: -------------------------------------------------------------------------------- 1 | LFAB: 2 | "13L": 3 | xy: 49°53'12.1"N 1°04'40.1"E 4 | "31R": 5 | xy: 49°52'59.8"N 1°05'06.2"E 6 | -------------------------------------------------------------------------------- /spec/fixtures/pdf/document.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svoop/aipp/8d1f45e176f4b7ec9a736211461acf117554857c/spec/fixtures/pdf/document.pdf -------------------------------------------------------------------------------- /spec/fixtures/pdf/document.pdf.json: -------------------------------------------------------------------------------- 1 | ["page 1, line 1\npage 1, line 2\npage 1, line 3\npage 1, line 4\npage 1, line 5\fpage 2, line 1\npage 2, line 2\npage 2, line 3\npage 2, line 4\npage 2, line 5\fpage 3, line 1\npage 3, line 2\npage 3, line 3\npage 3, line 4\npage 3, line 5",[74,149,225]] -------------------------------------------------------------------------------- /spec/lib/aipp/border_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe AIPP::Border::Position do 4 | subject do 5 | AIPP::Border::Position.new( 6 | geometries: [ 7 | [AIXM.xy(lat: 0, long: 0), AIXM.xy(lat: 1, long: 1), AIXM.xy(lat: 2, long: 2)], 8 | [AIXM.xy(lat: 10, long: 10), AIXM.xy(lat: 11, long: 11), AIXM.xy(lat: 12, long: 12)] 9 | ], 10 | geometry_index: 0, 11 | coordinates_index: 0 12 | ) 13 | end 14 | 15 | describe :xy do 16 | it "returns the coordinates" do 17 | _(subject.xy).must_equal AIXM.xy(lat: 0, long: 0) 18 | end 19 | 20 | it "returns nil if the geometry index is out of bounds" do 21 | _(subject.tap { _1.geometry_index = 2 }.xy).must_be_nil 22 | end 23 | 24 | it "returns nil if the coordinates index is out of bounds" do 25 | _(subject.tap { _1.coordinates_index = 3 }.xy).must_be_nil 26 | end 27 | end 28 | end 29 | 30 | describe AIPP::Border do 31 | # The border.geojson fixture defines three geometries: 32 | # * index 0: closed geometry circumventing the airfield of Pujaut 33 | # * index 1: closed geometry circumventing the village of Pujaut 34 | # * index 2: unclosed I-shaped geometry following the TGV from the S to N bridges over the Rhône 35 | # * index 3: unclosed U-shaped geometry around Île de Bartelasse from N to S end of Pont Daladier 36 | subject do 37 | AIPP::Border.from_file(fixtures_path.join('borders', 'oggystan.geojson')) 38 | end 39 | 40 | describe :from_file do 41 | it "fails for files unless the extension is .geojson" do 42 | _{ AIPP::Border.from_file("/path/to/another.txt") }.must_raise ArgumentError 43 | end 44 | 45 | it "loads the coordinates from the file" do 46 | _(subject.geometries[0].count).must_equal 7 47 | _(subject.geometries[0].first).must_equal AIXM.xy(lat: 43.98920208, long: 4.75729465) 48 | end 49 | end 50 | 51 | describe :from_array do 52 | subject do 53 | AIPP::Border.from_array([ 54 | ['0 0', '1 1', '2 2'], # index 0 55 | ['10,10', '11, 11', '12 , 12'] # index 1 56 | ]) 57 | end 58 | 59 | it "loads the whitespace separated coordinates from nested arrays" do 60 | _(subject.geometries[0].count).must_equal 3 61 | _(subject.geometries[0].first).must_equal AIXM.xy(lat: 0, long: 0) 62 | end 63 | 64 | it "loads the comma separated coordinates from nested arrays" do 65 | _(subject.geometries[1].count).must_equal 3 66 | _(subject.geometries[1].first).must_equal AIXM.xy(lat: 10, long: 10) 67 | end 68 | end 69 | 70 | describe :closed? do 71 | it "returns true for closed geometries" do 72 | _(subject.closed?(geometry_index: 0)).must_equal true 73 | _(subject.closed?(geometry_index: 1)).must_equal true 74 | end 75 | 76 | it "returns false for unclosed geometries" do 77 | _(subject.closed?(geometry_index: 2)).must_equal false 78 | _(subject.closed?(geometry_index: 3)).must_equal false 79 | end 80 | end 81 | 82 | describe :nearest do 83 | let :point do 84 | AIXM.xy(lat: 44.008187986625636, long: 4.759397506713866) 85 | end 86 | 87 | it "finds the nearest position on any geometry" do 88 | position = subject.nearest(xy: point) 89 | _(position.geometry_index).must_equal 1 90 | _(position.coordinates_index).must_equal 12 91 | _(position.xy).must_equal AIXM.xy(lat: 44.01065725159039, long: 4.760427474975586) 92 | end 93 | 94 | it "finds the nearest postition on a given geometry" do 95 | position = subject.nearest(xy: point, geometry_index: 0) 96 | _(position.geometry_index).must_equal 0 97 | _(position.coordinates_index).must_equal 2 98 | _(position.xy).must_equal AIXM.xy(lat: 44.00269350325321, long: 4.7519731521606445) 99 | end 100 | end 101 | 102 | describe :segment do 103 | it "fails if positions are not on the same geometry" do 104 | from_position = AIPP::Border::Position.new(geometries: subject.geometries, geometry_index: 0, coordinates_index: 0) 105 | to_position = AIPP::Border::Position.new(geometries: subject.geometries, geometry_index: 1, coordinates_index: 0) 106 | _{ subject.segment(from_position: from_position, to_position: to_position) }.must_raise ArgumentError 107 | end 108 | 109 | it "returns shortest segment on an unclosed I-shaped geometry" do 110 | from_position = subject.nearest(xy: AIXM.xy(lat: 44.002940457248556, long: 4.734249114990234)) 111 | to_position = subject.nearest(xy: AIXM.xy(lat: 44.07155380033749, long: 4.7687530517578125), geometry_index: from_position.geometry_index) 112 | _(subject.segment(from_position: from_position, to_position: to_position)).must_equal [ 113 | AIXM.xy(lat: 44.00516299694704, long: 4.7371673583984375), 114 | AIXM.xy(lat: 44.02195282780904, long: 4.743347167968749), 115 | AIXM.xy(lat: 44.037503870182896, long: 4.749870300292969), 116 | AIXM.xy(lat: 44.05379106204314, long: 4.755706787109375), 117 | AIXM.xy(lat: 44.070073775703484, long: 4.7646331787109375) 118 | ] 119 | end 120 | 121 | it "returns shortest segment on an unclosed U-shaped geometry" do 122 | from_position = subject.nearest(xy: AIXM.xy(lat: 43.96563876212758, long: 4.8126983642578125)) 123 | to_position = subject.nearest(xy: AIXM.xy(lat: 43.956989327857265, long: 4.83123779296875), geometry_index: from_position.geometry_index) 124 | _(subject.segment(from_position: from_position, to_position: to_position)).must_equal [ 125 | AIXM.xy(lat: 43.9646503190861, long: 4.815788269042969), 126 | AIXM.xy(lat: 43.98614524381678, long: 4.82025146484375), 127 | AIXM.xy(lat: 43.98491011404692, long: 4.840850830078125), 128 | AIXM.xy(lat: 43.99479043262446, long: 4.845314025878906), 129 | AIXM.xy(lat: 43.98367495857784, long: 4.8538970947265625), 130 | AIXM.xy(lat: 43.967121395851485, long: 4.851493835449218), 131 | AIXM.xy(lat: 43.96069638244953, long: 4.8442840576171875), 132 | AIXM.xy(lat: 43.96069638244953, long: 4.829521179199219) 133 | ] 134 | end 135 | 136 | it "returns shortest segment ignoring endings on a closed geometry" do 137 | from_position = subject.nearest(xy: AIXM.xy(lat: 44.00022390676026, long: 4.789009094238281)) 138 | to_position = subject.nearest(xy: AIXM.xy(lat: 43.99800118202362, long: 4.765834808349609), geometry_index: from_position.geometry_index) 139 | _(subject.segment(from_position: from_position, to_position: to_position)).must_equal [ 140 | AIXM.xy(lat: 44.00077957493397, long: 4.787635803222656), 141 | AIXM.xy(lat: 43.99818641226534, long: 4.784030914306641), 142 | AIXM.xy(lat: 43.994111213373934, long: 4.78205680847168), 143 | AIXM.xy(lat: 44.00115001749186, long: 4.777421951293944), 144 | AIXM.xy(lat: 44.002940457248556, long: 4.770212173461914) 145 | ] 146 | end 147 | 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /spec/lib/aipp/downloader/file_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../../spec_helper' 2 | 3 | describe AIPP::Downloader::File do 4 | subject do 5 | AIPP::Downloader::File 6 | end 7 | 8 | describe :name do 9 | it "isolates the name" do 10 | _(subject.new(file: 'path/to/foobar.txt').send(:name)).must_equal 'foobar' 11 | end 12 | end 13 | 14 | describe :type do 15 | it "isolates the type" do 16 | _(subject.new(file: 'path/to/foobar.txt').send(:type)).must_equal 'txt' 17 | end 18 | 19 | it "gives precedence to the declared type" do 20 | _(subject.new(file: 'path/to/foobar.txt', type: 'pdf').send(:type)).must_equal 'pdf' 21 | end 22 | end 23 | 24 | describe :fetch_to do 25 | let :tmp_dir do 26 | Pathname(Dir.mktmpdir) 27 | end 28 | 29 | after do 30 | FileUtils.rm_rf(tmp_dir) 31 | end 32 | 33 | context 'file' do 34 | subject do 35 | fixtures_path.join('downloader', 'new.txt') 36 | end 37 | 38 | it "fetches the file and detects the type" do 39 | downloader = AIPP::Downloader::File.new(file: subject).fetch_to(tmp_dir) 40 | _(downloader.fetched_file).must_equal 'new.txt' 41 | _(tmp_dir.join('new.txt')).path_must_exist 42 | end 43 | 44 | it "fetches the file and overrides the type" do 45 | downloader = AIPP::Downloader::File.new(file: subject, type: :csv).fetch_to(tmp_dir) 46 | _(downloader.fetched_file).must_equal 'new.csv' 47 | _(tmp_dir.join('new.csv')).path_must_exist 48 | end 49 | 50 | it "fails if file doesn't exist" do 51 | downloader = AIPP::Downloader::File.new(file: 'missing.txt') 52 | _{ downloader.fetch_to(tmp_dir) }.must_raise AIPP::Downloader::NotFoundError 53 | end 54 | end 55 | 56 | context 'ZIP archive' do 57 | subject do 58 | fixtures_path.join('downloader', 'archive.zip') 59 | end 60 | 61 | it "extracts the file and detects the type" do 62 | downloader = AIPP::Downloader::File.new(archive: subject, file: 'archive/new.txt').fetch_to(tmp_dir) 63 | _(downloader.fetched_file).must_equal 'new.txt' 64 | _(tmp_dir.join('new.txt')).path_must_exist 65 | end 66 | 67 | it "extracts the file and overrides the type" do 68 | downloader = AIPP::Downloader::File.new(archive: subject, file: 'archive/new.txt', type: :csv).fetch_to(tmp_dir) 69 | _(downloader.fetched_file).must_equal 'new.csv' 70 | _(tmp_dir.join('new.csv')).path_must_exist 71 | end 72 | 73 | it "fails if archive doesn't exist" do 74 | downloader = AIPP::Downloader::File.new(archive: 'missing.zip', file: 'archive/new.txt') 75 | _{ downloader.fetch_to(tmp_dir) }.must_raise AIPP::Downloader::NotFoundError 76 | end 77 | 78 | it "fails if file doesn't exist in archive" do 79 | downloader = AIPP::Downloader::File.new(archive: subject, file: 'archive/missing.txt') 80 | _{ downloader.fetch_to(tmp_dir) }.must_raise AIPP::Downloader::NotFoundError 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/lib/aipp/downloader/graphql_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../../spec_helper' 2 | 3 | describe AIPP::Downloader::GraphQL do 4 | subject do 5 | AIPP::Downloader::GraphQL 6 | end 7 | 8 | describe :name do 9 | it "returns the digest of client and query" do 10 | _(subject.new(client: 0, query: 0, variables: 0).send(:name)).must_equal '03643c5b' 11 | _(subject.new(client: 0, query: 0, variables: 1).send(:name)).must_equal '037fe0aa' 12 | _(subject.new(client: 0, query: 1, variables: 0).send(:name)).must_equal '562e71ee' 13 | _(subject.new(client: 0, query: 1, variables: 1).send(:name)).must_equal '9b3d7521' 14 | _(subject.new(client: 1, query: 0, variables: 0).send(:name)).must_equal 'e4873aab' 15 | _(subject.new(client: 1, query: 0, variables: 1).send(:name)).must_equal 'a56b3009' 16 | _(subject.new(client: 1, query: 1, variables: 0).send(:name)).must_equal 'a5eaf240' 17 | _(subject.new(client: 1, query: 1, variables: 1).send(:name)).must_equal '2ea5b261' 18 | end 19 | end 20 | 21 | describe :type do 22 | it "returns always JSON" do 23 | _(subject.new(client: 0, query: 0, variables: 0).send(:type)).must_equal :json 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/lib/aipp/downloader/http_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../../spec_helper' 2 | 3 | describe AIPP::Downloader::HTTP do 4 | subject do 5 | AIPP::Downloader::HTTP 6 | end 7 | 8 | describe :name do 9 | it "isolates the name" do 10 | _(subject.new(file: 'http://example.com/path/to/foobar.txt').send(:name)).must_equal 'foobar' 11 | end 12 | 13 | it "uses digest as name if none can be isolated" do 14 | _(subject.new(file: 'http://example.com').send(:name)).must_equal 'a9b9f043' 15 | end 16 | end 17 | 18 | describe :type do 19 | it "isolates the type" do 20 | _(subject.new(file: 'http://example.com/path/to/foobar.txt').send(:type)).must_equal 'txt' 21 | end 22 | 23 | it "gives precedence to the declared type" do 24 | _(subject.new(file: 'http://example.com/path/to/foobar.txt', type: :pdf).send(:type)).must_equal 'pdf' 25 | end 26 | end 27 | 28 | describe :fetch_to do 29 | before do 30 | unless Excon.defaults[:mock] 31 | Excon.defaults[:mock] = true 32 | end 33 | end 34 | 35 | let :tmp_dir do 36 | Pathname(Dir.mktmpdir) 37 | end 38 | 39 | after do 40 | FileUtils.rm_rf(tmp_dir) 41 | end 42 | 43 | context 'file' do 44 | subject do 45 | fixtures_path.join('downloader', 'new.txt') 46 | end 47 | 48 | it "fetches the file and detects the type" do 49 | Excon.stub({}, { headers: { 'Content-Type' => 'text/plain' }, body: subject.read, status: 200 }) 50 | downloader = AIPP::Downloader::HTTP.new(file: 'http://example.com/path/new.txt').fetch_to(tmp_dir) 51 | _(downloader.fetched_file).must_equal 'new.txt' 52 | _(tmp_dir.join('new.txt')).path_must_exist 53 | end 54 | 55 | it "fetches the file and overrides the type" do 56 | Excon.stub({}, { headers: { 'Content-Type' => 'text/plain' }, body: subject.read, status: 200 }) 57 | downloader = AIPP::Downloader::HTTP.new(file: 'http://example.com/path/new.txt', type: :csv).fetch_to(tmp_dir) 58 | _(downloader.fetched_file).must_equal 'new.csv' 59 | _(tmp_dir.join('new.csv')).path_must_exist 60 | end 61 | 62 | it "fails on 404 not found" do 63 | Excon.stub({}, { status: 404 }) 64 | downloader = AIPP::Downloader::HTTP.new(file: 'http://example.com/path/new.txt') 65 | _{ downloader.fetch_to(tmp_dir) }.must_raise AIPP::Downloader::NotFoundError 66 | end 67 | end 68 | 69 | context 'ZIP archive' do 70 | subject do 71 | fixtures_path.join('downloader', 'archive.zip') 72 | end 73 | 74 | it "extracts the file and detects the type" do 75 | Excon.stub({}, { headers: { 'Content-Type' => 'application/zip' }, body: ::File.read(subject), status: 200 }) 76 | downloader = AIPP::Downloader::HTTP.new(archive: 'http://example.com/path/archive.zip', file: 'archive/new.txt').fetch_to(tmp_dir) 77 | _(downloader.fetched_file).must_equal 'new.txt' 78 | _(tmp_dir.join('new.txt')).path_must_exist 79 | end 80 | 81 | it "extracts the file and overrides the type" do 82 | Excon.stub({}, { headers: { 'Content-Type' => 'application/zip' }, body: ::File.read(subject), status: 200 }) 83 | downloader = AIPP::Downloader::HTTP.new(archive: 'http://example.com/path/archive.zip', file: 'archive/new.txt', type: :csv).fetch_to(tmp_dir) 84 | _(downloader.fetched_file).must_equal 'new.csv' 85 | _(tmp_dir.join('new.csv')).path_must_exist 86 | end 87 | 88 | it "fails on 404 not found" do 89 | Excon.stub({}, { status: 404 }) 90 | downloader = AIPP::Downloader::HTTP.new(archive: 'http://example.com/path/archive.zip', file: 'archive/new.txt') 91 | _{ downloader.fetch_to(tmp_dir) }.must_raise AIPP::Downloader::NotFoundError 92 | end 93 | 94 | it "fails if file doesn't exist in archive" do 95 | Excon.stub({}, { headers: { 'Content-Type' => 'application/zip' }, body: ::File.read(subject), status: 200 }) 96 | downloader = AIPP::Downloader::HTTP.new(archive: 'http://example.com/path/archive.zip', file: 'archive/missing.txt') 97 | _{ downloader.fetch_to(tmp_dir) }.must_raise AIPP::Downloader::NotFoundError 98 | end 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /spec/lib/aipp/downloader_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe AIPP::Downloader do 4 | let :tmp_dir do 5 | Pathname(Dir.mktmpdir).tap do |tmp_dir| 6 | (sources_dir = tmp_dir.join('sources')).mkpath 7 | FileUtils.cp(fixtures_path.join('downloader', 'source.zip'), sources_dir) 8 | end 9 | end 10 | 11 | after do 12 | FileUtils.rm_rf(tmp_dir) 13 | end 14 | 15 | describe :read do 16 | def origin_for(fixture) 17 | AIPP::Downloader::File.new(file: fixtures_path.join('downloader', fixture)) 18 | end 19 | 20 | def zip_entries(zip_file) 21 | Zip::File.open(zip_file).entries.map(&:name).sort 22 | end 23 | 24 | context "source archive does not exist" do 25 | it "creates the source archive" do 26 | subject = AIPP::Downloader.new(storage: tmp_dir, source: 'new-source') do |downloader| 27 | _(File.exist?(tmp_dir.join('work'))).must_equal true 28 | downloader.read(document: 'new', origin: origin_for('new.html')) 29 | end 30 | _(zip_entries(subject.source_file)).must_equal %w(new.html) 31 | _(subject.send(:sources_path).children.count).must_equal 2 32 | end 33 | end 34 | 35 | context "source archive does exist" do 36 | it "unzips and uses the source archive" do 37 | subject = AIPP::Downloader.new(storage: tmp_dir, source: 'source') do |downloader| 38 | _(File.exist?(tmp_dir.join('work'))).must_equal true 39 | _(File.exist?(tmp_dir.join('sources', downloader.instance_variable_get('@source_file')))).must_equal true 40 | downloader.read(document: 'new', origin: origin_for('new.html')).tap do |content| 41 | _(content).must_be_instance_of Nokogiri::HTML5::Document 42 | _(content.text).must_match(/fixture-html-new/) 43 | end 44 | end 45 | _(zip_entries(subject.source_file)).must_equal %w(new.html one.html two.html) 46 | _(subject.send(:sources_path).children.count).must_equal 1 47 | end 48 | 49 | it "deletes the source archive on clean run" do 50 | AIPP.options.clean = true 51 | AIPP::Downloader.new(storage: tmp_dir, source: 'source') do |downloader| 52 | _(File.exist?(tmp_dir.join('work'))).must_equal true 53 | _(File.exist?(tmp_dir.join('sources', downloader.instance_variable_get('@source_file')))).must_equal false 54 | end 55 | AIPP.options.clean = false 56 | end 57 | 58 | it "downloads XML documents to Nokogiri::XML::Document" do 59 | AIPP::Downloader.new(storage: tmp_dir, source: 'source') do |downloader| 60 | downloader.read(document: 'new', origin: origin_for('new.xml')).tap do |content| 61 | _(content).must_be_instance_of Nokogiri::XML::Document 62 | _(content.css('element').text).must_equal 'fixture-xml-new' 63 | end 64 | end 65 | end 66 | 67 | it "downloads HTML documents to Nokogiri::HTML5::Document" do 68 | AIPP::Downloader.new(storage: tmp_dir, source: 'source') do |downloader| 69 | downloader.read(document: 'new', origin: origin_for('new.html')).tap do |content| 70 | _(content).must_be_instance_of Nokogiri::HTML5::Document 71 | _(content.text).must_match(/fixture-html-new/) 72 | end 73 | end 74 | end 75 | 76 | it "downloads PDF documents to AIPP::PDF" do 77 | AIPP::Downloader.new(storage: tmp_dir, source: 'source') do |downloader| 78 | downloader.read(document: 'new', origin: origin_for('new.pdf')).tap do |content| 79 | _(content).must_be_instance_of AIPP::PDF 80 | _(content.text).must_equal 'fixture-pdf-new' 81 | end 82 | end 83 | end 84 | 85 | it "downloads XLSX documents to Roo::Excelx" do 86 | AIPP::Downloader.new(storage: tmp_dir, source: 'source') do |downloader| 87 | downloader.read(document: 'new', origin: origin_for('new.xlsx')).tap do |content| 88 | _(content).must_be_instance_of Roo::Excelx 89 | _(content.sheet(0).cell(1, 1)).must_equal 'fixture-xlsx-new' 90 | end 91 | end 92 | end 93 | 94 | it "downloads ODS documents to Roo::OpenOffice" do 95 | AIPP::Downloader.new(storage: tmp_dir, source: 'source') do |downloader| 96 | downloader.read(document: 'new', origin: origin_for('new.ods')).tap do |content| 97 | _(content).must_be_instance_of Roo::OpenOffice 98 | _(content.sheet(0).cell(1, 1)).must_equal 'fixture-ods-new' 99 | end 100 | end 101 | end 102 | 103 | it "downloads CSV documents to Roo::CSV" do 104 | AIPP::Downloader.new(storage: tmp_dir, source: 'source') do |downloader| 105 | downloader.read(document: 'new', origin: origin_for('new.csv')).tap do |content| 106 | _(content).must_be_instance_of Roo::CSV 107 | _(content.sheet(0).cell(1, 1)).must_equal 'fixture-csv-new' 108 | end 109 | end 110 | end 111 | 112 | it "downloads TXT documents to String" do 113 | AIPP::Downloader.new(storage: tmp_dir, source: 'source') do |downloader| 114 | downloader.read(document: 'new', origin: origin_for('new.txt')).tap do |content| 115 | _(content).must_be_instance_of String 116 | _(content.split.first).must_equal 'fixture-txt-new' 117 | end 118 | end 119 | end 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /spec/lib/aipp/environment_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | module WarningFilter 4 | ACCESSORS_RE = '(cache|borders|fixtures|options|config)'.freeze 5 | def warn(message, category: nil, **kwargs) 6 | return if /(method redefined; discarding old #{ACCESSORS_RE}|previous definition of #{ACCESSORS_RE} was here)/.match?(message) 7 | super 8 | end 9 | end 10 | Warning.extend WarningFilter 11 | 12 | describe AIPP::Environment do 13 | before do 14 | Singleton.__init__(AIPP::Environment) 15 | end 16 | 17 | describe :cache do 18 | it "defaults to an empty OpenStruct" do 19 | _(AIPP.cache).must_equal OpenStruct.new 20 | end 21 | 22 | it "caches an object" do 23 | _(AIPP.cache.foo = :bar).must_equal :bar 24 | _(AIPP.cache.foo).must_equal :bar 25 | _(AIPP.cache.to_h.count).must_equal 1 26 | end 27 | 28 | describe :[] do 29 | it "converts the key to Symbol" do 30 | AIPP.cache.foo = :bar 31 | _(AIPP.cache[:foo]).must_equal :bar 32 | _(AIPP.cache['foo']).must_equal :bar 33 | end 34 | end 35 | 36 | describe :replace do 37 | it "replaces the table with the given hash" do 38 | AIPP.cache.replace(fii: :bir) 39 | _(AIPP.cache.fii).must_equal :bir 40 | _(AIPP.cache.to_h.count).must_equal 1 41 | end 42 | end 43 | 44 | describe :merge do 45 | it "merges the given hash into the table" do 46 | AIPP.cache.foo = :bar 47 | AIPP.cache.merge(fii: :bir) 48 | _(AIPP.cache.foo).must_equal :bar 49 | _(AIPP.cache.fii).must_equal :bir 50 | _(AIPP.cache.to_h.count).must_equal 2 51 | end 52 | end 53 | end 54 | 55 | describe :borders do 56 | describe :read! do 57 | it "reads GeoJSON files from directory" do 58 | _(AIPP.borders.to_h.count).must_equal 0 59 | AIPP.borders.read! fixtures_path.join('borders') 60 | _(AIPP.borders.to_h.count).must_equal 1 61 | _(AIPP.borders.oggystan).wont_be :nil? 62 | end 63 | end 64 | end 65 | 66 | describe :fixtures do 67 | describe :read! do 68 | it "reads YAML files from directory" do 69 | _(AIPP.fixtures.to_h.count).must_equal 0 70 | AIPP.fixtures.read! fixtures_path.join('fixtures') 71 | _(AIPP.fixtures.to_h.count).must_equal 1 72 | _(AIPP.fixtures.aerodromes).wont_be :nil? 73 | end 74 | end 75 | end 76 | 77 | describe :config do 78 | describe :read! do 79 | context "config.yml does exist" do 80 | it "reads config.yml" do 81 | AIPP.config.read! fixtures_path.join('config', 'config.yml') 82 | _(AIPP.config.namespace).must_equal '11111111-2222-3333-4444-555555555555' 83 | _(AIPP.config.foo).must_equal 'bar' 84 | end 85 | end 86 | 87 | context "config.yml does not exist" do 88 | it "sets random UUID namespace" do 89 | AIPP.config.read! fixtures_path.join('config', 'non-existant') 90 | _(AIPP.config.namespace).wont_equal '11111111-2222-3333-4444-555555555555' 91 | _(AIPP.config.namespace).must_match(/[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12}/) 92 | _(AIPP.config.foo).must_be :nil? 93 | end 94 | end 95 | end 96 | 97 | describe :write! do 98 | it "writes config.yml" do 99 | Dir.mktmpdir do |tmpdir| 100 | source = fixtures_path.join('config', 'config.yml') 101 | target = Pathname(tmpdir).join('config.yml') 102 | AIPP.config.read! source 103 | AIPP.config.write! target 104 | _(FileUtils.identical?(source, target)).must_equal true 105 | end 106 | end 107 | end 108 | 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /spec/lib/aipp/patcher_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | class Shoe 4 | include AIPP::Debugger 5 | include AIPP::Patcher 6 | 7 | attr_accessor :size 8 | 9 | patch Shoe, :size do |object, value| 10 | case value 11 | when 'S' then 36 12 | when 'one-size-fits-all' then nil 13 | else throw(:abort) 14 | end 15 | end 16 | end 17 | 18 | describe AIPP::Patcher do 19 | subject do 20 | Shoe.new.attach_patches 21 | end 22 | 23 | context "with patches attached" do 24 | after do 25 | subject.detach_patches 26 | end 27 | 28 | it "overwrites with non-nil values" do 29 | _(subject.tap { _1.size = 'S' }.size).must_equal 36 30 | end 31 | 32 | it "overwrite with nil values" do 33 | _(subject.tap { _1.size = 'one-size-fits-all' }.size).must_be_nil 34 | end 35 | 36 | it "skips overwrite if abort is thrown" do 37 | _(subject.tap { _1.size = 42 }.size).must_equal 42 38 | end 39 | end 40 | 41 | context "with patches detached" do 42 | it "removes patches" do 43 | subject.detach_patches 44 | _(subject.tap { _1.size = 'S' }.size).must_equal 'S' 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/lib/aipp/pdf_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe AIPP::PDF do 4 | subject do 5 | AIPP::PDF.new(fixtures_path.join('pdf', 'document.pdf')) 6 | end 7 | 8 | describe :@page_ranges do 9 | it "returns an array of page end positions" do 10 | _(subject.instance_variable_get(:@page_ranges)).must_equal [74, 149, 225] 11 | end 12 | end 13 | 14 | describe :page_for do 15 | it "finds the page for any given position" do 16 | _(subject.send(:page_for, index: 0)).must_equal 1 17 | _(subject.send(:page_for, index: 50)).must_equal 1 18 | _(subject.send(:page_for, index: 74)).must_equal 1 19 | _(subject.send(:page_for, index: 75)).must_equal 2 20 | _(subject.send(:page_for, index: 149)).must_equal 2 21 | _(subject.send(:page_for, index: 150)).must_equal 3 22 | _(subject.send(:page_for, index: 223)).must_equal 3 23 | end 24 | end 25 | 26 | describe :from do 27 | it "fences beginning to any position" do 28 | _(subject.from(100).range).must_equal (100..223) 29 | end 30 | 31 | it "fences beginning to first existing position" do 32 | _(subject.from(:begin).range).must_equal (0..223) 33 | end 34 | end 35 | 36 | describe :to do 37 | it "fences beginning to any position" do 38 | _(subject.to(100).range).must_equal (0..100) 39 | end 40 | 41 | it "fences beginning to first existing position" do 42 | _(subject.to(:end).range).must_equal (0..223) 43 | end 44 | end 45 | 46 | context "without boundaries" do 47 | describe :text do 48 | it "returns the entire text" do 49 | _(subject.text).must_match(/\Apage 1, line 1/) 50 | _(subject.text).must_match(/page 3, line 5\z/) 51 | end 52 | end 53 | 54 | describe :each_line do 55 | it "maps lines to positions" do 56 | target = [ 57 | ["page 1, line 1\n", 1, false], 58 | ["page 1, line 2\n", 1, false], 59 | ["page 1, line 3\n", 1, false], 60 | ["page 1, line 4\n", 1, false], 61 | ["page 1, line 5\f", 1, false], 62 | ["page 2, line 1\n", 2, false], 63 | ["page 2, line 2\n", 2, false], 64 | ["page 2, line 3\n", 2, false], 65 | ["page 2, line 4\n", 2, false], 66 | ["page 2, line 5\f", 2, false], 67 | ["page 3, line 1\n", 3, false], 68 | ["page 3, line 2\n", 3, false], 69 | ["page 3, line 3\n", 3, false], 70 | ["page 3, line 4\n", 3, false], 71 | ["page 3, line 5", 3, true] 72 | ] 73 | subject.each_line do |line, page, last| 74 | target_line, target_page, target_last = target.shift 75 | _(line).must_equal target_line 76 | _(page).must_equal target_page 77 | _(last).must_equal target_last 78 | end 79 | end 80 | 81 | it "returns an enumerator if no block is given" do 82 | _(subject.each_line).must_be_instance_of Enumerator 83 | end 84 | end 85 | end 86 | 87 | context "with boundaries" do 88 | before do 89 | subject.from(100).to(200) 90 | end 91 | 92 | describe :text do 93 | it "returns the entire text" do 94 | _(subject.text).must_match(/\Ane 2/) 95 | _(subject.text).must_match(/page 3\z/) 96 | end 97 | end 98 | 99 | describe :each_line do 100 | it "maps lines to positions" do 101 | target = [ 102 | ["ne 2\n", 2, false], 103 | ["page 2, line 3\n", 2, false], 104 | ["page 2, line 4\n", 2, false], 105 | ["page 2, line 5\f", 2, false], 106 | ["page 3, line 1\n", 3, false], 107 | ["page 3, line 2\n", 3, false], 108 | ["page 3, line 3\n", 3, false], 109 | ["page 3", 3, true] 110 | ] 111 | subject.each_line do |line, page, last| 112 | target_line, target_page, target_last = target.shift 113 | _(line).must_equal target_line 114 | _(page).must_equal target_page 115 | _(last).must_equal target_last 116 | end 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /spec/lib/aipp/t_hash_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe AIPP::THash do 4 | context "non-circular dependencies" do 5 | subject do 6 | AIPP::THash[ 7 | dns: %i(net), 8 | webserver: %i(dns logger), 9 | net: [], 10 | logger: [] 11 | ] 12 | end 13 | 14 | describe :tsort do 15 | it "must compile the overall dependency list" do 16 | _(subject.tsort).must_equal %i(net dns logger webserver) 17 | end 18 | 19 | it "must compile partial dependency lists" do 20 | _(subject.tsort(:dns)).must_equal %i(net dns) 21 | _(subject.tsort(:logger)).must_equal %i(logger) 22 | _(subject.tsort(:webserver)).must_equal %i(net dns logger webserver) 23 | end 24 | end 25 | end 26 | 27 | context "circular dependencies" do 28 | subject do 29 | AIPP::THash[ 30 | dns: %i(net), 31 | webserver: %i(dns logger), 32 | net: %i(dns), 33 | logger: [] 34 | ] 35 | end 36 | 37 | describe :tsort do 38 | it "must raise cyclic dependency error" do 39 | _{ subject.tsort }.must_raise TSort::Cyclic 40 | _{ subject.tsort(:dns) }.must_raise TSort::Cyclic 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/lib/aipp/version_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe AIPP do 4 | it "must be defined" do 5 | _(AIPP::VERSION).wont_be_nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/lib/core_ext/array_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe Array do 4 | 5 | describe :constantize do 6 | it "returns non-namespaced class" do 7 | _(%w(Minitest).constantize).must_equal Minitest 8 | end 9 | 10 | it "returns namespaced class" do 11 | _(%w(AIPP Border).constantize).must_equal AIPP::Border 12 | end 13 | 14 | it "converts elements to string beforehand" do 15 | _(%i(AIPP Border).constantize).must_equal AIPP::Border 16 | end 17 | end 18 | 19 | describe :consolidate_ranges do 20 | it "leaves empty array untouched" do 21 | _([].consolidate_ranges).must_equal [] 22 | end 23 | 24 | it "leaves array with only one element untouched" do 25 | _([7..13].consolidate_ranges).must_equal [7..13] 26 | end 27 | 28 | it "consolidates identical ranges" do 29 | _([7..13, 7..13].consolidate_ranges).must_equal [7..13] 30 | end 31 | 32 | it "consolidates overlapping ranges" do 33 | _([7..13, 7..14].consolidate_ranges).must_equal [7..14] 34 | _([7..13, 6..13].consolidate_ranges).must_equal [6..13] 35 | _([7..13, 6..14].consolidate_ranges).must_equal [6..14] 36 | end 37 | 38 | it "consolidates adjacent ranges" do 39 | _([7..13, 13..15].consolidate_ranges).must_equal [7..15] 40 | end 41 | 42 | it "separates not overlapping ranges" do 43 | _([7..13, 14..17].consolidate_ranges).must_equal [7..13, 14..17] 44 | end 45 | 46 | it "consolidates complex ranges" do 47 | _([15..17, 7..11, 12..13, 8..12, 12..13].consolidate_ranges).must_equal [7..13, 15..17] 48 | end 49 | end 50 | 51 | end 52 | -------------------------------------------------------------------------------- /spec/lib/core_ext/enumberable_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe Enumerable do 4 | 5 | describe :split do 6 | context "by object" do 7 | it "must split at matching element" do 8 | _([1, 2, 0, 3, 4].split(0)).must_equal [[1, 2], [3, 4]] 9 | end 10 | 11 | it "won't split when no element matches" do 12 | _([1, 2, 3].split(0)).must_equal [[1, 2, 3]] 13 | end 14 | 15 | it "won't split zero length enumerable" do 16 | _([].split(0)).must_equal [] 17 | end 18 | 19 | it "must keep leading empty subarrays" do 20 | _([0, 1, 2, 0, 3, 4].split(0)).must_equal [[], [1, 2], [3, 4]] 21 | end 22 | 23 | it "must keep empty subarrays in the middle" do 24 | _([1, 2, 0, 0, 3, 4].split(0)).must_equal [[1, 2], [], [3, 4]] 25 | end 26 | 27 | it "must drop trailing empty subarrays" do 28 | _([1, 2, 0, 3, 4, 0].split(0)).must_equal [[1, 2], [3, 4]] 29 | end 30 | end 31 | 32 | context "by block" do 33 | it "must split at matching element" do 34 | _([1, 2, 0, 3, 4].split { _1.zero? }).must_equal [[1, 2], [3, 4]] 35 | end 36 | 37 | it "won't split when no element matches" do 38 | _([1, 2, 3].split { _1.zero? }).must_equal [[1, 2, 3]] 39 | end 40 | 41 | it "won't split zero length enumerable" do 42 | _([].split { _1.zero? }).must_equal [] 43 | end 44 | 45 | it "must keep leading empty subarrays" do 46 | _([0, 1, 2, 0, 3, 4].split { _1.zero? }).must_equal [[], [1, 2], [3, 4]] 47 | end 48 | 49 | it "must keep empty subarrays in the middle" do 50 | _([1, 2, 0, 0, 3, 4].split { _1.zero? }).must_equal [[1, 2], [], [3, 4]] 51 | end 52 | 53 | it "must drop trailing empty subarrays" do 54 | _([1, 2, 0, 3, 4, 0].split { _1.zero? }).must_equal [[1, 2], [3, 4]] 55 | end 56 | end 57 | end 58 | 59 | describe :group_by_chunks do 60 | it "fails to group if the first element does not meet the chunk condition" do 61 | subject = [10, 11, 12, 2, 20, 21 ] 62 | _{ subject.group_by_chunks { _1 < 10 } }.must_raise ArgumentError 63 | end 64 | 65 | it "must map matching elements to array of subsequent non-matching elements" do 66 | subject = [1, 10, 11, 12, 2, 20, 21, 3, 30, 31, 32] 67 | _(subject.group_by_chunks { _1 < 10 }).must_equal(1 => [10, 11, 12], 2 => [20, 21], 3 => [30, 31, 32]) 68 | end 69 | 70 | it "must map matching elements to empty array if no subsequent non-matching elements exist" do 71 | subject = [1, 10, 11, 12, 2, 3, 30] 72 | _(subject.group_by_chunks { _1 < 10 }).must_equal(1 => [10, 11, 12], 2 => [], 3 => [30]) 73 | end 74 | end 75 | 76 | end 77 | -------------------------------------------------------------------------------- /spec/lib/core_ext/hash_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe Hash do 4 | 5 | describe :metch do 6 | subject do 7 | { /aa/ => :aa, /a/ => :a, 'b' => :b } 8 | end 9 | 10 | it "must return value of matching regexp key" do 11 | _(subject.metch('abc')).must_equal :a 12 | end 13 | 14 | it "must return value of equal non-regexp key" do 15 | _(subject.metch('b')).must_equal :b 16 | end 17 | 18 | it "fails with KeyError if nothing matches" do 19 | _{ subject.metch('bcd') }.must_raise KeyError 20 | end 21 | 22 | it "returns fallback value if nothing matches" do 23 | _(subject.metch('x', :foobar)).must_equal :foobar 24 | _(subject.metch('x', nil)).must_be :nil? 25 | end 26 | end 27 | 28 | describe :to_remarks do 29 | it "upcases keys" do 30 | _({ 'key' => 'value' }.to_remarks).must_equal "**KEY**\nvalue" 31 | end 32 | 33 | it "converts keys and values to strings" do 34 | _({ 111 => 222 }.to_remarks).must_equal "**111**\n222" 35 | end 36 | 37 | it "removes nil values" do 38 | _({ 'key' => 'value', 'ignore' => nil }.to_remarks).must_equal "**KEY**\nvalue" 39 | end 40 | 41 | it "remvoes blank values" do 42 | _({ 'key' => 'value', 'ignore' => '' }.to_remarks).must_equal "**KEY**\nvalue" 43 | end 44 | 45 | it "returns nil if no content remains" do 46 | _({ 'ignore' => nil }.to_remarks).must_be :nil? 47 | end 48 | end 49 | 50 | end 51 | -------------------------------------------------------------------------------- /spec/lib/core_ext/integer_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe Integer do 4 | 5 | describe :up_or_downto do 6 | it "behaves like Integer#upto for an increasing range" do 7 | _(10.up_or_downto(12).to_a).must_equal 10.upto(12).to_a 8 | end 9 | 10 | it "behaves like Integer#downto for a decreasing range" do 11 | _(10.up_or_downto(8).to_a).must_equal 10.downto(8).to_a 12 | end 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /spec/lib/core_ext/nil_class_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe NilClass do 4 | 5 | describe :blank_to_nil do 6 | it "must return nil" do 7 | _(nil.blank_to_nil).must_be :nil? 8 | end 9 | end 10 | 11 | end 12 | -------------------------------------------------------------------------------- /spec/lib/core_ext/nokogiri_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe Nokogiri do 4 | subject do 5 | xml = <<~END 6 | 7 | 8 | foo 9 | bar 10 | Yes 11 | 12 | 13 | END 14 | Nokogiri.XML(xml, &:noblanks).at_css('record') 15 | end 16 | 17 | describe Nokogiri::PseudoClasses::Matches do 18 | describe ':matches()' do 19 | it "must find nodes with matching content" do 20 | _(subject.at('location:matches("^b.r")', Nokogiri::MATCHES)).must_equal subject.at('/xml/record/location') 21 | end 22 | 23 | it "finds nothing if no content matches" do 24 | _(subject.at('location:matches("^x")', Nokogiri::MATCHES)).must_be :nil? 25 | end 26 | end 27 | end 28 | 29 | describe Nokogiri::XML::Element do 30 | describe :call do 31 | it "must return the value for an existing contents key" do 32 | _(subject.(:name)).must_equal 'foo' 33 | end 34 | 35 | it "must return nil for a non-existing contents key" do 36 | _(subject.(:not_found)).must_be :nil? 37 | end 38 | 39 | context "postfixed !" do 40 | it "must return the value for an existing contents key" do 41 | _(subject.(:name!)).must_equal 'foo' 42 | end 43 | 44 | it "must fail for a non-existing contents key" do 45 | _{ subject.(:not_found!) }.must_raise KeyError 46 | end 47 | end 48 | 49 | context "postfixed ?" do 50 | it "must return the boolean equivalent for an existing contents key" do 51 | _(subject.(:boolean?)).must_equal true 52 | end 53 | 54 | it "must fail for content without boolean equivalent" do 55 | _{ subject.(:name?) }.must_raise KeyError 56 | end 57 | 58 | it "must fail for a non-existing contents key" do 59 | _{ subject.(:not_found?) }.must_raise KeyError 60 | end 61 | end 62 | end 63 | 64 | describe :contents do 65 | it "must convert child elements to hash" do 66 | _(subject.contents).must_equal({ name: 'foo', location: 'bar', boolean: 'Yes' }) 67 | end 68 | end 69 | 70 | describe :find_or_add_child do 71 | it "returns an existing child" do 72 | _(subject.find_or_add_child('location', after_css: []).to_s).must_equal 'bar' 73 | end 74 | 75 | context "after_css is given" do 76 | it "returns nil if child is not existing and no add position can be determined" do 77 | _(subject.find_or_add_child('missing', after_css: [])).must_be :nil? 78 | end 79 | 80 | it "adds a new child element after the last matching position and before_css is ignored" do 81 | added = subject.find_or_add_child('new', after_css: ['name', 'location', 'missing'], before_css: ['location']) 82 | _(added.to_s).must_equal '' 83 | _(added).must_be_instance_of Nokogiri::XML::Element 84 | _(subject.to_xml(indent: 2)).must_equal <<~END.strip 85 | 86 | foo 87 | bar 88 | 89 | Yes 90 | 91 | END 92 | end 93 | end 94 | 95 | context "only before_css is given" do 96 | it "returns nil if child is not existing and no add position can be determined" do 97 | _(subject.find_or_add_child('missing', before_css: [])).must_be :nil? 98 | end 99 | 100 | it "adds a new child element before the first matching position" do 101 | added = subject.find_or_add_child('new', before_css: ['missing', 'location', 'boolean']) 102 | _(added.to_s).must_equal '' 103 | _(added).must_be_instance_of Nokogiri::XML::Element 104 | _(subject.to_xml(indent: 2)).must_equal <<~END.strip 105 | 106 | foo 107 | 108 | bar 109 | Yes 110 | 111 | END 112 | end 113 | end 114 | 115 | context "neither after_css nor before_css are given" do 116 | it "adds a new child at last position" do 117 | added = subject.find_or_add_child('new') 118 | _(added.to_s).must_equal '' 119 | _(added).must_be_instance_of Nokogiri::XML::Element 120 | _(subject.to_xml(indent: 2)).must_equal <<~END.strip 121 | 122 | foo 123 | bar 124 | Yes 125 | 126 | 127 | END 128 | end 129 | end 130 | end 131 | 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /spec/lib/core_ext/string_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe String do 4 | 5 | describe :classify do 6 | it "converts sections following AIP naming conventions" do 7 | _("ENR".classify).must_equal "ENR" 8 | _("AD-2".classify).must_equal "AD2" 9 | _("ENR-4.1".classify).must_equal "ENR41" 10 | _("ENR-4.11".classify).must_equal "ENR411" 11 | end 12 | 13 | it "converts other sections" do 14 | _("navigational_aids".classify).must_equal "NavigationalAids" 15 | _("aerodromes".classify).must_equal "Aerodromes" 16 | _("other".classify).must_equal "Other" 17 | end 18 | 19 | it "ignores namespaces" do 20 | _("AIPP/LF/AIP/ENR-4.1".classify).must_equal "ENR41" 21 | end 22 | end 23 | 24 | describe :sectionize do 25 | it "converts class following AIP naming conventions" do 26 | _("ENR".sectionize).must_equal "ENR" 27 | _("AD2".sectionize).must_equal "AD-2" 28 | _("ENR41".sectionize).must_equal "ENR-4.1" 29 | _("ENR411".sectionize).must_equal "ENR-4.11" 30 | end 31 | 32 | it "converts other class" do 33 | _("NavigationalAids".sectionize).must_equal "navigational_aids" 34 | _("Aerodromes".sectionize).must_equal "aerodromes" 35 | _("Other".sectionize).must_equal "other" 36 | end 37 | 38 | it "ignores namespaces" do 39 | _("AIPP::LF::AIP::ENR41".sectionize).must_equal "ENR-4.1" 40 | end 41 | end 42 | 43 | describe :blank_to_nil do 44 | it "converts blank to nil" do 45 | _("\n \n ".blank_to_nil).must_be :nil? 46 | end 47 | 48 | it "leaves non-blank untouched" do 49 | _("foobar".blank_to_nil).must_equal "foobar" 50 | end 51 | 52 | it "leaves non-blank with whitespace untouched" do 53 | _("\nfoo bar\n".blank_to_nil).must_equal "\nfoo bar\n" 54 | end 55 | end 56 | 57 | describe :cleanup do 58 | it "replaces double apostrophes" do 59 | _("the ''Terror'' was a fine ship".cleanup).must_equal 'the "Terror" was a fine ship' 60 | end 61 | 62 | it "replaces funky apostrophes and quotes" do 63 | _("from ’a‘ to “b”".cleanup).must_equal %q(from 'a' to "b") 64 | end 65 | 66 | it "removes whitespace within quotes" do 67 | _('the " best " way to fly'.cleanup).must_equal 'the "best" way to fly' 68 | _(%Q(the " best\nway " to fly).cleanup).must_equal %Q(the "best\nway" to fly) 69 | end 70 | end 71 | 72 | describe :compact do 73 | it "removes unneccessary whitespace" do 74 | _(" foo\n\nbar \r".compact).must_equal "foo\nbar" 75 | _("foo\n \nbar".compact).must_equal "foo\nbar" 76 | _(" ".compact).must_equal "" 77 | _("\n \r \v ".compact).must_equal "" 78 | _("okay".compact).must_equal "okay" 79 | end 80 | end 81 | 82 | describe :full_strip do 83 | it "behaves like strip" do 84 | subject = " foobar\t\t" 85 | _(subject.full_strip).must_equal subject.strip 86 | end 87 | 88 | it "removes non-letterlike characters as well" do 89 | _(" - foobar :.".full_strip).must_equal "foobar" 90 | end 91 | end 92 | 93 | describe :first_match do 94 | context "one pattern" do 95 | subject { "A/A: 123.5 mhz" } 96 | 97 | it "returns the entire match if no capture group is present" do 98 | _(subject.first_match(/123\.5/)).must_equal "123.5" 99 | end 100 | 101 | it "returns the first matching capture group" do 102 | _(subject.first_match(/:\s+([\d.]+)/)).must_equal "123.5" 103 | end 104 | 105 | it "returns nil if the pattern doesn't match and no capture group is present" do 106 | _(subject.first_match(/121\.5/)).must_be :nil? 107 | end 108 | 109 | it "returns nil if the capture group doesn't match" do 110 | _(subject.first_match(/(121\.5)/)).must_be :nil? 111 | end 112 | 113 | it "returns default if the pattern doesn't match" do 114 | _(subject.first_match(/121\.5/, default: "123")).must_equal "123" 115 | end 116 | end 117 | 118 | context "multiple patterns" do 119 | subject { "LIM-LIH" } 120 | 121 | it "returns the entire match if no capture group is present" do 122 | _(subject.first_match(/LIH/, /LIM/)).must_equal "LIH" 123 | end 124 | 125 | it "returns the first matching capture group" do 126 | _(subject.first_match(/LI(H)/, /LI(M)/)).must_equal "H" 127 | end 128 | 129 | it "returns nil if the pattern doesn't match and no capture group is present" do 130 | _(subject.first_match(/LIA/, /LIB/)).must_be :nil? 131 | end 132 | 133 | it "returns nil if the capture group doesn't match" do 134 | _(subject.first_match(/LI(A)/, /LI(B)/)).must_be :nil? 135 | end 136 | 137 | it "returns default if the pattern doesn't match" do 138 | _(subject.first_match(/LIA/, /LIB/, default: "123")).must_equal "123" 139 | end 140 | end 141 | end 142 | 143 | describe :extract do 144 | subject do 145 | "This is #first# a test #second# of extract." 146 | end 147 | 148 | it "returns array of matches" do 149 | _(subject.extract(/#.+?#/)).must_equal ['#first#', '#second#'] 150 | end 151 | 152 | it "removes matches from the string" do 153 | subject.extract(/#.+?#/) 154 | _(subject).must_equal "This is a test of extract." 155 | end 156 | end 157 | 158 | describe :strip_markup do 159 | subject do 160 | 'This
contains   markup & entities.' 161 | end 162 | 163 | it "strips tags and entities" do 164 | _(subject.strip_markup).must_equal 'This contains markup entities.' 165 | end 166 | end 167 | 168 | describe :to_digest do 169 | it "returns short MD5 hex" do 170 | _("this is a test".to_digest).must_equal "54b0c58c" 171 | end 172 | end 173 | 174 | describe :to_ff do 175 | it "converts normal float numbers as does to_f" do 176 | _("5".to_ff).must_equal "5".to_f 177 | _("5.1".to_ff).must_equal "5.1".to_f 178 | _(" 5.2 ".to_ff).must_equal " 5.2 ".to_f 179 | end 180 | 181 | it "converts comma float numbers as well" do 182 | _("5,1".to_ff).must_equal "5.1".to_f 183 | _(" 5,2 ".to_ff).must_equal "5.2".to_f 184 | end 185 | end 186 | 187 | end 188 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | gem 'minitest' 2 | 3 | require 'pathname' 4 | 5 | require 'minitest/autorun' 6 | require Pathname(__dir__).join('..', 'lib', 'aipp') 7 | 8 | require 'minitest/flash' 9 | require 'minitest/focus' 10 | 11 | module AIPP 12 | def self.root 13 | Pathname(__dir__).join('..') 14 | end 15 | end 16 | 17 | class Minitest::Spec 18 | class << self 19 | alias_method :context, :describe 20 | end 21 | end 22 | 23 | def fixtures_path 24 | Pathname(__dir__).join('fixtures') 25 | end 26 | --------------------------------------------------------------------------------