git-fit 0.17.3 → 0.17.5

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: 8e88e21cd60e366ac83ce9f0dac135a5532d6c8953dc9557873a39b55de07174
4
- data.tar.gz: 38d23b0f68e6e15ecbe9413d3ac0992fdab84faaa7b2977c6b4284a1f1a223ca
3
+ metadata.gz: 7e56c52b84377fa13e79c341ee252e84ce3664a448de70ceb690609c55f6d659
4
+ data.tar.gz: 5eee032b2293d18b5775a1250b15c723f794c02aedf27b0198d975c5fd1f27d5
5
5
  SHA512:
6
- metadata.gz: 77e28b8840ad990c41c82585de950af40761f52cfddba38f9b7d6d64386fdc5a9e87191e33d4be9d5a945da950a13e0e3f5182a92a1eaeb0f38ecc481dcb6917
7
- data.tar.gz: f6fcee3ebc58afdcbaeb849cb2eca576de1039ed13783424abda28da6f6fccc80cc9fd5690efdf492e533039b22adcfb16b8a98b28cbc5e5494165dd05b88328
6
+ metadata.gz: ba9969a0f3a82a4b255e41e41739cafd75554a1a36e499322610d50cd0fba75ebec6e46a8936418ca58dcd4763e167f72467036b4ff778d6f92a142d53ba6e7b
7
+ data.tar.gz: 470017ec4828dcae793778c26732234b9c27b6aeac561d774a152734adc2a2ab27cc362feb586b40862cc7ddb45c16924026ae3ac99c181ed20d7b3b5b3f2fa3
@@ -3,7 +3,7 @@
3
3
  module GitFit
4
4
  module Elevation
5
5
  class Backfill
6
- SOURCES = %w[igpsport xoss strava garmin garmin_cn].freeze
6
+ SOURCES = %w[igpsport xoss strava garmin garmin_cn xingzhe].freeze
7
7
  BATCH_SIZE = 100
8
8
 
9
9
  def initialize(db, force: false)
@@ -61,6 +61,7 @@ module GitFit
61
61
  when 'garmin' then compute_from_fit(source, natural_id)
62
62
  when 'garmin_cn' then compute_from_fit(source, natural_id)
63
63
  when 'strava' then compute_from_l2(source, natural_id)
64
+ when 'xingzhe' then compute_from_xingzhe(natural_id)
64
65
  else return
65
66
  end
66
67
 
@@ -118,6 +119,25 @@ module GitFit
118
119
  nil
119
120
  end
120
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
+
121
141
  def fit_path_for(source, natural_id)
122
142
  case source
123
143
  when 'garmin', 'garmin_cn'
@@ -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,49 @@ 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
+ dir = File.join(raw_dir, platform_id)
110
+ %w[stream.json detail.json].all? { |f| File.exist?(File.join(dir, f)) }
111
+ end
112
+
113
+ def write_source_archive(data, platform_id, _ext = 'json')
114
+ # Back-compat entry point — now writes stream.json into per-id dir
115
+ write_raw_file(data, 'stream.json', platform_id)
116
+ end
117
+
118
+ def write_raw_file(data, filename, platform_id)
119
+ dir = File.join(raw_dir, platform_id)
120
+ FileUtils.mkdir_p(dir)
121
+ tmp = File.join(dir, ".#{filename}.tmp")
122
+ dest = File.join(dir, filename)
123
+ case data
124
+ when String then File.write(tmp, data)
125
+ when Hash, Array then File.write(tmp, JSON.generate(data))
126
+ end
127
+ File.rename(tmp, dest)
128
+ end
129
+
88
130
  private
89
131
 
90
132
  def http_request(method, path, body: nil, params: nil, headers: {})
@@ -252,9 +294,28 @@ module GitFit
252
294
  average_speed: (act['avg_speed'].to_f / 3.6).round(2).nonzero?,
253
295
  elevation_gain: act['elevation_gain'].to_f.nonzero?,
254
296
  elevation_loss: act['elevation_loss'].to_f.nonzero?,
297
+ elevation_max: act['max_altitude']&.to_f&.nonzero?,
255
298
  source: 'xingzhe',
