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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/lib/git-fit.rb +44 -42
  3. data/lib/git_fit/cli/export.rb +8 -6
  4. data/lib/git_fit/cli/geo_cli.rb +10 -8
  5. data/lib/git_fit/cli/gh_cli.rb +24 -22
  6. data/lib/git_fit/cli/import_cli.rb +40 -38
  7. data/lib/git_fit/cli/install_cli.rb +10 -8
  8. data/lib/git_fit/cli/sync.rb +9 -7
  9. data/lib/git_fit/cli.rb +29 -27
  10. data/lib/git_fit/config.rb +42 -28
  11. data/lib/git_fit/config_template.rb +2 -0
  12. data/lib/git_fit/db/activity.rb +2 -0
  13. data/lib/git_fit/db/cli.rb +11 -9
  14. data/lib/git_fit/db/connection.rb +8 -6
  15. data/lib/git_fit/db/rebuild.rb +5 -3
  16. data/lib/git_fit/export/defaults.rb +14 -12
  17. data/lib/git_fit/export/json.rb +5 -3
  18. data/lib/git_fit/export.rb +4 -2
  19. data/lib/git_fit/fit/decoder.rb +9 -7
  20. data/lib/git_fit/fit.rb +3 -1
  21. data/lib/git_fit/geo/amap_client.rb +15 -13
  22. data/lib/git_fit/geo/coord_transform.rb +2 -0
  23. data/lib/git_fit/geo/division_detector.rb +31 -26
  24. data/lib/git_fit/geo/nominatim_client.rb +14 -12
  25. data/lib/git_fit/geo/polyline.rb +4 -2
  26. data/lib/git_fit/geo/reverse_geocode.rb +11 -9
  27. data/lib/git_fit/geo.rb +8 -6
  28. data/lib/git_fit/import/apple_health.rb +84 -82
  29. data/lib/git_fit/import/local_file.rb +45 -42
  30. data/lib/git_fit/import.rb +4 -2
  31. data/lib/git_fit/install/actions.rb +15 -13
  32. data/lib/git_fit/parser/base.rb +12 -10
  33. data/lib/git_fit/parser/fit.rb +11 -9
  34. data/lib/git_fit/parser/gpx.rb +17 -15
  35. data/lib/git_fit/parser/tcx.rb +22 -20
  36. data/lib/git_fit/parser.rb +6 -4
  37. data/lib/git_fit/privacy/polyline_filter.rb +6 -4
  38. data/lib/git_fit/source/base.rb +25 -23
  39. data/lib/git_fit/source/tally.rb +9 -7
  40. data/lib/git_fit/source.rb +4 -2
  41. data/lib/git_fit/sport_mapper.rb +23 -21
  42. data/lib/git_fit/sync/base.rb +29 -27
  43. data/lib/git_fit/sync/garmin.rb +5 -3
  44. data/lib/git_fit/sync/garmin_base.rb +145 -143
  45. data/lib/git_fit/sync/garmin_base_di.rb +42 -40
  46. data/lib/git_fit/sync/garmin_cn.rb +8 -6
  47. data/lib/git_fit/sync/igpsport.rb +43 -41
  48. data/lib/git_fit/sync/keep.rb +98 -96
  49. data/lib/git_fit/sync/lorem.rb +31 -29
  50. data/lib/git_fit/sync/runner.rb +14 -11
  51. data/lib/git_fit/sync/strava.rb +61 -52
  52. data/lib/git_fit/sync/xingzhe.rb +52 -50
  53. data/lib/git_fit/sync/xoss.rb +68 -66
  54. data/lib/git_fit/timezone/resolver.rb +5 -3
  55. data/lib/git_fit/util/tally.rb +10 -8
  56. data/lib/git_fit/util.rb +2 -0
  57. data/lib/git_fit/version.rb +3 -1
  58. metadata +1 -1
