git-fit 0.17.3 → 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: 8e88e21cd60e366ac83ce9f0dac135a5532d6c8953dc9557873a39b55de07174
4
- data.tar.gz: 38d23b0f68e6e15ecbe9413d3ac0992fdab84faaa7b2977c6b4284a1f1a223ca
3
+ metadata.gz: 4c9c026f3d44b75a14eae1f36cc7dc6a56434cd0039996bfb38163fc3bb617a5
4
+ data.tar.gz: b11245bfa2b0fef405e5f0f369fd02582d1a2ba509b7fb91972f38249ae8ec01
5
5
  SHA512:
6
- metadata.gz: 77e28b8840ad990c41c82585de950af40761f52cfddba38f9b7d6d64386fdc5a9e87191e33d4be9d5a945da950a13e0e3f5182a92a1eaeb0f38ecc481dcb6917
7
- data.tar.gz: f6fcee3ebc58afdcbaeb849cb2eca576de1039ed13783424abda28da6f6fccc80cc9fd5690efdf492e533039b22adcfb16b8a98b28cbc5e5494165dd05b88328
6
+ metadata.gz: 1baed90c0c86bbb46edefd951646ba0f2fffd8cd08b07eb900479a74f372de43d50c7a0f39c56f728f8fb476cfe264075eaafcc3721b2bcd5dddc692b9469592
7
+ data.tar.gz: e98542328ef0210b5934a1c85dfb388b35563df9a4998bb52de7dd6847c05f7ada8a57402d9c359ed08bb6f9420d3a5205d91454c61b40801faa6bfd6fad769f
@@ -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,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.3'
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.3
4
+ version: 0.17.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lax