256
299
  }
257
300
 
301
+ # Phase 1: fetch and persist detail + stream; Phase 2: exports
302
+ detail_workout = nil
303
+ full_detail = nil
304
+
305
+ # Fetch full detail (public, with segments/slopes/pois/laps) for raw archive
306
+ fetched_full, fetched_workout = fetch_full_detail(aid)
307
+ if fetched_full
308
+ full_detail = fetched_full
309
+ detail_workout = fetched_workout
310
+ # Persist full detail response (code/data/msg) as detail.json
311
+ write_raw_file(full_detail, 'detail.json', aid)
312
+ # Prefer workout from full_detail for attrs that benefit from richer data
313
+ if detail_workout
314
+ attrs[:elevation_max] ||= detail_workout['max_altitude']&.to_f&.nonzero?
315
+ # equipment_info / power / grade etc. are archived in detail.json for downstream use
316
+ end
317
+ end
318
+
258
319
  pts = nil
259
320
  stream = fetch_stream(aid)
260
321
  if stream
@@ -270,19 +331,36 @@ module GitFit
270
331
  end
271
332
  end
272
333
 
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
334
+ if attrs[:summary_polyline].nil?
335
+ fallback_workout = detail_workout || fetch_activity_detail(aid)
336
+ if fallback_workout && (segments = fallback_workout['segments_km'])
337
+ pts = segments.map { |s| [s['latitude'], s['longitude']] }
338
+ attrs[:summary_polyline] = Geo::Polyline.encode(pts) if pts.size >= 2
339
+ end
276
340
  end
277
341
 
342
+ # Elevation min: prefer stream altitude min, fallback to segments_km altitude min (see wiki)
343
+ attrs[:elevation_min] = derive_elevation_min(stream, detail_workout || act)
344
+
345
+ # Exports (best-effort, per-record format probing — see issue #86)
346
+ try_download_exports(aid, detail_workout || act) if stream || full_detail
347
+
278
348
  attrs[:start_date_local] = resolve_local_time(start_t, pts)
279
349
 
280
350
  upsert_activity(attrs)
281
351
  attrs
282
352
  end
283
353
 
284
- def raw_path(platform_id)
285
- File.join(raw_dir, "#{platform_id}.#{RAW_EXT}")
354
+ def derive_elevation_min(stream, detail_workout)
355
+ if stream && (alt = stream['altitude']) && alt.is_a?(Array) && alt.any?
356
+ v = alt.compact.min
357
+ return v.to_f if v
358
+ end
359
+ if detail_workout && (segs = detail_workout['segments_km']) && segs.is_a?(Array) && segs.any?
360
+ v = segs.filter_map { |s| s['altitude'] }.min
361
+ return v.to_f if v
362
+ end
363
+ nil
286
364
  end
287
365
 
288
366
  def fetch_stream(id)
@@ -338,6 +416,114 @@ module GitFit
338
416
  data.dig('data', 'workout')
339
417
  end
340
418
 