@@ -1,18 +1,20 @@
1
- require_relative "base"
2
- require_relative "../fit/decoder"
3
- require "ostruct"
4
- require "base64"
5
- require "oauth"
6
- require "oauth/request_proxy/net_http"
7
- require "json"
8
- require "net/http"
9
- require "uri"
10
- require "cgi"
11
- require "http/cookie_jar"
12
- require "yaml"
13
- require "fileutils"
14
- require "zip"
15
- require "tzinfo"
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../fit/decoder'
5
+ require 'ostruct'
6
+ require 'base64'
7
+ require 'oauth'
8
+ require 'oauth/request_proxy/net_http'
9
+ require 'json'
10
+ require 'net/http'
11
+ require 'uri'
12
+ require 'cgi'
13
+ require 'http/cookie_jar'
14
+ require 'yaml'
15
+ require 'fileutils'
16
+ require 'zip'
17
+ require 'tzinfo'
16
18
 
17
19
  module GitFit
18
20
  module Sync
@@ -20,22 +22,22 @@ module GitFit
20
22
  class SyncError < StandardError; end
21
23
 
22
24
  class GarminBase < Base
23
- CLIENT_ID = "GCM_ANDROID_DARK"
24
- OAUTH_CONSUMER_URL = "https://thegarth.s3.amazonaws.com/oauth_consumer.json"
25
+ CLIENT_ID = 'GCM_ANDROID_DARK'
26
+ OAUTH_CONSUMER_URL = 'https://thegarth.s3.amazonaws.com/oauth_consumer.json'
25
27
 
26
28
  SSO_HEADERS = {
27
- "User-Agent" => "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
28
- "Accept" => "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
29
- "Accept-Language" => "en-US,en;q=0.9"
29
+ 'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148',
30
+ 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
31
+ 'Accept-Language' => 'en-US,en;q=0.9',
30
32
  }.freeze
31
33
 
32
34
  def initialize(...)
33
35
  super
34
- @domain = "garmin.com"
35
- @email = @config["email"]
36
- @password = @config["password"]
37
- @secret = @config["secret"]
38
- @ssl_verify = @config.key?("ssl_verify") ? @config["ssl_verify"] : true
36
+ @domain = 'garmin.com'
37
+ @email = @config['email']
38
+ @password = @config['password']
39
+ @secret = @config['secret']
40
+ @ssl_verify = @config.key?('ssl_verify') ? @config['ssl_verify'] : true
39
41
  end
40
42
 
41
43
  attr_reader :generated_secret
@@ -73,7 +75,7 @@ module GitFit
73
75
  @generated_secret = dump_secret
74
76
  unless verify_access_token
75
77
  @generated_secret = nil
76
- raise AuthError, "New tokens failed API verify — SSO result invalid"
78
+ raise AuthError, 'New tokens failed API verify — SSO result invalid'
77
79
  end
78
80
  save_secret_to_config
79
81
  save_tokens
@@ -89,8 +91,8 @@ module GitFit
89
91
  activities = fetch_activity_list(start, limit)
90
92
  break if activities.nil? || activities.empty?
91
93
  activities.each do |act|
92
- act_id = act["activityId"].to_s
93
- type = map_type(act["activityType"])
94
+ act_id = act['activityId'].to_s
95
+ type = map_type(act['activityType'])
94
96
  next if skip_type?(type)
95
97
  next if existing.include?(act_id) && raw_file_exists?(act_id)
96
98
  ids << { id: act_id, type: type }
@@ -117,7 +119,7 @@ module GitFit
117
119
 
118
120
  attrs = build_attrs(details, act_id, type, polyline)
119
121
 
120
- if (start_t = parse_garmin_time(details.dig("summaryDTO", "startTimeGMT")))
122
+ if (start_t = parse_garmin_time(details.dig('summaryDTO', 'startTimeGMT')))
121
123
  local_t = resolve_local_time(start_t, act_id, polyline)
122
124
  attrs[:start_date_local] = local_t.iso8601 if local_t
123
125
  end
@@ -131,7 +133,7 @@ module GitFit
131
133
  def access_token_valid?
132
134
  return false unless @access_token
133
135
 
134
- expires_at = @access_token["expires_at"] || @saved_expires_at
136
+ expires_at = @access_token['expires_at'] || @saved_expires_at
135
137
  expires_at.nil? || Time.now.to_i < expires_at - 60
136
138
  end
137
139
 
