git-fit 0.17.2 → 0.17.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 472d41138e7c8607cbc63dbff14db13079b6b824c342ec03661696c77f08f8f9
4
- data.tar.gz: 291394e38307334eaedd49106b5c6c057331d972ccf4fa2619ce68904ca89a86
3
+ metadata.gz: 4c9c026f3d44b75a14eae1f36cc7dc6a56434cd0039996bfb38163fc3bb617a5
4
+ data.tar.gz: b11245bfa2b0fef405e5f0f369fd02582d1a2ba509b7fb91972f38249ae8ec01
5
5
  SHA512:
6
- metadata.gz: 2f7e4971cc16db4f139ae122a65fe19842bfd1e999362bfc57294e4fa2b41a3adbd228824e9126b13a13fc2499643dec07ad195e1d6050618137a54f12b34a48
7
- data.tar.gz: 713ff0444661494cb22469f5f3535de7b20f89229ec82b5db07398654b6cd2622267db18a0d1841517f796a351bbc1d29800d79b53469009454532dba855b61e
6
+ metadata.gz: 1baed90c0c86bbb46edefd951646ba0f2fffd8cd08b07eb900479a74f372de43d50c7a0f39c56f728f8fb476cfe264075eaafcc3721b2bcd5dddc692b9469592
7
+ data.tar.gz: e98542328ef0210b5934a1c85dfb388b35563df9a4998bb52de7dd6847c05f7ada8a57402d9c359ed08bb6f9420d3a5205d91454c61b40801faa6bfd6fad769f
data/lib/git-fit.rb CHANGED
@@ -93,6 +93,8 @@ require_relative 'git_fit/cli/export'
93
93
  require_relative 'git_fit/cli/import_cli'
94
94
  require_relative 'git_fit/cli/geo_cli'
95
95
  require_relative 'git_fit/strava_web/file_check'
96
+ require_relative 'git_fit/cli/elevation'
97
+ require_relative 'git_fit/elevation/backfill'
96
98
  require_relative 'git_fit/cli/strava'
97
99
  require_relative 'git_fit/auth/garmin_token'
98
100
  require_relative 'git_fit/auth/strava'
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+
5
+ module GitFit
6
+ class ElevationCLI < Thor
7
+ no_commands do
8
+ def git_fit_config
9
+ @git_fit_config ||= GitFit::Config.new(options[:config])
10
+ end
11
+ end
12
+
13
+ desc 'backfill [SOURCE]', 'Backfill elevation data (gain/loss/min/max) from raw FIT files or L2 data'
14
+ option :force, type: :boolean, default: false, desc: 'Overwrite existing values (default: only fill NULLs)'
15
+ def backfill(source = nil)
16
+ config = git_fit_config
17
+ conn = GitFit::DB::Connection.new(config.db_path)
18
+ conn.migrate!
19
+ db = conn.db
20
+
21
+ result = GitFit::Elevation::Backfill.new(db, force: options[:force]).call(source)
22
+
23
+ msg = "Elevation backfill: #{result[:updated]} updated, " \
24
+ "#{result[:skipped]} skipped, #{result[:failed]} failed"
25
+ say_status :done, msg, :green
26
+ rescue StandardError => e
27
+ say_status :error, "Elevation backfill failed: #{e.message}", :red
28
+ end
29
+ end
30
+ end
data/lib/git_fit/cli.rb CHANGED
@@ -117,6 +117,9 @@ module GitFit
117
117
  say_status :error, "Dedup failed: #{e.message}", :red
118
118
  end
119
119
 
120
+ desc 'elevation SUBCOMMAND', 'Elevation data operations'
121
+ subcommand 'elevation', ElevationCLI
122
+
120
123
  desc 'strava SUBCOMMAND', 'Strava web operations'
121
124
  subcommand 'strava', StravaCLI
