fdc 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/bin/fdc +114 -0
- data/lib/fdc/converter.rb +284 -0
- data/lib/fdc/exceptions.rb +12 -0
- data/lib/fdc/utilities.rb +32 -0
- data/lib/fdc.rb +11 -0
- metadata +83 -0
data/bin/fdc
ADDED
@@ -0,0 +1,114 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'fdc'
|
4
|
+
require 'optparse'
|
5
|
+
|
6
|
+
ERROR_MISSING_ARGUMENT = -1
|
7
|
+
ERROR_INVALID_OPTION = -2
|
8
|
+
ERROR_FILE_EXPORT = -3
|
9
|
+
ERROR_INCOMPATIBLE_RUBY_VERSION = -4
|
10
|
+
|
11
|
+
# Lock out older and knowingly incompatible ruby versions
|
12
|
+
if RUBY_VERSION[0..2].to_f < 1.9 then
|
13
|
+
STDERR.puts <<-EOS
|
14
|
+
Incompatible Ruby version: #{RUBY_VERSION} (at least 1.9.1 required)
|
15
|
+
Get the latest version from ruby-lang.org!
|
16
|
+
EOS
|
17
|
+
exit(ERROR_INCOMPATIBLE_RUBY_VERSION)
|
18
|
+
end
|
19
|
+
|
20
|
+
options = {}
|
21
|
+
|
22
|
+
optparse = OptionParser.new do |opts|
|
23
|
+
opts.banner = "Usage: fdc [options] <file>..."
|
24
|
+
|
25
|
+
opts.separator ""
|
26
|
+
opts.separator "Options:"
|
27
|
+
|
28
|
+
# Define alternative destination directory
|
29
|
+
options[:dest] = nil
|
30
|
+
opts.on( "-d", "--destination DEST", String, "Alternative destination directory" ) do |dest|
|
31
|
+
options[:dest] = dest
|
32
|
+
end
|
33
|
+
|
34
|
+
# Define alternative destination directory
|
35
|
+
options[:stdout] = false
|
36
|
+
opts.on( "-s", "--stdout", String, "Print converted KML to STDOUT" ) do
|
37
|
+
options[:stdout] = true
|
38
|
+
end
|
39
|
+
|
40
|
+
# Clamp track to ground and ignore altitude information
|
41
|
+
options[:clamp] = false
|
42
|
+
opts.on( "-c", "--clamp", "Clamp track to ground") do
|
43
|
+
options[:clamp] = true
|
44
|
+
end
|
45
|
+
|
46
|
+
# Extrude track to ground to emphasize absolute height
|
47
|
+
options[:extrude] = false
|
48
|
+
opts.on( "-e", "--extrude", "Extrude track to ground") do
|
49
|
+
options[:extrude] = true
|
50
|
+
end
|
51
|
+
|
52
|
+
# Extrude track to ground to emphasize absolute height
|
53
|
+
options[:gps] = false
|
54
|
+
opts.on( "-g", "--gps-alt", "Use gps instead of barometric altitude") do
|
55
|
+
options[:gps] = true
|
56
|
+
end
|
57
|
+
|
58
|
+
# UTF-8 input file encoding
|
59
|
+
options[:utf] = "ISO-8859-1"
|
60
|
+
opts.on( "-u", "--utf8", "Set input file encoding to UTF-8") do
|
61
|
+
options[:utf] = "UTF-8"
|
62
|
+
end
|
63
|
+
|
64
|
+
# Verbose output
|
65
|
+
options[:verbose] = false
|
66
|
+
opts.on( "-v", "--verbose", "Verbose output") do
|
67
|
+
options[:verbose] = true
|
68
|
+
end
|
69
|
+
|
70
|
+
# Define help
|
71
|
+
opts.on_tail( "-h", "--help", "Display this help screen" ) do
|
72
|
+
STDERR.puts opts
|
73
|
+
exit
|
74
|
+
end
|
75
|
+
|
76
|
+
end
|
77
|
+
|
78
|
+
begin
|
79
|
+
optparse.parse!
|
80
|
+
rescue OptionParser::MissingArgument => e
|
81
|
+
STDERR.puts e.message
|
82
|
+
exit(ERROR_MISSING_ARGUMENT)
|
83
|
+
rescue OptionParser::InvalidOption => e
|
84
|
+
STDERR.puts e.message
|
85
|
+
exit(ERROR_INVALID_OPTION)
|
86
|
+
end
|
87
|
+
|
88
|
+
STDERR.puts optparse if ARGV.empty?
|
89
|
+
|
90
|
+
@converter = Fdc::Converter.new
|
91
|
+
|
92
|
+
ARGV.each do |file|
|
93
|
+
|
94
|
+
begin
|
95
|
+
@converter.parse(file, encoding=options[:utf])
|
96
|
+
rescue Fdc::FileReadError => e
|
97
|
+
STDERR.puts e.message
|
98
|
+
next
|
99
|
+
end
|
100
|
+
|
101
|
+
@converter.compile(clamp=options[:clamp], extrude=options[:extrude], gps=options[:gps])
|
102
|
+
|
103
|
+
if options[:stdout]
|
104
|
+
STDOUT.puts @converter.kml
|
105
|
+
else
|
106
|
+
begin
|
107
|
+
options[:dest] ? @converter.export(options[:dest]) : @converter.export
|
108
|
+
STDERR.puts "Successfully converted file: #{file}" if options[:verbose]
|
109
|
+
rescue Fdc::FileWriteError => e
|
110
|
+
STDERR.puts e.message
|
111
|
+
exit(ERROR_FILE_EXPORT)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,284 @@
|
|
1
|
+
require 'date'
|
2
|
+
require 'pathname'
|
3
|
+
require 'builder'
|
4
|
+
require 'fdc/utilities'
|
5
|
+
require 'fdc/exceptions'
|
6
|
+
|
7
|
+
module Fdc
|
8
|
+
|
9
|
+
# Class to convert IGC files to KML.
|
10
|
+
#
|
11
|
+
# @!attribute [r] kml
|
12
|
+
# @return [String] The KML document
|
13
|
+
#
|
14
|
+
# @example
|
15
|
+
# converter = Converter.new
|
16
|
+
# converter.parse(path/to/file.igc)
|
17
|
+
# converter.compile
|
18
|
+
# converter.export(output/dir)
|
19
|
+
class Converter
|
20
|
+
|
21
|
+
# The compiled KML document
|
22
|
+
attr_accessor :kml
|
23
|
+
|
24
|
+
# Load and parse an IGC file from the supplied path.
|
25
|
+
#
|
26
|
+
# @param [String] file The path to the IGC file
|
27
|
+
# @param [String] encoding The encoding of the input file
|
28
|
+
# @raise [Fdc::FileReadError] If file could not be loaded
|
29
|
+
# @raise [Fdc::FileFormatError] If the file format is invalid
|
30
|
+
def parse(file, encoding="ISO-8859-1")
|
31
|
+
|
32
|
+
# Update state
|
33
|
+
@path = Pathname.new(file)
|
34
|
+
@encoding = encoding
|
35
|
+
|
36
|
+
# Do work
|
37
|
+
load_file
|
38
|
+
parse_file
|
39
|
+
|
40
|
+
end
|
41
|
+
|
42
|
+
# Compile the KML document from the parsed IGC file.
|
43
|
+
#
|
44
|
+
# @param [Boolean] clamp Whether the track should be clamped to the ground
|
45
|
+
# @param [Boolean] extrude Whether the track should be extruded to the ground
|
46
|
+
# @param [Boolean] gps Whether GPS altitude information should be used
|
47
|
+
# @raise [RuntimeError] If {#parse} was not called before
|
48
|
+
def compile(clamp=false, extrude=false, gps=false)
|
49
|
+
|
50
|
+
# State assertion
|
51
|
+
raise RuntimeError, "Cannot compile before successfull parse" if @igc.nil? or @date.nil?
|
52
|
+
|
53
|
+
# Build HTML for balloon description
|
54
|
+
html = Builder::XmlMarkup.new(:indent => 2)
|
55
|
+
html.div :style => "width: 250;" do
|
56
|
+
html.p do
|
57
|
+
unless @a_records[3].nil? then
|
58
|
+
html.strong "Device:"
|
59
|
+
html.dfn @a_records[3].strip
|
60
|
+
html.br
|
61
|
+
end
|
62
|
+
end
|
63
|
+
html.p do
|
64
|
+
@h_records.each do |h|
|
65
|
+
if h.include? "PLT" and not h[2].strip.empty? then
|
66
|
+
html.strong "Pilot:"
|
67
|
+
html.dfn h[2].strip
|
68
|
+
html.br
|
69
|
+
end
|
70
|
+
if h.include? "CID" and not h[2].strip.empty? then
|
71
|
+
html.strong "Competition ID:"
|
72
|
+
html.dfn h[2].strip
|
73
|
+
html.br
|
74
|
+
end
|
75
|
+
if h.include? "GTY" and not h[2].strip.empty? then
|
76
|
+
html.strong "Glider:"
|
77
|
+
html.dfn h[2].strip
|
78
|
+
html.br
|
79
|
+
end
|
80
|
+
if h.include? "GID" and not h[2].strip.empty? then
|
81
|
+
html.strong "Glider ID:"
|
82
|
+
html.dfn h[2].strip
|
83
|
+
html.br
|
84
|
+
end
|
85
|
+
if h.include? "CCL" and not h[2].strip.empty? then
|
86
|
+
html.strong "Competition class:"
|
87
|
+
html.dfn h[2].strip
|
88
|
+
html.br
|
89
|
+
end
|
90
|
+
if h.include? "SIT" and not h[2].strip.empty? then
|
91
|
+
html.strong "Site:"
|
92
|
+
html.dfn h[2].strip
|
93
|
+
html.br
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
html.strong "Date:"
|
98
|
+
html.dfn @date[3..5].join(".")
|
99
|
+
html.br
|
100
|
+
end
|
101
|
+
|
102
|
+
# Manufacturer-dependent L records
|
103
|
+
case @a_records[1]
|
104
|
+
when "XSX"
|
105
|
+
@l_records.each do |l|
|
106
|
+
if matches = l[1].scan(/(\w*):(-?\d+.?\d+)/) then
|
107
|
+
html.p do
|
108
|
+
matches.each do |match|
|
109
|
+
case match[0]
|
110
|
+
when "MC"
|
111
|
+
html.strong "Max. climb:"
|
112
|
+
html.dfn match[1] << " m/s"
|
113
|
+
html.br
|
114
|
+
when "MS"
|
115
|
+
html.strong "Max. sink:"
|
116
|
+
html.dfn match[1] << " m/s"
|
117
|
+
html.br
|
118
|
+
when "MSP"
|
119
|
+
html.strong "Max. speed:"
|
120
|
+
html.dfn match[1] << " km/h"
|
121
|
+
html.br
|
122
|
+
when "Dist"
|
123
|
+
html.strong "Track distance:"
|
124
|
+
html.dfn match[1] << " km"
|
125
|
+
html.br
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
end
|
134
|
+
|
135
|
+
# Build KML
|
136
|
+
xml = Builder::XmlMarkup.new(:indent => 2)
|
137
|
+
xml.instruct!
|
138
|
+
xml.kml "xmlns" => "http://www.opengis.net/kml/2.2", "xmlns:gx" => "http://www.google.com/kml/ext/2.2" do
|
139
|
+
xml.Placemark {
|
140
|
+
xml.name @path.basename(@path.extname)
|
141
|
+
xml.Snippet :maxLines => "2" do
|
142
|
+
xml.text! snippet
|
143
|
+
end
|
144
|
+
xml.description do
|
145
|
+
xml.cdata! html.target!
|
146
|
+
end
|
147
|
+
xml.Style do
|
148
|
+
xml.IconStyle do
|
149
|
+
xml.Icon do
|
150
|
+
xml.href "http://earth.google.com/images/kml-icons/track-directional/track-0.png"
|
151
|
+
end
|
152
|
+
end
|
153
|
+
xml.LineStyle do
|
154
|
+
xml.color "99ffac59"
|
155
|
+
xml.width "4"
|
156
|
+
end
|
157
|
+
end
|
158
|
+
xml.gx:Track do
|
159
|
+
|
160
|
+
clamp ? xml.altitudeMode("clampToGround") : xml.altitudeMode("absolute")
|
161
|
+
extrude ? xml.extrude("1") : xml.extrude("0")
|
162
|
+
|
163
|
+
@b_records.each do |b_record|
|
164
|
+
time = DateTime.new(2000 + @date[5].to_i, @date[4].to_i, @date[3].to_i,
|
165
|
+
b_record[1].to_i, b_record[2].to_i, b_record[3].to_i)
|
166
|
+
xml.when time
|
167
|
+
end
|
168
|
+
@b_records.each do |b_record|
|
169
|
+
coords = Fdc::GeoLocation.to_dec(b_record[5], b_record[4])
|
170
|
+
gps ? coords << b_record[8].to_f : coords << b_record[7].to_f
|
171
|
+
xml.gx :coord, coords.join(" ")
|
172
|
+
end
|
173
|
+
end
|
174
|
+
}
|
175
|
+
end
|
176
|
+
|
177
|
+
@kml = xml.target!
|
178
|
+
end
|
179
|
+
|
180
|
+
# Export the compiled KML document
|
181
|
+
#
|
182
|
+
# @param [String] dir The alternative output directory.
|
183
|
+
# If nothing is supplied the files are written to the same location
|
184
|
+
# as the IGC input file.
|
185
|
+
# @raise [RuntimeError] If {#parse} and {#compile} were not called before
|
186
|
+
# @raise [Fdc::FileWriteError] If dirname is not a directory or write protected
|
187
|
+
def export(dir = nil)
|
188
|
+
|
189
|
+
# Assert state
|
190
|
+
raise RuntimeError, "Cannot export before compile was called" unless @kml
|
191
|
+
|
192
|
+
dir = @path.dirname.to_s unless dir
|
193
|
+
|
194
|
+
# Create Pathname for easier handling
|
195
|
+
dest = Pathname.new(dir)
|
196
|
+
|
197
|
+
# Create output file name
|
198
|
+
dest += @path.basename(@path.extname)
|
199
|
+
|
200
|
+
begin
|
201
|
+
file = File.new(dest.to_s << ".kml", "w:UTF-8")
|
202
|
+
rescue Errno::EACCES => e
|
203
|
+
raise Fdc::FileWriteError, "Destination is write-protected: #{dir.to_s}"
|
204
|
+
rescue Errno::ENOTDIR => e
|
205
|
+
raise Fdc::FileWriteError, "Destination is not a directory: #{dir.to_s}"
|
206
|
+
rescue Errno::ENOENT => e
|
207
|
+
raise Fdc::FileWriteError, "Destination does not exist: #{dir.to_s}"
|
208
|
+
end
|
209
|
+
|
210
|
+
file.write(@kml)
|
211
|
+
file.close
|
212
|
+
|
213
|
+
end
|
214
|
+
|
215
|
+
private
|
216
|
+
|
217
|
+
# Load igc file from supplied path
|
218
|
+
def load_file
|
219
|
+
|
220
|
+
raise Fdc::FileReadError, "Invalid file extension: #{@path.to_s}" unless @path.extname == ".igc"
|
221
|
+
|
222
|
+
# Load file
|
223
|
+
begin
|
224
|
+
file = File.new(@path, "r", :encoding => @encoding)
|
225
|
+
rescue Errno::EISDIR => e
|
226
|
+
raise Fdc::FileReadError, "Input file is a directory: #{@path.to_s}"
|
227
|
+
rescue Errno::ENOENT => e
|
228
|
+
raise Fdc::FileReadError, "Input file does not exist: #{@path.to_s}"
|
229
|
+
end
|
230
|
+
|
231
|
+
@igc = file.read
|
232
|
+
file.close
|
233
|
+
|
234
|
+
end
|
235
|
+
|
236
|
+
# Regular expressions for file parsing
|
237
|
+
REGEX_A = /^[a]([a-z\d]{3})([a-z\d]{3})?(.*)$/i
|
238
|
+
REGEX_H = /^[h][f|o|p]([\w]{3})(.*):(.*)$/i
|
239
|
+
REGEX_H_DTE = /^hf(dte)((\d{2})(\d{2})(\d{2}))/i
|
240
|
+
REGEX_B = /^(B)(\d{2})(\d{2})(\d{2})(\d{7}[NS])(\d{8}[EW])([AV])(\d{5})(\d{5})/
|
241
|
+
REGEX_L = /^l([a-z0-9]{3}|[plt]|[pfc])(.*)/i
|
242
|
+
|
243
|
+
# Parse igc file content
|
244
|
+
def parse_file
|
245
|
+
|
246
|
+
begin
|
247
|
+
|
248
|
+
# parse utc date
|
249
|
+
@date = @igc.match(REGEX_H_DTE)
|
250
|
+
raise Fdc::FileFormatError, "Invalid file format - header date is missing: #{@path.to_s}" unless @date
|
251
|
+
|
252
|
+
# parse a records
|
253
|
+
@a_records = @igc.match(REGEX_A)
|
254
|
+
raise Fdc::FileFormatError, "Invalid file format: #{@path.to_s}" unless @a_records
|
255
|
+
|
256
|
+
# parse h records
|
257
|
+
@h_records = @igc.scan(REGEX_H)
|
258
|
+
|
259
|
+
# parse b records
|
260
|
+
@b_records = @igc.scan(REGEX_B)
|
261
|
+
|
262
|
+
# parse l records
|
263
|
+
@l_records = @igc.scan(REGEX_L)
|
264
|
+
|
265
|
+
rescue ArgumentError => e
|
266
|
+
raise Fdc::FileFormatError, "Wrong file encoding: #{e.message}"
|
267
|
+
end
|
268
|
+
|
269
|
+
end
|
270
|
+
|
271
|
+
# Generate Snippet tag content
|
272
|
+
def snippet
|
273
|
+
summary = "Flight"
|
274
|
+
@h_records.each do |h|
|
275
|
+
if h.include? "SIT" and not h[2].strip.empty? then
|
276
|
+
summary << " from #{h[2].strip}"
|
277
|
+
end
|
278
|
+
end
|
279
|
+
summary << " on #{@date[3..5].join(".")}"
|
280
|
+
end
|
281
|
+
|
282
|
+
end
|
283
|
+
|
284
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module Fdc
|
2
|
+
|
3
|
+
# Exception caused by invalid input file format
|
4
|
+
class FileFormatError < StandardError; end
|
5
|
+
|
6
|
+
# Exception that is raised when a file cannot be read
|
7
|
+
class FileReadError < StandardError; end
|
8
|
+
|
9
|
+
# Exception that is raised when a files cannot be written
|
10
|
+
class FileWriteError < StandardError; end
|
11
|
+
|
12
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# Utility classes used by {IGCConverter}
|
2
|
+
module Fdc
|
3
|
+
|
4
|
+
# Module with helper functions for geocoordinate conversion
|
5
|
+
module GeoLocation
|
6
|
+
|
7
|
+
# Convert geocoordinates from mindec notation of IGC to dec notation
|
8
|
+
#
|
9
|
+
# @param [String] long The longitude from the igc file
|
10
|
+
# @param [String] lat The Latitude from the igc file
|
11
|
+
# @return [Float, Float] Longitude and Latitude in decimal notation
|
12
|
+
# @example Convert a pair of coordinates
|
13
|
+
# GeoLocation.to_dec("01343272E", "4722676N") #=>[13.7212,47.37793333333333]
|
14
|
+
def GeoLocation.to_dec(long, lat)
|
15
|
+
|
16
|
+
long_m = long.match(/^(\d{3})((\d{2})(\d{3}))(E|W)/)
|
17
|
+
lat_m = lat.match(/^(\d{2})((\d{2})(\d{3}))(N|S)/)
|
18
|
+
|
19
|
+
# Convert minutes to decimal
|
20
|
+
long_dec = long_m[1].to_f + (long_m[2].to_f / 1000 / 60)
|
21
|
+
lat_dec = lat_m[1].to_f + (lat_m[2].to_f / 1000 / 60)
|
22
|
+
|
23
|
+
# Change signs according to direction
|
24
|
+
long_dec *= (-1) if long_m[5] == "W"
|
25
|
+
lat_dec *= (-1) if lat_m[5] == "S"
|
26
|
+
|
27
|
+
return long_dec, lat_dec
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
data/lib/fdc.rb
ADDED
metadata
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: fdc
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Tobias Noiges
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-06-20 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: builder
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: 3.0.0
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ~>
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: 3.0.0
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: rake
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ~>
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: 0.9.2
|
38
|
+
type: :development
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ~>
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: 0.9.2
|
46
|
+
description: Convert files in the avionics flight recorder data format (IGC) to the
|
47
|
+
keyhole markup language (KML) for display in Applications such as Google Earth.
|
48
|
+
email: tobias@noig.es
|
49
|
+
executables:
|
50
|
+
- fdc
|
51
|
+
extensions: []
|
52
|
+
extra_rdoc_files: []
|
53
|
+
files:
|
54
|
+
- lib/fdc/converter.rb
|
55
|
+
- lib/fdc/exceptions.rb
|
56
|
+
- lib/fdc/utilities.rb
|
57
|
+
- lib/fdc.rb
|
58
|
+
- bin/fdc
|
59
|
+
homepage: https://github.com/nokinen/igc-kml
|
60
|
+
licenses: []
|
61
|
+
post_install_message:
|
62
|
+
rdoc_options: []
|
63
|
+
require_paths:
|
64
|
+
- lib
|
65
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
66
|
+
none: false
|
67
|
+
requirements:
|
68
|
+
- - ! '>='
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
version: '0'
|
71
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
72
|
+
none: false
|
73
|
+
requirements:
|
74
|
+
- - ! '>='
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: '0'
|
77
|
+
requirements: []
|
78
|
+
rubyforge_project:
|
79
|
+
rubygems_version: 1.8.24
|
80
|
+
signing_key:
|
81
|
+
specification_version: 3
|
82
|
+
summary: Convert flight data format files (IGC) to keyhole markup language (KML)
|
83
|
+
test_files: []
|