@@ -140,17 +142,17 @@ module GitFit
140
142
  o1 = decoded[0]
141
143
  o2 = decoded[1]
142
144
 
143
- secret_domain = o1["domain"]
145
+ secret_domain = o1['domain']
144
146
  @domain = secret_domain if secret_domain
145
147
  @oauth1_token = OpenStruct.new(
146
- oauth_token: o1["oauth_token"],
147
- oauth_token_secret: o1["oauth_token_secret"],
148
- mfa_token: o1["mfa_token"],
148
+ oauth_token: o1['oauth_token'],
149
+ oauth_token_secret: o1['oauth_token_secret'],
150
+ mfa_token: o1['mfa_token'],
149
151
  domain: @domain
150
152
  )
151
153
  @access_token = o2
152
154
  true
153
- rescue => e
155
+ rescue StandardError => e
154
156
  puts "Garmin: secret decode failed: #{e.message}"
155
157
  puts " #{reauth_hint}"
156
158
  false
@@ -162,29 +164,29 @@ module GitFit
162
164
  oauth_token_secret: @oauth1_token.oauth_token_secret,
163
165
  mfa_token: @oauth1_token.mfa_token,
164
166
  mfa_expiration_timestamp: nil,
165
- domain: @oauth1_token.domain
167
+ domain: @oauth1_token.domain,
166
168
  }
167
169
  JSON.generate([o1, @access_token]).then { |s| Base64.strict_encode64(s) }
168
170
  end
169
171
 
170
172
  def save_secret_to_config
171
- path = "config/config.yml"
173
+ path = 'config/config.yml'
172
174
  return unless File.exist?(path)
173
175
 
174
176
  cfg = YAML.safe_load(File.read(path)) || {}
175
- source = @domain == "garmin.cn" ? "garmin_cn" : "garmin"
176
- cfg["sync"] ||= {}
177
- cfg["sync"][source] ||= {}
178
- cfg["sync"][source]["secret"] = @generated_secret
177
+ source = @domain == 'garmin.cn' ? 'garmin_cn' : 'garmin'
178
+ cfg['sync'] ||= {}
179
+ cfg['sync'][source] ||= {}
180
+ cfg['sync'][source]['secret'] = @generated_secret
179
181
  File.write(path, YAML.dump(cfg))
180
- rescue => e
182
+ rescue StandardError => e
181
183
  puts "Warning: failed to save secret to config.yml: #{e.message}"
182
184
  end
183
185
 
184
186
  def verify_access_token
185
- result = api_get("/activitylist-service/activities/search/activities?start=0&limit=1")
187
+ result = api_get('/activitylist-service/activities/search/activities?start=0&limit=1')
186
188
  !result.nil?
187
- rescue => e
189
+ rescue StandardError => e
188
190
  puts "Garmin: API verify failed — token invalid: #{e.message}"
189
191
  false
190
192
  end
@@ -192,7 +194,7 @@ module GitFit
192
194
  def remove_stale_tokens
193
195
  path = token_path
194
196
  File.delete(path) if File.exist?(path)
195
- rescue => e
197
+ rescue StandardError => e
196
198
  puts "Warning: failed to remove stale tokens: #{e.message}"
197
199
  end
198
200
 
@@ -200,10 +202,10 @@ module GitFit
200
202
  return false unless @oauth1_token&.oauth_token
201
203
 
202
204
  @access_token = exchange_oauth2
203
- @access_token["expires_at"] = Time.now.to_i + (@access_token["expires_in"] || 21600).to_i
205
+ @access_token['expires_at'] = Time.now.to_i + (@access_token['expires_in'] || 21600).to_i
204
206
  save_tokens
205
207
  true
206
- rescue => e
208
+ rescue StandardError => e
207
209
  raise AuthError, "Garmin OAuth refresh failed: #{e.message}" +
208
210
  " — #{reauth_hint}"
209
211
  end
@@ -218,20 +220,20 @@ module GitFit
218
220
  jar = cookie_jar
219
221
  sso_base = "https://sso.#{@domain}"
220
222
 