122
125
 
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitFit
4
+ module Elevation
5
+ class Backfill
6
+ SOURCES = %w[igpsport xoss strava garmin garmin_cn xingzhe].freeze
7
+ BATCH_SIZE = 100
8
+
9
+ def initialize(db, force: false)
10
+ @db = db
11
+ @force = force
12
+ @updated = 0
13
+ @skipped = 0
14
+ @failed = 0
15
+ end
16
+
17
+ def call(source = nil)
18
+ sources = source ? [source] : SOURCES
19
+ sources.each do |src|
20
+ backfill_source(src)
21
+ end
22
+ { updated: @updated, skipped: @skipped, failed: @failed }
23
+ end
24
+
25
+ private
26
+
27
+ def backfill_source(source)
28
+ offset = 0
29
+ loop do
30
+ rows = fetch_rows(source, offset)
31
+ break if rows.empty?
32
+
33
+ @db.transaction do
34
+ rows.each { |row| process_row(row, source) }
35
+ end
36
+
37
+ offset += BATCH_SIZE
38
+ end
39
+ end
40
+
41
+ def fetch_rows(source, offset)
42
+ scope = @db[:activities].where(source: source)
43
+ unless @force
44
+ scope = scope.where(Sequel.|(
45
+ elevation_gain: nil,
46
+ elevation_loss: nil,
47
+ elevation_min: nil,
48
+ elevation_max: nil,
49
+ ))
50
+ end
51
+ scope.order(:id).limit(BATCH_SIZE, offset).all
52
+ end
53
+
54
+ def process_row(row, source)
55
+ run_id = row[:run_id]
56
+ natural_id = run_id.sub("#{source}_", '')
57
+
58
+ elev = case source
59
+ when 'igpsport' then compute_from_fit(source, natural_id)
60
+ when 'xoss' then compute_from_fit(source, natural_id)
61
+ when 'garmin' then compute_from_fit(source, natural_id)
62
+ when 'garmin_cn' then compute_from_fit(source, natural_id)
63
+ when 'strava' then compute_from_l2(source, natural_id)
64
+ when 'xingzhe' then compute_from_xingzhe(natural_id)
65
+ else return
66
+ end
67
+
68
+ return skip_row(run_id) unless elev
69
+
70
+ update_row(row, elev)
71
+ end
72
+
73
+ def compute_from_fit(source, natural_id)
74
+ fit_path = fit_path_for(source, natural_id)
75
+ return nil unless fit_path && File.exist?(fit_path)
76
+
77
+ records = GitFit::FIT::Decoder.decode(fit_path)
78
+ return nil if records.empty?
79
+
80
+ alts = records.filter_map { |r| r['enhancedAltitude'] || r['altitude'] }
81
+ return nil if alts.size < 2
82
+
83
+ session = begin
84
+ GitFit::FIT::Decoder.session(fit_path)
85
+ rescue StandardError
86
+ nil
87
+ end
88
+
89
+ gain = session&.dig('totalAscent').is_a?(Numeric) ? session['totalAscent'] : elevation_gain_from_alts(alts)
90
+ loss = session&.dig('totalDescent').is_a?(Numeric) ? session['totalDescent'] : elevation_loss_from_alts(alts)
91
+
92
+ {
93
+ elevation_gain: gain&.round(1),
94
+ elevation_loss: loss&.round(1),
95
+ elevation_min: alts.min&.round(1),
96
+ elevation_max: alts.max&.round(1),
97
+ }
98
+ rescue StandardError => e
99
+ warn "Elevation backfill [#{source}][#{natural_id}]: #{e.message}"
100
+ nil
101
+ end
102
+
103
+ def compute_from_l2(source, natural_id)
104
+ l2_path = File.join('data', 'std', source, "#{natural_id}.json")
105
+ return nil unless File.exist?(l2_path)
106
+
107
+ l2 = JSON.parse(File.read(l2_path))
108
+ alts = l2.filter_map { |pt| pt['altitude'] || pt[:altitude] }
109
+ return nil if alts.size < 2
110
+
111
+ {
112
+ elevation_gain: elevation_gain_from_alts(alts)&.round(1),
113
+ elevation_loss: elevation_loss_from_alts(alts)&.round(1),
114
+ elevation_min: alts.min&.round(1),
115
+ elevation_max: alts.max&.round(1),
116
+ }
117
+ rescue StandardError => e
118
+ warn "Elevation backfill [#{source}][#{natural_id}]: #{e.message}"
119
+ nil
120
+ end
121
+
122
+ def compute_from_xingzhe(natural_id)
123
+ raw_path = File.join('data', 'raw', 'xingzhe', "#{natural_id}.json")
124
+ return nil unless File.exist?(raw_path)
125
+
126
+ raw = JSON.parse(File.read(raw_path))
127
+ alts = raw.dig('stream', 'altitude') || []
128
+ return nil if alts.size < 2
129
+
130
+ {
131
+ elevation_gain: elevation_gain_from_alts(alts)&.round(1),
132
+ elevation_loss: elevation_loss_from_alts(alts)&.round(1),
133
+ elevation_min: alts.min&.round(1),
134
+ elevation_max: alts.max&.round(1),
135
+ }
136
+ rescue StandardError => e
137
+ warn "Elevation backfill [xingzhe][#{natural_id}]: #{e.message}"
138
+ nil
139
+ end
140
+
141
+ def fit_path_for(source, natural_id)
142
+ case source
143
+ when 'garmin', 'garmin_cn'
144
+ File.join('data', 'raw', source, natural_id, 'activity.fit')
145
+ else
146
+ File.join('data', 'raw', source, "#{natural_id}.fit")
147
+ end
148
+ end
149
+
150
+ def elevation_gain_from_alts(alts)
151
+ return 0.0 if alts.size < 2
152
+ (1...alts.size).sum do |i|
153
+ d = alts[i] - alts[i - 1]
154
+ d > 0 ? d : 0.0
155
+ end
156
+ end
157
+
158
+ def elevation_loss_from_alts(alts)
159
+ return 0.0 if alts.size < 2
160
+ (1...alts.size).sum do |i|
161
+ d = alts[i - 1] - alts[i]
162
+ d > 0 ? d : 0.0
163
+ end
164
+ end
165
+
166
+ def update_row(row, elev)
167
+ @db[:activities].where(id: row[:id]).update(
168
+ elevation_gain: elev[:elevation_gain],
169
+ elevation_loss: elev[:elevation_loss],
170
+ elevation_min: elev[:elevation_min],
171
+ elevation_max: elev[:elevation_max],
172
+ updated_at: Time.now.utc,
173
+ )
174
+ @updated += 1
175
+ end
176
+
177
+ def skip_row(_run_id)
178
+ @skipped += 1
179
+ end
180
+ end
181
+ end
182
+ end
@@ -14,6 +14,7 @@ module GitFit
14
14
  WORKOUTS_PATH = '/api/v1/pgworkout/'
