git-fit 0.7.9 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/git-fit.rb +44 -42
- data/lib/git_fit/cli/export.rb +8 -6
- data/lib/git_fit/cli/geo_cli.rb +10 -8
- data/lib/git_fit/cli/gh_cli.rb +24 -22
- data/lib/git_fit/cli/import_cli.rb +40 -38
- data/lib/git_fit/cli/install_cli.rb +10 -8
- data/lib/git_fit/cli/sync.rb +9 -7
- data/lib/git_fit/cli.rb +29 -27
- data/lib/git_fit/config.rb +42 -28
- data/lib/git_fit/config_template.rb +2 -0
- data/lib/git_fit/db/activity.rb +2 -0
- data/lib/git_fit/db/cli.rb +11 -9
- data/lib/git_fit/db/connection.rb +8 -6
- data/lib/git_fit/db/rebuild.rb +5 -3
- data/lib/git_fit/export/defaults.rb +14 -12
- data/lib/git_fit/export/json.rb +5 -3
- data/lib/git_fit/export.rb +4 -2
- data/lib/git_fit/fit/decoder.rb +9 -7
- data/lib/git_fit/fit.rb +3 -1
- data/lib/git_fit/geo/amap_client.rb +15 -13
- data/lib/git_fit/geo/coord_transform.rb +2 -0
- data/lib/git_fit/geo/division_detector.rb +31 -26
- data/lib/git_fit/geo/nominatim_client.rb +14 -12
- data/lib/git_fit/geo/polyline.rb +4 -2
- data/lib/git_fit/geo/reverse_geocode.rb +11 -9
- data/lib/git_fit/geo.rb +8 -6
- data/lib/git_fit/import/apple_health.rb +84 -82
- data/lib/git_fit/import/local_file.rb +45 -42
- data/lib/git_fit/import.rb +4 -2
- data/lib/git_fit/install/actions.rb +15 -13
- data/lib/git_fit/parser/base.rb +12 -10
- data/lib/git_fit/parser/fit.rb +11 -9
- data/lib/git_fit/parser/gpx.rb +17 -15
- data/lib/git_fit/parser/tcx.rb +22 -20
- data/lib/git_fit/parser.rb +6 -4
- data/lib/git_fit/privacy/polyline_filter.rb +6 -4
- data/lib/git_fit/source/base.rb +25 -23
- data/lib/git_fit/source/tally.rb +9 -7
- data/lib/git_fit/source.rb +4 -2
- data/lib/git_fit/sport_mapper.rb +23 -21
- data/lib/git_fit/sync/base.rb +29 -27
- data/lib/git_fit/sync/garmin.rb +5 -3
- data/lib/git_fit/sync/garmin_base.rb +145 -143
- data/lib/git_fit/sync/garmin_base_di.rb +42 -40
- data/lib/git_fit/sync/garmin_cn.rb +8 -6
- data/lib/git_fit/sync/igpsport.rb +43 -41
- data/lib/git_fit/sync/keep.rb +98 -96
- data/lib/git_fit/sync/lorem.rb +31 -29
- data/lib/git_fit/sync/runner.rb +14 -11
- data/lib/git_fit/sync/strava.rb +61 -52
- data/lib/git_fit/sync/xingzhe.rb +52 -50
- data/lib/git_fit/sync/xoss.rb +68 -66
- data/lib/git_fit/timezone/resolver.rb +5 -3
- data/lib/git_fit/util/tally.rb +10 -8
- data/lib/git_fit/util.rb +2 -0
- data/lib/git_fit/version.rb +3 -1
- metadata +1 -1
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
7
|
-
require
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'nokogiri'
|
|
7
|
+
require 'time'
|
|
8
|
+
require 'tzinfo'
|
|
9
|
+
require 'zip'
|
|
8
10
|
|
|
9
11
|
module GitFit
|
|
10
12
|
module Import
|
|
@@ -16,7 +18,7 @@ module GitFit
|
|
|
16
18
|
|
|
17
19
|
APPLE_WATCH_PATTERN = /Apple Watch/
|
|
18
20
|
|
|
19
|
-
DEFAULT_ZIP_PATH =
|
|
21
|
+
DEFAULT_ZIP_PATH = 'data/import/apple_health/export.zip'
|
|
20
22
|
|
|
21
23
|
HeartRateRecord = Struct.new(:time, :value, keyword_init: true)
|
|
22
24
|
|
|
@@ -24,7 +26,7 @@ module GitFit
|
|
|
24
26
|
|
|
25
27
|
def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil)
|
|
26
28
|
super
|
|
27
|
-
@zip_path = @config[
|
|
29
|
+
@zip_path = @config['zip_path'] || DEFAULT_ZIP_PATH
|
|
28
30
|
@heart_rate_index = nil
|
|
29
31
|
@heart_rate_by_date = nil
|
|
30
32
|
@skip_patterns = load_skip_sources
|
|
@@ -64,40 +66,40 @@ module GitFit
|
|
|
64
66
|
hr_records = []
|
|
65
67
|
@route_index = []
|
|
66
68
|
zip = Zip::File.new(@zip_path)
|
|
67
|
-
xml_entry = zip.find_entry(
|
|
69
|
+
xml_entry = zip.find_entry('apple_health_export/export.xml')
|
|
68
70
|
return results unless xml_entry
|
|
69
71
|
|
|
70
72
|
io = xml_entry.get_input_stream
|
|
71
|
-
buffer =
|
|
73
|
+
buffer = ''
|
|
72
74
|
in_workout = false
|
|
73
75
|
route_buf = nil
|
|
74
76
|
|
|
75
77
|
io.each_line do |line|
|
|
76
|
-
if line.include?(
|
|
78
|
+
if line.include?('HKQuantityTypeIdentifierHeartRate') && APPLE_WATCH_PATTERN.match?(line)
|
|
77
79
|
hr = parse_hr_line(line)
|
|
78
80
|
hr_records << hr if hr
|
|
79
81
|
end
|
|
80
82
|
|
|
81
|
-
if line.include?(
|
|
83
|
+
if line.include?('<WorkoutRoute ')
|
|
82
84
|
route_buf = line
|
|
83
85
|
elsif route_buf
|
|
84
86
|
route_buf << line
|
|
85
|
-
if line.include?(
|
|
87
|
+
if line.include?('</WorkoutRoute>')
|
|
86
88
|
process_route_entry(route_buf)
|
|
87
89
|
route_buf = nil
|
|
88
90
|
end
|
|
89
91
|
end
|
|
90
92
|
|
|
91
93
|
if !in_workout
|
|
92
|
-
if line.include?(
|
|
94
|
+
if line.include?('<Workout ')
|
|
93
95
|
in_workout = true
|
|
94
96
|
buffer = line
|
|
95
97
|
end
|
|
96
98
|
else
|
|
97
99
|
buffer << line
|
|
98
|
-
if line.include?(
|
|
100
|
+
if line.include?('</Workout>')
|
|
99
101
|
process_workout_chunk(buffer, existing, results)
|
|
100
|
-
buffer =
|
|
102
|
+
buffer = ''
|
|
101
103
|
in_workout = false
|
|
102
104
|
end
|
|
103
105
|
end
|
|
@@ -107,7 +109,7 @@ module GitFit
|
|
|
107
109
|
|
|
108
110
|
@heart_rate_by_date = {}
|
|
109
111
|
@heart_rate_index.each do |hr|
|
|
110
|
-
date_key = hr.time.strftime(
|
|
112
|
+
date_key = hr.time.strftime('%Y-%m-%d')
|
|
111
113
|
@heart_rate_by_date[date_key] ||= []
|
|
112
114
|
@heart_rate_by_date[date_key] << hr
|
|
113
115
|
end
|
|
@@ -153,7 +155,7 @@ module GitFit
|
|
|
153
155
|
begin
|
|
154
156
|
zip = Zip::File.new(@zip_path)
|
|
155
157
|
route_paths.each do |gpx_path|
|
|
156
|
-
normalized = gpx_path.sub(%r{^/?},
|
|
158
|
+
normalized = gpx_path.sub(%r{^/?}, 'apple_health_export/')
|
|
157
159
|
entry = zip.find_entry(normalized)
|
|
158
160
|
next unless entry
|
|
159
161
|
data = entry.get_input_stream.read
|
|
@@ -162,7 +164,7 @@ module GitFit
|
|
|
162
164
|
File.rename(tmpp, gpx_l1_path)
|
|
163
165
|
break
|
|
164
166
|
end
|
|
165
|
-
rescue => e
|
|
167
|
+
rescue StandardError => e
|
|
166
168
|
$stderr.puts "AppleHealth: GPX extract error for #{run_id}: #{e.message}"
|
|
167
169
|
ensure
|
|
168
170
|
zip&.close
|
|
@@ -210,15 +212,15 @@ module GitFit
|
|
|
210
212
|
end
|
|
211
213
|
|
|
212
214
|
def source_label
|
|
213
|
-
|
|
215
|
+
'AppleHealth'
|
|
214
216
|
end
|
|
215
217
|
|
|
216
218
|
def raw_dir
|
|
217
|
-
File.join(
|
|
219
|
+
File.join('data', 'raw', 'apple_health')
|
|
218
220
|
end
|
|
219
221
|
|
|
220
222
|
def std_dir
|
|
221
|
-
File.join(
|
|
223
|
+
File.join('data', 'std', 'apple_health')
|
|
222
224
|
end
|
|
223
225
|
|
|
224
226
|
def raw_path(platform_id)
|
|
@@ -233,10 +235,10 @@ module GitFit
|
|
|
233
235
|
end
|
|
234
236
|
|
|
235
237
|
def load_skip_sources
|
|
236
|
-
configured = @config[
|
|
238
|
+
configured = @config['skip_sources']
|
|
237
239
|
return [] unless configured
|
|
238
240
|
|
|
239
|
-
list = configured.is_a?(String) ? configured.split(
|
|
241
|
+
list = configured.is_a?(String) ? configured.split(',').map(&:strip) : configured
|
|
240
242
|
validated = list.select do |name|
|
|
241
243
|
SKIPPED_SOURCE_PATTERNS.any? { |p| p.match?(name) }
|
|
242
244
|
end
|
|
@@ -247,7 +249,7 @@ module GitFit
|
|
|
247
249
|
end
|
|
248
250
|
|
|
249
251
|
def existing_run_ids_set
|
|
250
|
-
@db[:activities].where(source:
|
|
252
|
+
@db[:activities].where(source: 'apple_health').select_map(:run_id).map(&:to_s).to_set
|
|
251
253
|
end
|
|
252
254
|
|
|
253
255
|
def parse_hr_line(line)
|
|
@@ -277,7 +279,7 @@ module GitFit
|
|
|
277
279
|
if @heart_rate_by_date
|
|
278
280
|
results = []
|
|
279
281
|
(start_t.to_date..end_t.to_date).each do |date|
|
|
280
|
-
date_key = date.strftime(
|
|
282
|
+
date_key = date.strftime('%Y-%m-%d')
|
|
281
283
|
bucket = @heart_rate_by_date[date_key]
|
|
282
284
|
if bucket&.any?
|
|
283
285
|
lo = bucket.bsearch_index { |hr| hr.time >= start_t }
|
|
@@ -301,24 +303,24 @@ module GitFit
|
|
|
301
303
|
|
|
302
304
|
def process_workout_chunk(raw_xml, existing_ids, results)
|
|
303
305
|
doc = Nokogiri::XML("<root>#{raw_xml}</root>")
|
|
304
|
-
workout = doc.at_xpath(
|
|
306
|
+
workout = doc.at_xpath('//Workout')
|
|
305
307
|
return unless workout
|
|
306
308
|
|
|
307
|
-
source_name = workout[
|
|
309
|
+
source_name = workout['sourceName']
|
|
308
310
|
return if known_source?(source_name)
|
|
309
311
|
|
|
310
|
-
workout_type = workout[
|
|
312
|
+
workout_type = workout['workoutActivityType']
|
|
311
313
|
return unless workout_type
|
|
312
314
|
|
|
313
|
-
start_date = workout[
|
|
314
|
-
end_date = workout[
|
|
315
|
+
start_date = workout['startDate']
|
|
316
|
+
end_date = workout['endDate']
|
|
315
317
|
return if start_date.nil? || end_date.nil?
|
|
316
318
|
|
|
317
|
-
duration = workout[
|
|
318
|
-
duration_unit = workout[
|
|
319
|
+
duration = workout['duration']
|
|
320
|
+
duration_unit = workout['durationUnit'] || 'min'
|
|
319
321
|
return if duration.nil?
|
|
320
322
|
|
|
321
|
-
creation_date = workout[
|
|
323
|
+
creation_date = workout['creationDate'] || start_date
|
|
322
324
|
run_id = generate_run_id(source_name, creation_date, start_date, end_date)
|
|
323
325
|
return if existing_ids.include?("apple_health_#{run_id}")
|
|
324
326
|
|
|
@@ -329,7 +331,7 @@ module GitFit
|
|
|
329
331
|
meta = extract_metadata(workout)
|
|
330
332
|
route_paths = extract_route_paths(workout)
|
|
331
333
|
route_paths = match_routes_by_time(start_date, end_date) if route_paths.empty?
|
|
332
|
-
source_version = workout[
|
|
334
|
+
source_version = workout['sourceVersion']
|
|
333
335
|
|
|
334
336
|
results << {
|
|
335
337
|
id: run_id,
|
|
@@ -346,26 +348,26 @@ module GitFit
|
|
|
346
348
|
stats: stats,
|
|
347
349
|
meta: meta,
|
|
348
350
|
route_paths: route_paths,
|
|
349
|
-
creation_date: creation_date
|
|
351
|
+
creation_date: creation_date,
|
|
350
352
|
}
|
|
351
353
|
end
|
|
352
354
|
|
|
353
355
|
def process_route_entry(xml)
|
|
354
356
|
doc = Nokogiri::XML("<root>#{xml}</root>")
|
|
355
|
-
route = doc.at_xpath(
|
|
357
|
+
route = doc.at_xpath('//WorkoutRoute')
|
|
356
358
|
return unless route
|
|
357
359
|
|
|
358
|
-
start_date = route[
|
|
359
|
-
end_date = route[
|
|
360
|
+
start_date = route['startDate']
|
|
361
|
+
end_date = route['endDate']
|
|
360
362
|
return unless start_date && end_date
|
|
361
363
|
|
|
362
|
-
paths = route.xpath(
|
|
364
|
+
paths = route.xpath('FileReference').filter_map { |ref| ref['path'] }
|
|
363
365
|
return if paths.empty?
|
|
364
366
|
|
|
365
367
|
@route_index << {
|
|
366
368
|
route_start: (Time.parse(start_date) rescue nil),
|
|
367
369
|
route_end: (Time.parse(end_date) rescue nil),
|
|
368
|
-
route_paths: paths
|
|
370
|
+
route_paths: paths,
|
|
369
371
|
}
|
|
370
372
|
end
|
|
371
373
|
|
|
@@ -386,47 +388,47 @@ module GitFit
|
|
|
386
388
|
|
|
387
389
|
def extract_statistics(workout)
|
|
388
390
|
stats = {}
|
|
389
|
-
workout.xpath(
|
|
390
|
-
type = stat[
|
|
391
|
-
sum = stat[
|
|
392
|
-
unit = stat[
|
|
391
|
+
workout.xpath('WorkoutStatistics').each do |stat|
|
|
392
|
+
type = stat['type']
|
|
393
|
+
sum = stat['sum']
|
|
394
|
+
unit = stat['unit']
|
|
393
395
|
next unless type && sum
|
|
394
|
-
stats[type] = {
|
|
396
|
+
stats[type] = { 'sum' => sum, 'unit' => unit }
|
|
395
397
|
end
|
|
396
398
|
stats
|
|
397
399
|
end
|
|
398
400
|
|
|
399
401
|
def extract_metadata(workout)
|
|
400
402
|
meta = {}
|
|
401
|
-
workout.xpath(
|
|
402
|
-
key = entry[
|
|
403
|
-
value = entry[
|
|
403
|
+
workout.xpath('MetadataEntry').each do |entry|
|
|
404
|
+
key = entry['key']
|
|
405
|
+
value = entry['value']
|
|
404
406
|
meta[key] = value if key && value
|
|
405
407
|
end
|
|
406
408
|
meta
|
|
407
409
|
end
|
|
408
410
|
|
|
409
411
|
def extract_route_paths(workout)
|
|
410
|
-
workout.xpath(
|
|
411
|
-
ref[
|
|
412
|
+
workout.xpath('WorkoutRoute/FileReference').filter_map do |ref|
|
|
413
|
+
ref['path']
|
|
412
414
|
end
|
|
413
415
|
end
|
|
414
416
|
|
|
415
417
|
def parse_gpx_file(file_path)
|
|
416
418
|
doc = Nokogiri::XML(File.read(file_path))
|
|
417
419
|
doc.remove_namespaces!
|
|
418
|
-
doc.xpath(
|
|
419
|
-
lat = pt[
|
|
420
|
-
lng = pt[
|
|
420
|
+
doc.xpath('//trkpt').filter_map do |pt|
|
|
421
|
+
lat = pt['lat']&.to_f
|
|
422
|
+
lng = pt['lon']&.to_f
|
|
421
423
|
next unless lat && lng
|
|
422
|
-
ele = pt.at_xpath(
|
|
423
|
-
time_str = pt.at_xpath(
|
|
424
|
-
ext = pt.at_xpath(
|
|
425
|
-
speed = ext&.at_xpath(
|
|
424
|
+
ele = pt.at_xpath('ele')&.text&.to_f
|
|
425
|
+
time_str = pt.at_xpath('time')&.text
|
|
426
|
+
ext = pt.at_xpath('extensions')
|
|
427
|
+
speed = ext&.at_xpath('speed')&.text&.to_f
|
|
426
428
|
speed = nil if speed && speed < MIN_GPX_SPEED
|
|
427
429
|
{ lat: lat, lng: lng, ele: ele, time: time_str, speed: speed }
|
|
428
430
|
end
|
|
429
|
-
rescue => e
|
|
431
|
+
rescue StandardError => e
|
|
430
432
|
$stderr.puts "AppleHealth: GPX parse error for #{file_path}: #{e.message}"
|
|
431
433
|
nil
|
|
432
434
|
end
|
|
@@ -439,10 +441,10 @@ module GitFit
|
|
|
439
441
|
|
|
440
442
|
cals_sum = 0.0
|
|
441
443
|
stats.each do |type_str, data|
|
|
442
|
-
if type_str.include?(
|
|
443
|
-
distance = parse_distance(data[
|
|
444
|
-
elsif type_str.include?(
|
|
445
|
-
cals_sum += data[
|
|
444
|
+
if type_str.include?('Distance')
|
|
445
|
+
distance = parse_distance(data['sum'], data['unit'])
|
|
446
|
+
elsif type_str.include?('ActiveEnergyBurned') || type_str.include?('BasalEnergyBurned')
|
|
447
|
+
cals_sum += data['sum'].to_f
|
|
446
448
|
end
|
|
447
449
|
end
|
|
448
450
|
calories = cals_sum > 0 ? cals_sum.round : nil
|
|
@@ -462,16 +464,16 @@ module GitFit
|
|
|
462
464
|
sport_category: map_sport_type(workout_type),
|
|
463
465
|
sport_type: workout_type,
|
|
464
466
|
start_date: format_datetime(start_date),
|
|
465
|
-
start_date_local: meta[
|
|
467
|
+
start_date_local: meta['HKTimeZone'] ? format_datetime(start_date, utc: false) : nil,
|
|
466
468
|
summary_polyline: polyline,
|
|
467
469
|
average_heartrate: hr_avg,
|
|
468
470
|
max_heartrate: hr_max,
|
|
469
471
|
average_speed: avg_speed,
|
|
470
472
|
calories: calories,
|
|
471
473
|
elevation_gain: elevation_gain&.round(1),
|
|
472
|
-
average_temperature: parse_temperature(meta[
|
|
473
|
-
source:
|
|
474
|
-
external_id: meta[
|
|
474
|
+
average_temperature: parse_temperature(meta['HKWeatherTemperature']),
|
|
475
|
+
source: 'apple_health',
|
|
476
|
+
external_id: meta['HKExternalUUID'],
|
|
475
477
|
}
|
|
476
478
|
end
|
|
477
479
|
|
|
@@ -493,9 +495,9 @@ module GitFit
|
|
|
493
495
|
def parse_duration(value, unit)
|
|
494
496
|
v = value.to_f
|
|
495
497
|
case unit.to_s.downcase
|
|
496
|
-
when
|
|
497
|
-
when
|
|
498
|
-
when
|
|
498
|
+
when 'min' then (v * 60).round
|
|
499
|
+
when 'sec' then v.round
|
|
500
|
+
when 'hour' then (v * 3600).round
|
|
499
501
|
else (v * 60).round
|
|
500
502
|
end
|
|
501
503
|
end
|
|
@@ -503,10 +505,10 @@ module GitFit
|
|
|
503
505
|
def parse_distance(value, unit)
|
|
504
506
|
v = value.to_f
|
|
505
507
|
case unit.to_s.downcase
|
|
506
|
-
when
|
|
507
|
-
when
|
|
508
|
-
when
|
|
509
|
-
when
|
|
508
|
+
when 'km' then (v * 1000).round(2)
|
|
509
|
+
when 'mi' then (v * 1609.344).round(2)
|
|
510
|
+
when 'm' then v.round(2)
|
|
511
|
+
when 'ft' then (v * 0.3048).round(2)
|
|
510
512
|
else v.round(2)
|
|
511
513
|
end
|
|
512
514
|
end
|
|
@@ -517,7 +519,7 @@ module GitFit
|
|
|
517
519
|
return nil unless m
|
|
518
520
|
num = m[1].to_f
|
|
519
521
|
unit = m[2].downcase
|
|
520
|
-
if unit.start_with?(
|
|
522
|
+
if unit.start_with?('degf') || unit.start_with?('°f')
|
|
521
523
|
((num - 32) * 5.0 / 9.0).round(1)
|
|
522
524
|
else
|
|
523
525
|
num.round(1)
|
|
@@ -536,22 +538,22 @@ module GitFit
|
|
|
536
538
|
def normalize_date(date_str)
|
|
537
539
|
t = Time.parse(date_str) rescue nil
|
|
538
540
|
return date_str unless t
|
|
539
|
-
t.utc.strftime(
|
|
541
|
+
t.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
540
542
|
end
|
|
541
543
|
|
|
542
544
|
def map_sport_type(workout_type)
|
|
543
|
-
return
|
|
544
|
-
key = workout_type.sub(/\AHKWorkoutActivityType/,
|
|
545
|
-
return
|
|
545
|
+
return 'other' unless workout_type
|
|
546
|
+
key = workout_type.sub(/\AHKWorkoutActivityType/, '')
|
|
547
|
+
return 'workout' if key.downcase.include?('strength')
|
|
546
548
|
GitFit::SportMapper.canonicalize(key)
|
|
547
549
|
end
|
|
548
550
|
|
|
549
551
|
def display_name(workout_type, _start_date)
|
|
550
|
-
workout_type.sub(/\AHKWorkoutActivityType/,
|
|
552
|
+
workout_type.sub(/\AHKWorkoutActivityType/, '')
|
|
551
553
|
end
|
|
552
554
|
|
|
553
555
|
def build_title(workout_type, start_date)
|
|
554
|
-
label = workout_type.sub(/\AHKWorkoutActivityType/,
|
|
556
|
+
label = workout_type.sub(/\AHKWorkoutActivityType/, '')
|
|
555
557
|
date = start_date ? start_date[0..9] : nil
|
|
556
558
|
date ? "#{date} · #{label}" : label
|
|
557
559
|
end
|
|
@@ -1,23 +1,26 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'nokogiri'
|
|
7
|
+
require 'tzinfo'
|
|
6
8
|
|
|
7
9
|
module GitFit
|
|
8
10
|
module Import
|
|
9
11
|
class LocalFile < Source::Base
|
|
10
12
|
IMPORT_DIRS = {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
'gpx' => 'data/import/gpx',
|
|
14
|
+
'tcx' => 'data/import/tcx',
|
|
15
|
+
'fit' => 'data/import/fit',
|
|
14
16
|
}.freeze
|
|
15
17
|
|
|
16
18
|
SUPPORTED_EXTENSIONS = %w[.gpx .tcx .fit].freeze
|
|
17
19
|
|
|
18
|
-
NS_TCX = { tcx:
|
|
20
|
+
NS_TCX = { tcx: 'http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2' }.freeze
|
|
19
21
|
|
|
20
|
-
def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil, sources: nil,
|
|
22
|
+
def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil, sources: nil,
|
|
23
|
+
sport_override: nil)
|
|
21
24
|
super(config:, db:, activity_filter:, privacy:, time_budget:)
|
|
22
25
|
@sources = sources
|
|
23
26
|
@sport_override = sport_override
|
|
@@ -32,7 +35,7 @@ module GitFit
|
|
|
32
35
|
dir = IMPORT_DIRS[source]
|
|
33
36
|
next unless dir && File.directory?(dir)
|
|
34
37
|
|
|
35
|
-
files = Dir.glob(File.join(dir,
|
|
38
|
+
files = Dir.glob(File.join(dir, '*'), File::FNM_CASEFOLD)
|
|
36
39
|
.select { |f| SUPPORTED_EXTENSIONS.include?(File.extname(f).downcase) && File.file?(f) }
|
|
37
40
|
|
|
38
41
|
total_imported += process_files(files, source)
|
|
@@ -50,15 +53,15 @@ module GitFit
|
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
def source_label
|
|
53
|
-
|
|
56
|
+
'LocalFile'
|
|
54
57
|
end
|
|
55
58
|
|
|
56
59
|
def raw_dir_for(source)
|
|
57
|
-
File.join(
|
|
60
|
+
File.join('data', 'raw', source)
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
def std_dir_for(source)
|
|
61
|
-
File.join(
|
|
64
|
+
File.join('data', 'std', source)
|
|
62
65
|
end
|
|
63
66
|
|
|
64
67
|
private
|
|
@@ -107,7 +110,7 @@ module GitFit
|
|
|
107
110
|
activity[:source] = source
|
|
108
111
|
activity[:run_id] = imported.zero? ? platform_id : "#{platform_id}_#{imported}"
|
|
109
112
|
|
|
110
|
-
if source ==
|
|
113
|
+
if source == 'gpx' && @sport_override
|
|
111
114
|
activity[:sport_category] = SportMapper.canonicalize(@sport_override)
|
|
112
115
|
end
|
|
113
116
|
|
|
@@ -141,18 +144,18 @@ module GitFit
|
|
|
141
144
|
end
|
|
142
145
|
|
|
143
146
|
{ activity_count: imported }
|
|
144
|
-
rescue => e
|
|
147
|
+
rescue StandardError => e
|
|
145
148
|
$stderr.puts "LocalFile: error importing #{file_path}: #{e.message}"
|
|
146
149
|
nil
|
|
147
150
|
end
|
|
148
151
|
|
|
149
152
|
def parse_file(ext, content, file_path)
|
|
150
153
|
case ext
|
|
151
|
-
when
|
|
154
|
+
when '.gpx'
|
|
152
155
|
GitFit::Parser::GPX.new.call(content)
|
|
153
|
-
when
|
|
156
|
+
when '.tcx'
|
|
154
157
|
GitFit::Parser::TCX.new.call(content)
|
|
155
|
-
when
|
|
158
|
+
when '.fit'
|
|
156
159
|
GitFit::Parser::FIT.new.call(file_path)
|
|
157
160
|
else
|
|
158
161
|
[]
|
|
@@ -161,11 +164,11 @@ module GitFit
|
|
|
161
164
|
|
|
162
165
|
def extract_l2_points(ext, content, file_path)
|
|
163
166
|
case ext
|
|
164
|
-
when
|
|
167
|
+
when '.gpx'
|
|
165
168
|
extract_gpx_points(content)
|
|
166
|
-
when
|
|
169
|
+
when '.tcx'
|
|
167
170
|
extract_tcx_points(content)
|
|
168
|
-
when
|
|
171
|
+
when '.fit'
|
|
169
172
|
extract_fit_points(file_path)
|
|
170
173
|
else
|
|
171
174
|
nil
|
|
@@ -175,36 +178,36 @@ module GitFit
|
|
|
175
178
|
def extract_gpx_points(xml)
|
|
176
179
|
doc = Nokogiri::XML(xml)
|
|
177
180
|
doc.remove_namespaces!
|
|
178
|
-
doc.xpath(
|
|
179
|
-
lat = pt[
|
|
180
|
-
lon = pt[
|
|
181
|
+
doc.xpath('//trkpt').filter_map do |pt|
|
|
182
|
+
lat = pt['lat']&.to_f
|
|
183
|
+
lon = pt['lon']&.to_f
|
|
181
184
|
next unless lat && lon
|
|
182
|
-
ele = pt.at_xpath(
|
|
183
|
-
time_str = pt.at_xpath(
|
|
185
|
+
ele = pt.at_xpath('ele')&.text&.to_f
|
|
186
|
+
time_str = pt.at_xpath('time')&.text
|
|
184
187
|
time = parse_timestamp(time_str)
|
|
185
|
-
hr = pt.at_xpath(
|
|
188
|
+
hr = pt.at_xpath('extensions//hr')&.text&.to_f
|
|
186
189
|
[lat, lon, ele, time, hr]
|
|
187
190
|
end
|
|
188
|
-
rescue => e
|
|
191
|
+
rescue StandardError => e
|
|
189
192
|
$stderr.puts "LocalFile: GPX parse error: #{e.message}"
|
|
190
193
|
nil
|
|
191
194
|
end
|
|
192
195
|
|
|
193
196
|
def extract_tcx_points(xml)
|
|
194
197
|
doc = Nokogiri::XML(xml)
|
|
195
|
-
doc.xpath(
|
|
196
|
-
pos = tp.at_xpath(
|
|
198
|
+
doc.xpath('//tcx:Trackpoint', NS_TCX).filter_map do |tp|
|
|
199
|
+
pos = tp.at_xpath('tcx:Position', NS_TCX)
|
|
197
200
|
next unless pos
|
|
198
|
-
lat = pos.at_xpath(
|
|
199
|
-
lon = pos.at_xpath(
|
|
201
|
+
lat = pos.at_xpath('tcx:LatitudeDegrees', NS_TCX)&.text&.to_f
|
|
202
|
+
lon = pos.at_xpath('tcx:LongitudeDegrees', NS_TCX)&.text&.to_f
|
|
200
203
|
next unless lat && lon
|
|
201
|
-
ele = tp.at_xpath(
|
|
202
|
-
time_str = tp.at_xpath(
|
|
204
|
+
ele = tp.at_xpath('tcx:AltitudeMeters', NS_TCX)&.text&.to_f
|
|
205
|
+
time_str = tp.at_xpath('tcx:Time', NS_TCX)&.text
|
|
203
206
|
time = parse_timestamp(time_str)
|
|
204
|
-
hr = tp.at_xpath(
|
|
207
|
+
hr = tp.at_xpath('tcx:HeartRateBpm/tcx:Value', NS_TCX)&.text&.to_f
|
|
205
208
|
[lat, lon, ele, time, hr]
|
|
206
209
|
end
|
|
207
|
-
rescue => e
|
|
210
|
+
rescue StandardError => e
|
|
208
211
|
$stderr.puts "LocalFile: TCX parse error: #{e.message}"
|
|
209
212
|
nil
|
|
210
213
|
end
|
|
@@ -212,14 +215,14 @@ module GitFit
|
|
|
212
215
|
def extract_fit_points(file_path)
|
|
213
216
|
records = GitFit::FIT::Decoder.decode(file_path)
|
|
214
217
|
records.filter_map do |r|
|
|
215
|
-
lat = r[
|
|
216
|
-
lon = r[
|
|
218
|
+
lat = r['positionLat']
|
|
219
|
+
lon = r['positionLong']
|
|
217
220
|
next unless lat && lon
|
|
218
|
-
ts = r[
|
|
221
|
+
ts = r['timestamp']
|
|
219
222
|
time = ts.is_a?(Numeric) ? (Time.utc(1989, 12, 31) + ts) : (Time.parse(ts.to_s) rescue nil)
|
|
220
|
-
[lat, lon, r[
|
|
223
|
+
[lat, lon, r['altitude'], time, r['heartRate']]
|
|
221
224
|
end
|
|
222
|
-
rescue => e
|
|
225
|
+
rescue StandardError => e
|
|
223
226
|
$stderr.puts "LocalFile: FIT decode error: #{e.message}"
|
|
224
227
|
nil
|
|
225
228
|
end
|
data/lib/git_fit/import.rb
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
require 'digest'
|
|
3
5
|
|
|
4
6
|
module GitFit
|
|
5
7
|
module Install
|
|
6
8
|
module Actions
|
|
7
|
-
TEMPLATE_DIR = File.expand_path(
|
|
9
|
+
TEMPLATE_DIR = File.expand_path('actions', __dir__)
|
|
8
10
|
VERSION_STAMP = "# Generated by git-fit v#{GitFit::VERSION} — do not edit manually\n"
|
|
9
11
|
|
|
10
12
|
def self.render(name, db_path:)
|
|
@@ -20,11 +22,11 @@ module GitFit
|
|
|
20
22
|
|
|
21
23
|
def self.install_family(family, check: false, dry_run: false)
|
|
22
24
|
dir = File.join(TEMPLATE_DIR, family)
|
|
23
|
-
db_path = ENV[
|
|
25
|
+
db_path = ENV['GIT_FIT_DATABASE_PATH'] || 'db/fit.sqlite3'
|
|
24
26
|
results = []
|
|
25
27
|
|
|
26
|
-
Dir.glob(
|
|
27
|
-
action_name = tpl.delete_suffix(
|
|
28
|
+
Dir.glob('*.yml.erb', base: dir).sort.map do |tpl|
|
|
29
|
+
action_name = tpl.delete_suffix('.yml.erb')
|
|
28
30
|
content = render("#{family}/#{action_name}", db_path: db_path)
|
|
29
31
|
stamped = "#{VERSION_STAMP}\n#{content}"
|
|
30
32
|
out_path = ".github/actions/#{family}/#{action_name}/action.yml"
|
|
@@ -61,9 +63,9 @@ module GitFit
|
|
|
61
63
|
end
|
|
62
64
|
|
|
63
65
|
def self.diff(a, b)
|
|
64
|
-
require
|
|
65
|
-
ta = Tempfile.new(
|
|
66
|
-
tb = Tempfile.new(
|
|
66
|
+
require 'tempfile'
|
|
67
|
+
ta = Tempfile.new('a')
|
|
68
|
+
tb = Tempfile.new('b')
|
|
67
69
|
ta.write(a)
|
|
68
70
|
tb.write(b)
|
|
69
71
|
ta.close
|
|
@@ -76,14 +78,14 @@ module GitFit
|
|
|
76
78
|
families.flat_map { |f| install_family(f, check: check, dry_run: dry_run) }
|
|
77
79
|
end
|
|
78
80
|
|
|
79
|
-
def self.restore_action(db_path =
|
|
80
|
-
render(
|
|
81
|
+
def self.restore_action(db_path = 'db/fit.sqlite3')
|
|
82
|
+
render('db-store/restore', db_path: db_path)
|
|
81
83
|
end
|
|
82
84
|
|
|
83
85
|
RESTORE_ACTION = restore_action
|
|
84
86
|
|
|
85
|
-
def self.save_action(db_path =
|
|
86
|
-
render(
|
|
87
|
+
def self.save_action(db_path = 'db/fit.sqlite3')
|
|
88
|
+
render('db-store/save', db_path: db_path)
|
|
87
89
|
end
|
|
88
90
|
|
|
89
91
|
SAVE_ACTION = save_action
|