221
- sso_get(sso_base, "/mobile/sso/en/sign-in", { clientId: CLIENT_ID }, jar)
223
+ sso_get(sso_base, '/mobile/sso/en/sign-in', { clientId: CLIENT_ID }, jar)
222
224
 
223
225
  service_url = "https://mobile.integration.#{@domain}/gcm/android"
224
- login_params = { clientId: CLIENT_ID, locale: "en-US", service: service_url }
226
+ login_params = { clientId: CLIENT_ID, locale: 'en-US', service: service_url }
225
227
  login_body = JSON.generate(
226
228
  username: @email, password: @password,
227
- rememberMe: false, captchaToken: ""
229
+ rememberMe: false, captchaToken: ''
228
230
  )
229
- resp = sso_post(sso_base, "/mobile/api/login", login_params, login_body, jar)
231
+ resp = sso_post(sso_base, '/mobile/api/login', login_params, login_body, jar)
230
232
  body = JSON.parse(resp.body)
231
- status = body.dig("responseStatus", "type")
233
+ status = body.dig('responseStatus', 'type')
232
234
 
233
- if status == "MFA_REQUIRED"
234
- mfa_method = body.dig("customerMfaInfo", "mfaLastMethodUsed") || "email"
235
+ if status == 'MFA_REQUIRED'
236
+ mfa_method = body.dig('customerMfaInfo', 'mfaLastMethodUsed') || 'email'
235
237
  print "MFA code sent via #{mfa_method}. Enter code: "
236
238
  $stdout.flush
237
239
  mfa_code = $stdin.gets.strip
@@ -242,16 +244,16 @@ module GitFit
242
244
  reconsentList: [],
243
245
  mfaSetup: false
244
246
  )
245
- resp = sso_post(sso_base, "/mobile/api/mfa/verifyCode", login_params, mfa_body, jar)
247
+ resp = sso_post(sso_base, '/mobile/api/mfa/verifyCode', login_params, mfa_body, jar)
246
248
  body = JSON.parse(resp.body)
247
- status = body.dig("responseStatus", "type")
249
+ status = body.dig('responseStatus', 'type')
248
250
  end
249
251
 
250
- unless status == "SUCCESSFUL"
252
+ unless status == 'SUCCESSFUL'
251
253
  raise "Garmin login failed (#{body.dig("responseStatus", "message") || status})"
252
254
  end
253
255
 
254
- body["serviceTicketId"]
256
+ body['serviceTicketId']
255
257
  end
256
258
 
257
259
  def sso_get(base, path, params, jar)
@@ -270,7 +272,7 @@ module GitFit
270
272
  uri.query = URI.encode_www_form(params) if params
271
273
  req = Net::HTTP::Post.new(uri)
272
274
  SSO_HEADERS.each { |k, v| req[k] = v }
273
- req["Content-Type"] = "application/json"
275
+ req['Content-Type'] = 'application/json'
274
276
  add_cookies(req, uri, jar)
275
277
  req.body = body
276
278
  resp = send_http(uri, req)
@@ -285,11 +287,11 @@ module GitFit
285
287
  def add_cookies(request, uri, jar)
286
288
  cookies = jar.cookies(uri)
287
289
  return if cookies.empty?
288
- request["Cookie"] = HTTP::Cookie.cookie_value(cookies)
290
+ request['Cookie'] = HTTP::Cookie.cookie_value(cookies)
289
291
  end
290
292
 
291
293
  def update_jar(jar, response, uri)
292
- sets = response.get_fields("Set-Cookie") || []
294
+ sets = response.get_fields('Set-Cookie') || []
293
295
  sets.each { |c| jar.parse(c, uri) }
294
296
  end
295
297
 
@@ -299,15 +301,15 @@ module GitFit
299
301
  url = "#{base_url}preauthorized?ticket=#{CGI.escape(ticket)}&login-url=#{CGI.escape(login_url)}&accepts-mfa-tokens=true"
300
302
 
301
303
  oauth_consumer = OAuth::Consumer.new(
302
- @oauth_consumer["consumer_key"], @oauth_consumer["consumer_secret"],
304
+ @oauth_consumer['consumer_key'], @oauth_consumer['consumer_secret'],
303
305
  site: "https://connectapi.#{@domain}",
304
- scheme: :header, signature_method: "HMAC-SHA1"
306
+ scheme: :header, signature_method: 'HMAC-SHA1'
305
307
  )