15
15
  STREAM_PATH = '/api/v1/pgworkout/'
16
16
  DETAIL_PATH = '/api/v1/pgworkout/'
17
+ EXPORT_BASE = '/api/v1/workout/'
17
18
  RSA_PUBKEY = 'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDmuQkBbijudDAJgfffDeeIButqWHZvUwcRuvWdg89393FSdz3IJUHc0rgI/S3WuU8N0VePJLmVAZtCOK4qe4FY/eKmWpJmn7JfXB4HTMWjPVoyRZmSYjW4L8GrWmh51Qj7DwpTADadF3aq04o+s1b8LXJa8r6+TIqqL5WUHtRqmQIDAQAB'
18
19
 
19
20
  SPORT_MAP = {
@@ -21,8 +22,6 @@ module GitFit
21
22
  5 => 'swim', 6 => 'walk', 11 => 'ski', 13 => 'workout'
22
23
  }.freeze
23
24
 
24
- RAW_EXT = 'json'
25
-
26
25
  SESSION_TTL = 30 * 24 * 60 * 60
27
26
 
28
27
  register_adapter
@@ -85,6 +84,48 @@ module GitFit
85
84
  @last_http_code
86
85
  end
87
86
 
87
+ # ── Raw directory layout (Phase 1, mirrors garmin Phase 1) ──────────
88
+ # data/raw/xingzhe/{id}/stream.json — stream envelope
89
+ # data/raw/xingzhe/{id}/detail.json — full detail response (workout+user+...)
90
+ # data/raw/xingzhe/{id}/{id}.gpx — GPX export (when available)
91
+ # data/raw/xingzhe/{id}/{id}.fit — FIT export (when is_fit)
92
+ def raw_path(platform_id)
93
+ File.join(raw_dir, platform_id, 'stream.json')
94
+ end
95
+
96
+ def raw_detail_path(platform_id)
97
+ File.join(raw_dir, platform_id, 'detail.json')
98
+ end
99
+
100
+ def raw_gpx_path(platform_id)
101
+ File.join(raw_dir, platform_id, "#{platform_id}.gpx")
102
+ end
103
+
104
+ def raw_fit_path(platform_id)
105
+ File.join(raw_dir, platform_id, "#{platform_id}.fit")
106
+ end
107
+
108
+ def raw_file_exists?(platform_id)
109
+ File.exist?(raw_path(platform_id))
110
+ end
111
+
112
+ def write_source_archive(data, platform_id, _ext = 'json')
113
+ # Back-compat entry point — now writes stream.json into per-id dir
114
+ write_raw_file(data, 'stream.json', platform_id)
115
+ end
116
+
117
+ def write_raw_file(data, filename, platform_id)
118
+ dir = File.join(raw_dir, platform_id)
119
+ FileUtils.mkdir_p(dir)
120
+ tmp = File.join(dir, ".#{filename}.tmp")
121
+ dest = File.join(dir, filename)
122
+ case data
123
+ when String then File.write(tmp, data)
124
+ when Hash, Array then File.write(tmp, JSON.generate(data))
125
+ end
126
+ File.rename(tmp, dest)
127
+ end
128
+
88
129
  private