419
+ # Full detail with segments/slopes/pois/laps — returns [full_json, workout_hash]
420
+ def fetch_full_detail(id)
421
+ resp = http_request(:get, "#{DETAIL_PATH}#{id}/",
422
+ params: { segments: 'true', slopes: 'true', pois: 'true', laps: 'true' })
423
+ return [nil, nil] unless resp.code.to_i == 200
424
+
425
+ data = JSON.parse(resp.body)
426
+ return [nil, nil] unless data['code'] == 200 || data.dig('data', 'workout')
427
+
428
+ workout = data.dig('data', 'workout')
429
+ [data, workout]
430
+ rescue JSON::ParserError
431
+ [nil, nil]
432
+ end
433
+
434
+ def try_download_exports(activity_id, detail_workout)
435
+ # GPX: generally available — probe via authed GET
436
+ download_and_save_gpx(activity_id)
437
+ # FIT: only when is_fit truthy (per detail), else probe still but expect 404/302 empty
438
+ is_fit = detail_workout && detail_workout['is_fit']
439
+ # Always attempt FIT when summary suggests ride with device; but gate on is_fit to avoid noise
440
+ download_and_save_fit(activity_id) if is_fit
441
+ rescue StandardError => e
442
+ warn "XingZhe: export download failed for #{activity_id}: #{e.message}"
443
+ end
444
+
445
+ def download_and_save_gpx(activity_id)
446
+ resp = authed_get("#{EXPORT_BASE}#{activity_id}/gpx/")
447
+ return false unless resp && resp.code.to_i == 200
448
+ body = resp.body
449
+ return false if body.nil? || body.empty?
450
+ # GPX endpoint returns XML but Content-Type may be application/json — validate by prefix
451
+ stripped = body.lstrip
452
+ unless stripped.start_with?('<?xml', '<gpx')
453
+ # Some error responses are JSON with code/msg
454
+ begin
455
+ j = JSON.parse(body)
456
+ return false if j['code'] && j['code'] != 200
457
+ rescue JSON::ParserError
458
+ return false
459
+ end
460
+ return false unless stripped.include?('<gpx') || stripped.include?('<trk')
461
+ end
462
+ write_raw_file(body, "#{activity_id}.gpx", activity_id)
463
+ true
464
+ rescue StandardError => e
465
+ warn "XingZhe: failed to download gpx for #{activity_id}: #{e.message}"
466
+ false
467
+ end
468
+
469
+ def download_and_save_fit(activity_id)
470
+ # FIT export: 302 → presigned OSS URL (see issue #86 verification)
471
+ resp = http_request(:get, "#{EXPORT_BASE}#{activity_id}/fit/", headers: @headers || {})
472
+ code = resp.code.to_i
473
+ # 302/301 redirect to OSS is the expected success path — handle before auth check
474
+ if [301, 302, 303, 307, 308].include?(code)
475
+ location = resp['location'] || resp['Location']
476
+ if location && !location.empty?
477
+ uri = URI(location)
478
+ http = Net::HTTP.new(uri.host, uri.port)
479
+ http.use_ssl = uri.scheme == 'https'
480
+ http.open_timeout = 30
481
+ http.read_timeout = 60
482
+ req = Net::HTTP::Get.new(uri)
483
+ oss_resp = http.start { |h| h.request(req) }
484
+ return false unless oss_resp.code.to_i == 200
485
+
486
+ # Binary FIT — write as String (Ruby handles binary)
487
+ write_raw_file(oss_resp.body, "#{activity_id}.fit", activity_id)
488
+ return true
489
+ end
490
+ end
491
+ if auth_failure?(resp)
492
+ remove_stale_session
493
+ return false unless login
494
+
495
+ resp = http_request(:get, "#{EXPORT_BASE}#{activity_id}/fit/", headers: @headers)
496
+ code = resp.code.to_i
497
+ if [301, 302, 303, 307, 308].include?(code)
498
+ location = resp['location'] || resp['Location']
499
+ return false unless location && !location.empty?
500
+
501
+ uri = URI(location)
502
+ http = Net::HTTP.new(uri.host, uri.port)
503
+ http.use_ssl = uri.scheme == 'https'
504
+ http.open_timeout = 30
505
+ http.read_timeout = 60
506
+ req = Net::HTTP::Get.new(uri)
507
+ oss_resp = http.start { |h| h.request(req) }
508
+ return false unless oss_resp.code.to_i == 200
509
+
510
+ write_raw_file(oss_resp.body, "#{activity_id}.fit", activity_id)
511
+ return true
512
+ end
513
+ end
514
+ if code == 200
515
+ # Direct FIT (rare)
516
+ return false if resp.body.nil? || resp.body.empty?
517
+ write_raw_file(resp.body, "#{activity_id}.fit", activity_id)
518
+ true
519
+ else
520
+ false
521
+ end
522
+ rescue StandardError => e
523
+ warn "XingZhe: failed to download fit for #{activity_id}: #{e.message}"
524
+ false
525
+ end
526
+
341
527
  def map_sport(sport)
342
528
  SPORT_MAP[sport] || 'other'
343
529
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GitFit
4
- VERSION = '0.17.3'
4
+ VERSION = '0.17.5'
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.3
4
+ version: 0.17.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lax