306
308
 
307
309
  sleep(3)
308
310
 
309
311
  req = Net::HTTP::Get.new(URI(url))
310
- req["User-Agent"] = "com.garmin.android.apps.connectmobile"
312
+ req['User-Agent'] = 'com.garmin.android.apps.connectmobile'
311
313
  oauth_consumer.sign!(req, nil)
312
314
 
313
315
  resp = retry_on_429(3, 5) { send_http(URI(url), req) }
@@ -316,9 +318,9 @@ module GitFit
316
318
  parsed = CGI.parse(resp.body).transform_keys(&:to_s).transform_values(&:first)
317
319
 
318
320
  OpenStruct.new(
319
- oauth_token: parsed["oauth_token"],
320
- oauth_token_secret: parsed["oauth_token_secret"],
321
- mfa_token: parsed["mfa_token"],
321
+ oauth_token: parsed['oauth_token'],
322
+ oauth_token_secret: parsed['oauth_token_secret'],
323
+ mfa_token: parsed['mfa_token'],
322
324
  domain: @domain
323
325
  )
324
326
  end
@@ -327,16 +329,16 @@ module GitFit
327
329
  url = "https://connectapi.#{@domain}/oauth-service/oauth/exchange/user/2.0"
328
330
 
329
331
  oauth_consumer = OAuth::Consumer.new(
330
- @oauth_consumer["consumer_key"], @oauth_consumer["consumer_secret"],
332
+ @oauth_consumer['consumer_key'], @oauth_consumer['consumer_secret'],
331
333
  site: "https://connectapi.#{@domain}",
332
- scheme: :header, signature_method: "HMAC-SHA1"
334
+ scheme: :header, signature_method: 'HMAC-SHA1'
333
335
  )
334
336
 
335
337
  token = OAuth::Token.new(@oauth1_token.oauth_token, @oauth1_token.oauth_token_secret)
336
338
  req = Net::HTTP::Post.new(URI(url))
337
- req["User-Agent"] = "com.garmin.android.apps.connectmobile"
338
- req["Content-Type"] = "application/x-www-form-urlencoded"
339
- body_params = { audience: "GARMIN_CONNECT_MOBILE_ANDROID_DI" }
339
+ req['User-Agent'] = 'com.garmin.android.apps.connectmobile'
340
+ req['Content-Type'] = 'application/x-www-form-urlencoded'
341
+ body_params = { audience: 'GARMIN_CONNECT_MOBILE_ANDROID_DI' }
340
342
  body_params[:mfa_token] = @oauth1_token.mfa_token if @oauth1_token.mfa_token
341
343
  req.body = URI.encode_www_form(body_params)
342
344
  oauth_consumer.sign!(req, token)
@@ -345,7 +347,7 @@ module GitFit
345
347
  raise "OAuth2 exchange error: #{resp.code}" unless resp.code.to_i == 200
346
348
 
347
349
  result = JSON.parse(resp.body)
348
- result["expires_at"] = Time.now.to_i + (result["expires_in"] || 21600).to_i
350
+ result['expires_at'] = Time.now.to_i + (result['expires_in'] || 21600).to_i
349
351
  result
350
352
  end
351
353
 
@@ -356,7 +358,7 @@ module GitFit
356
358
 
357
359
  def existing_run_ids
358
360
  @db[:activities].where(source: source_prefix).select_map(:run_id)
359
- .map { |id| id.sub("#{source_prefix}_", "") }
361
+ .map { |id| id.sub("#{source_prefix}_", '') }
360
362
  end
361
363
 
362
364
  def fetch_activity_list(start, limit)
@@ -370,8 +372,8 @@ module GitFit
370
372
  def api_get(path)
371
373
  uri = URI("#{connectapi_base}#{path}")
372
374
  req = Net::HTTP::Get.new(uri)