89
130
 
90
131
  def http_request(method, path, body: nil, params: nil, headers: {})
@@ -252,9 +293,28 @@ module GitFit
252
293
  average_speed: (act['avg_speed'].to_f / 3.6).round(2).nonzero?,
253
294
  elevation_gain: act['elevation_gain'].to_f.nonzero?,
254
295
  elevation_loss: act['elevation_loss'].to_f.nonzero?,
296
+ elevation_max: act['max_altitude']&.to_f&.nonzero?,
255
297
  source: 'xingzhe',
256
298
  }
257
299
 
300
+ # Phase 1: fetch and persist detail + stream; Phase 2: exports
301
+ detail_workout = nil
302
+ full_detail = nil
303
+
304
+ # Fetch full detail (public, with segments/slopes/pois/laps) for raw archive
305
+ fetched_full, fetched_workout = fetch_full_detail(aid)
306
+ if fetched_full
307
+ full_detail = fetched_full
308
+ detail_workout = fetched_workout
309
+ # Persist full detail response (code/data/msg) as detail.json
310
+ write_raw_file(full_detail, 'detail.json', aid)
311
+ # Prefer workout from full_detail for attrs that benefit from richer data
312
+ if detail_workout
313
+ attrs[:elevation_max] ||= detail_workout['max_altitude']&.to_f&.nonzero?
314
+ # equipment_info / power / grade etc. are archived in detail.json for downstream use
315
+ end
316
+ end
317
+
258
318
  pts = nil
259
319
  stream = fetch_stream(aid)
260
320
  if stream
@@ -270,19 +330,36 @@ module GitFit
270
330
  end
271
331
  end
272
332
 