373
- req["Authorization"] = "Bearer #{@access_token["access_token"]}"
374
- req["User-Agent"] = "GCM-iOS-5.22.1.4"
375
+ req['Authorization'] = "Bearer #{@access_token["access_token"]}"
376
+ req['User-Agent'] = 'GCM-iOS-5.22.1.4'
375
377
  resp = retry_on_429 { send_http(uri, req) }
376
378
  return nil unless resp.code.to_i == 200
377
379
  JSON.parse(resp.body)
@@ -420,7 +422,7 @@ module GitFit
420
422
  tz_string = tz_resolver.resolve(lat, lng)
421
423
  tz = TZInfo::Timezone.get(tz_string)
422
424
  tz.utc_to_local(start_utc)
423
- rescue => e
425
+ rescue StandardError => e
424
426
  warn "Garmin: timezone resolution failed for #{activity_id}: #{e.message}"
425
427
  nil
426
428
  end
@@ -433,19 +435,19 @@ module GitFit
433
435
  return [nil, nil] unless l2.is_a?(Array) && l2.any?
434
436
 
435
437
  first = l2.find { |r|
436
- lat = r["positionLat"]
437
- lng = r["positionLong"]
438
+ lat = r['positionLat']
439
+ lng = r['positionLong']
438
440
  lat && lng && lat.to_f != 0.0 && lng.to_f != 0.0
439
441
  }
440
442
  return [nil, nil] unless first
441
443
 
442
- [first["positionLat"].to_f, first["positionLong"].to_f]
444
+ [first['positionLat'].to_f, first['positionLong'].to_f]
443
445
  rescue JSON::ParserError
444
446
  [nil, nil]
445
447
  end
446
448
 
447
449
  def source_prefix
448
- "garmin"
450
+ 'garmin'
449
451
  end
450
452
 
451
453
  def reauth_hint
@@ -453,35 +455,35 @@ module GitFit
453
455
  end
454
456
 
455
457
  def build_attrs(detail, activity_id, type, polyline = nil)
456
- summary = detail["summaryDTO"] || {}
458
+ summary = detail['summaryDTO'] || {}
457
459
 
458
- start_t = parse_garmin_time(summary["startTimeGMT"])
459
- moving_time = summary["movingDuration"]&.to_i || summary["duration"]&.to_i
460
- elapsed_time = summary["elapsedDuration"]&.to_i || summary["duration"]&.to_i
460
+ start_t = parse_garmin_time(summary['startTimeGMT'])
461
+ moving_time = summary['movingDuration']&.to_i || summary['duration']&.to_i
462
+ elapsed_time = summary['elapsedDuration']&.to_i || summary['duration']&.to_i
461
463
 
462
464
  {
463
465
  run_id: "#{source_prefix}_#{activity_id}",
464
- name: detail["activityName"] || "Garmin Activity",
465
- distance: summary["distance"]&.to_f,
466
+ name: detail['activityName'] || 'Garmin Activity',
467
+ distance: summary['distance']&.to_f,
466
468
  moving_time: moving_time,
467
469
  elapsed_time: elapsed_time,
468
470
  sport_category: type,
469
- sport_type: type_str(detail["activityTypeDTO"]),
471
+ sport_type: type_str(detail['activityTypeDTO']),
470
472
  start_date: start_t&.utc&.iso8601,
471
473
  start_date_local: start_t&.iso8601,
472
- location_country: detail["locationName"],
474
+ location_country: detail['locationName'],
473
475
  summary_polyline: polyline,
474
- average_heartrate: summary["averageHR"]&.to_f,
475
- max_heartrate: summary["maxHR"]&.to_f,
476
- average_cadence: summary["avgCadence"]&.to_f,
477
- max_cadence: summary["maxCadence"]&.to_f,
478
- average_power: summary["avgPower"]&.to_f,
479
- max_power: summary["maxPower"]&.to_f,
480
- calories: summary["calories"]&.to_i,
481
- average_temperature: summary["avgTemperature"]&.to_f,
482
- average_speed: summary["averageSpeed"]&.to_f,
483
- elevation_gain: summary["elevationGain"]&.to_f,
484
- source: source_prefix
476
+ average_heartrate: summary['averageHR']&.to_f,
477
+ max_heartrate: summary['maxHR']&.to_f,
478
+ average_cadence: summary['avgCadence']&.to_f,
479
+ max_cadence: summary['maxCadence']&.to_f,
480
+ average_power: summary['avgPower']&.to_f,
481
+ max_power: summary['maxPower']&.to_f,
482
+ calories: summary['calories']&.to_i,
483
+ average_temperature: summary['avgTemperature']&.to_f,
484
+ average_speed: summary['averageSpeed']&.to_f,
485
+ elevation_gain: summary['elevationGain']&.to_f,
486
+ source: source_prefix,
485
487
  }
486
488
  end
487
489
 
@@ -489,44 +491,44 @@ module GitFit
489
491
  return nil unless time_str
490
492
  t = Time.parse(time_str)
491
493
  t.utc? ? t : t.utc
492
- rescue
494
+ rescue StandardError
493
495
  nil
494
496
  end
495
497
 
496
498
  def type_str(val)
497
- return "" unless val
498
- val.is_a?(Hash) ? (val["typeKey"] || "").downcase : val.to_s.downcase
499
+ return '' unless val
500
+ val.is_a?(Hash) ? (val['typeKey'] || '').downcase : val.to_s.downcase
499
501
  end
500
502
 
501
503
  def map_type(activity_type)
502
504
  ts = type_str(activity_type)
503
- return "other" if ts.empty?
505
+ return 'other' if ts.empty?
504
506
  case ts
505
- when /running/ then "run"
506
- when /cycling/, /biking/ then "ride"
507
- when /hiking/ then "hike"
508
- when /walking/ then "walk"
509
- when /swimming/ then "swim"
510
- when /skiing/, /snowboard/ then "ski"
511
- when /workout/, /strength/, /fitness/ then "workout"
512
- else "other"
507
+ when /running/ then 'run'
508
+ when /cycling/, /biking/ then 'ride'
509
+ when /hiking/ then 'hike'
510
+ when /walking/ then 'walk'
511
+ when /swimming/ then 'swim'
512
+ when /skiing/, /snowboard/ then 'ski'
513
+ when /workout/, /strength/, /fitness/ then 'workout'
514
+ else 'other'
513
515
  end
514
516
  end
515
517
 
516
518
  def save_tokens
517
519
  path = token_path
518
520
  dir = File.dirname(path)
519
- FileUtils.mkdir_p(dir) unless dir == "."
521
+ FileUtils.mkdir_p(dir) unless dir == '.'
520
522
  File.write(path, JSON.pretty_generate({
521
523
  oauth1: {
522
524
  oauth_token: @oauth1_token.oauth_token,
523
525
  oauth_token_secret: @oauth1_token.oauth_token_secret,
524
526
  mfa_token: @oauth1_token.mfa_token,
525
- domain: @oauth1_token.domain || @domain
527
+ domain: @oauth1_token.domain || @domain,
526
528
  },
527
- oauth2: @access_token
529
+ oauth2: @access_token,
528
530
  }))
529
- rescue => e
531
+ rescue StandardError => e
530
532
  puts "Failed to save Garmin tokens: #{e.message}"
531
533
  end
532
534
 
@@ -535,25 +537,25 @@ module GitFit
535
537
  return false unless File.exist?(path)
536
538
 
537
539
  data = JSON.parse(File.read(path))
538
- o1 = data["oauth1"]
539
- saved_domain = o1["domain"]
540
+ o1 = data['oauth1']
541
+ saved_domain = o1['domain']
540
542
  @domain = saved_domain if saved_domain
541
543
  @oauth1_token = OpenStruct.new(
542
- oauth_token: o1["oauth_token"],
543
- oauth_token_secret: o1["oauth_token_secret"],
544
- mfa_token: o1["mfa_token"],
544
+ oauth_token: o1['oauth_token'],
545
+ oauth_token_secret: o1['oauth_token_secret'],
546
+ mfa_token: o1['mfa_token'],
545
547
  domain: @domain
546
548
  )
547
- @access_token = data["oauth2"]
548
- @saved_expires_at = @access_token&.dig("expires_at")
549
+ @access_token = data['oauth2']
550
+ @saved_expires_at = @access_token&.dig('expires_at')
549
551
  true