273
- if attrs[:summary_polyline].nil? && (detail = fetch_activity_detail(aid)) && (segments = detail['segments_km'])
274
- pts = segments.map { |s| [s['latitude'], s['longitude']] }
275
- attrs[:summary_polyline] = Geo::Polyline.encode(pts) if pts.size >= 2
333
+ if attrs[:summary_polyline].nil?
334
+ fallback_workout = detail_workout || fetch_activity_detail(aid)
335
+ if fallback_workout && (segments = fallback_workout['segments_km'])
336
+ pts = segments.map { |s| [s['latitude'], s['longitude']] }
337
+ attrs[:summary_polyline] = Geo::Polyline.encode(pts) if pts.size >= 2
338
+ end
276
339
  end
277
340
 
341
+ # Elevation min: prefer stream altitude min, fallback to segments_km altitude min (see wiki)
342
+ attrs[:elevation_min] = derive_elevation_min(stream, detail_workout || act)
343
+
344
+ # Exports (best-effort, per-record format probing — see issue #86)
345
+ try_download_exports(aid, detail_workout || act) if stream || full_detail
346
+
278
347
  attrs[:start_date_local] = resolve_local_time(start_t, pts)
279
348
 
280
349
  upsert_activity(attrs)
281
350
  attrs
282
351
  end
283
352
 
284
- def raw_path(platform_id)
285
- File.join(raw_dir, "#{platform_id}.#{RAW_EXT}")
353
+ def derive_elevation_min(stream, detail_workout)
354
+ if stream && (alt = stream['altitude']) && alt.is_a?(Array) && alt.any?
355
+ v = alt.compact.min
356
+ return v.to_f if v
357
+ end
358
+ if detail_workout && (segs = detail_workout['segments_km']) && segs.is_a?(Array) && segs.any?
359
+ v = segs.filter_map { |s| s['altitude'] }.min
360
+ return v.to_f if v
361
+ end
362
+ nil
286
363
  end
287
364
 
288
365
  def fetch_stream(id)
@@ -338,6 +415,114 @@ module GitFit
338
415
  data.dig('data', 'workout')
339
416
  end
340
417
 