550
- rescue => e
552
+ rescue StandardError => e
551
553
  puts "Failed to load Garmin tokens: #{e.message}"
552
554
  false
553
555
  end
554
556
 
555
557
  def token_path
556
- @config["token_path"] || File.join("data", "auth", "garmin_#{@domain.tr(".", "_")}_tokens.json")
558
+ @config['token_path'] || File.join('data', 'auth', "garmin_#{@domain.tr(".", "_")}_tokens.json")
557
559
  end
558
560
 
559
561
  FIT_EPOCH = 631_065_600
@@ -564,15 +566,15 @@ module GitFit
564
566
 
565
567
  def fetch_summary_polyline(activity_id)
566
568
  gps = api_get("/activity-service/activity/#{activity_id}/details")
567
- polyline = gps&.dig("geoPolylineDTO", "polyline")
569
+ polyline = gps&.dig('geoPolylineDTO', 'polyline')
568
570
 
569
571
  if polyline.is_a?(Array)
570
- points = polyline.map { |pt| [pt["lat"], pt["lon"]] }
572
+ points = polyline.map { |pt| [pt['lat'], pt['lon']] }
571
573
  GitFit::Geo::Polyline.encode(points) if points.size >= 2
572
574
  else
573
575
  polyline
574
576
  end
575
- rescue => e
577
+ rescue StandardError => e
576
578
  puts "Warning: failed to encode polyline for #{activity_id}: #{e.message}"
577
579
  nil
578
580
  end
@@ -580,8 +582,8 @@ module GitFit
580
582
  def download_fit_raw(activity_id)
581
583
  uri = URI("#{connectapi_base}/download-service/files/activity/#{activity_id}")
582
584
  req = Net::HTTP::Get.new(uri)
583
- req["Authorization"] = "Bearer #{@access_token.fetch("access_token")}"
584
- req["User-Agent"] = "GCM-iOS-5.22.1.4"
585
+ req['Authorization'] = "Bearer #{@access_token.fetch("access_token")}"
586
+ req['User-Agent'] = 'GCM-iOS-5.22.1.4'
585
587
  resp = retry_on_429 { send_http(uri, req) }
586
588
  return false unless resp.code.to_i == 200
587
589
 
@@ -594,9 +596,9 @@ module GitFit
594
596
 
595
597
  return false unless fit_data
596
598
 
597
- write_source_archive(fit_data, activity_id, "fit")
599
+ write_source_archive(fit_data, activity_id, 'fit')
598
600
  true
599
- rescue => e
601
+ rescue StandardError => e
600
602
  puts "Failed to download FIT for #{activity_id}: #{e.message}"
601
603
  false
602
604
  end
@@ -609,27 +611,27 @@ module GitFit
609
611
  return nil if records.empty?
610
612
 
611
613
  records.map do |r|
612
- lat = r["positionLat"]
613
- lng = r["positionLong"]
614
+ lat = r['positionLat']
615
+ lng = r['positionLong']
614
616
  next unless lat && lng && lat != 0.0 && lng != 0.0
615
617
 
616
- ts = r["timestamp"]
618
+ ts = r['timestamp']
617
619
  abs_ts = ts ? Time.at(FIT_EPOCH + ts).utc.xmlschema : nil
618
620
 
619
621
  {
620
622
  positionLat: lat,
621
623
  positionLong: lng,
622
- altitude: r["altitude"],
623
- heartRate: r["heartRate"],
624
- cadence: r["cadence"],
625
- power: r["power"],
626
- speed: r["speed"],
624
+ altitude: r['altitude'],
625
+ heartRate: r['heartRate'],
626
+ cadence: r['cadence'],
627
+ power: r['power'],
628
+ speed: r['speed'],
627
629
  timestamp: abs_ts,
628
- distance: r["distance"],
629
- temperature: r["temperature"]
630
+ distance: r['distance'],
631
+ temperature: r['temperature'],
630
632
  }.compact
631
633
  end.compact
632
- rescue => e
634
+ rescue StandardError => e
633
635
  puts "Failed to decode FIT for #{activity_id}: #{e.message}"
634
636
  nil
635
637
  end