418
+ # Full detail with segments/slopes/pois/laps — returns [full_json, workout_hash]
419
+ def fetch_full_detail(id)
420
+ resp = http_request(:get, "#{DETAIL_PATH}#{id}/",
421
+ params: { segments: 'true', slopes: 'true', pois: 'true', laps: 'true' })
422
+ return [nil, nil] unless resp.code.to_i == 200
423
+
424
+ data = JSON.parse(resp.body)
425
+ return [nil, nil] unless data['code'] == 200 || data.dig('data', 'workout')
426
+
427
+ workout = data.dig('data', 'workout')
428
+ [data, workout]
429
+ rescue JSON::ParserError
430
+ [nil, nil]
431
+ end
432
+
433
+ def try_download_exports(activity_id, detail_workout)
434
+ # GPX: generally available — probe via authed GET
435
+ download_and_save_gpx(activity_id)
436
+ # FIT: only when is_fit truthy (per detail), else probe still but expect 404/302 empty
437
+ is_fit = detail_workout && detail_workout['is_fit']
438
+ # Always attempt FIT when summary suggests ride with device; but gate on is_fit to avoid noise
439
+ download_and_save_fit(activity_id) if is_fit
440
+ rescue StandardError => e
441
+ warn "XingZhe: export download failed for #{activity_id}: #{e.message}"
442
+ end
443
+
444
+ def download_and_save_gpx(activity_id)
445
+ resp = authed_get("#{EXPORT_BASE}#{activity_id}/gpx/")
446
+ return false unless resp && resp.code.to_i == 200
447
+ body = resp.body
448
+ return false if body.nil? || body.empty?
449
+ # GPX endpoint returns XML but Content-Type may be application/json — validate by prefix
450
+ stripped = body.lstrip
451
+ unless stripped.start_with?('<?xml', '<gpx')
452
+ # Some error responses are JSON with code/msg
453
+ begin
454
+ j = JSON.parse(body)
455
+ return false if j['code'] && j['code'] != 200
456
+ rescue JSON::ParserError
457
+ return false
458
+ end
459
+ return false unless stripped.include?('<gpx') || stripped.include?('<trk')
460
+ end
461
+ write_raw_file(body, "#{activity_id}.gpx", activity_id)
462
+ true
463
+ rescue StandardError => e
464
+ warn "XingZhe: failed to download gpx for #{activity_id}: #{e.message}"
465
+ false
466
+ end
467
+
468
+ def download_and_save_fit(activity_id)
469
+ # FIT export: 302 → presigned OSS URL (see issue #86 verification)
470
+ resp = http_request(:get, "#{EXPORT_BASE}#{activity_id}/fit/", headers: @headers || {})
471
+ code = resp.code.to_i
472
+ # 302/301 redirect to OSS is the expected success path — handle before auth check
473
+ if [301, 302, 303, 307, 308].include?(code)
474
+ location = resp['location'] || resp['Location']
475
+ if location && !location.empty?
476
+ uri = URI(location)
477
+ http = Net::HTTP.new(uri.host, uri.port)
478
+ http.use_ssl = uri.scheme == 'https'
479
+ http.open_timeout = 30
480
+ http.read_timeout = 60
481
+ req = Net::HTTP::Get.new(uri)
482
+ oss_resp = http.start { |h| h.request(req) }
483
+ return false unless oss_resp.code.to_i == 200
484
+
485
+ # Binary FIT — write as String (Ruby handles binary)
486
+ write_raw_file(oss_resp.body, "#{activity_id}.fit", activity_id)
487
+ return true
488
+ end
489
+ end
490
+ if auth_failure?(resp)
491
+ remove_stale_session
492
+ return false unless login
493
+
494
+ resp = http_request(:get, "#{EXPORT_BASE}#{activity_id}/fit/", headers: @headers)
495
+ code = resp.code.to_i
496
+ if [301, 302, 303, 307, 308].include?(code)
497
+ location = resp['location'] || resp['Location']
498
+ return false unless location && !location.empty?
499
+
500
+ uri = URI(location)
501
+ http = Net::HTTP.new(uri.host, uri.port)
502
+ http.use_ssl = uri.scheme == 'https'
503
+ http.open_timeout = 30
504
+ http.read_timeout = 60
505
+ req = Net::HTTP::Get.new(uri)
506
+ oss_resp = http.start { |h| h.request(req) }
507
+ return false unless oss_resp.code.to_i == 200
508
+
509
+ write_raw_file(oss_resp.body, "#{activity_id}.fit", activity_id)
510
+ return true
511
+ end
512
+ end
513
+ if code == 200
514
+ # Direct FIT (rare)
515
+ return false if resp.body.nil? || resp.body.empty?
516
+ write_raw_file(resp.body, "#{activity_id}.fit", activity_id)
517
+ true
518
+ else
519
+ false
520
+ end
521
+ rescue StandardError => e
522
+ warn "XingZhe: failed to download fit for #{activity_id}: #{e.message}"
523
+ false
524
+ end
525
+
341
526
  def map_sport(sport)
342
527
  SPORT_MAP[sport] || 'other'
343
528
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GitFit
4
- VERSION = '0.17.2'
4
+ VERSION = '0.17.4'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: git-fit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.2
4
+ version: 0.17.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lax
@@ -257,6 +257,7 @@ files:
257
257
  - lib/git_fit/auth/strava.rb
258
258
  - lib/git_fit/cli.rb
259
259
  - lib/git_fit/cli/checkpointable.rb
260
+ - lib/git_fit/cli/elevation.rb
260
261
  - lib/git_fit/cli/export.rb
261
262
  - lib/git_fit/cli/geo_cli.rb
262
263
  - lib/git_fit/cli/gh_cli.rb
@@ -277,6 +278,7 @@ files:
277
278
  - lib/git_fit/dedup/matcher.rb
278
279
  - lib/git_fit/dedup/service.rb
279
280
  - lib/git_fit/dedup/trajectory.rb
281
+ - lib/git_fit/elevation/backfill.rb
280
282
  - lib/git_fit/export.rb
281
283
  - lib/git_fit/export/calculator.rb
282
284
  - lib/git_fit/export/csv.rb