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,18 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
require
|
|
6
|
-
require
|
|
7
|
-
require
|
|
8
|
-
require
|
|
9
|
-
require
|
|
10
|
-
require
|
|
11
|
-
require
|
|
12
|
-
require
|
|
13
|
-
require
|
|
14
|
-
require
|
|
15
|
-
require
|
|
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 =
|
|
24
|
-
OAUTH_CONSUMER_URL =
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 =
|
|
35
|
-
@email = @config[
|
|
36
|
-
@password = @config[
|
|
37
|
-
@secret = @config[
|
|
38
|
-
@ssl_verify = @config.key?(
|
|
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,
|
|
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[
|
|
93
|
-
type = map_type(act[
|
|
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(
|
|
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[
|
|
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[
|
|
145
|
+
secret_domain = o1['domain']
|
|
144
146
|
@domain = secret_domain if secret_domain
|
|
145
147
|
@oauth1_token = OpenStruct.new(
|
|
146
|
-
oauth_token: o1[
|
|
147
|
-
oauth_token_secret: o1[
|
|
148
|
-
mfa_token: o1[
|
|
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 =
|
|
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 ==
|
|
176
|
-
cfg[
|
|
177
|
-
cfg[
|
|
178
|
-
cfg[
|
|
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(
|
|
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[
|
|
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,
|
|
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:
|
|
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,
|
|
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(
|
|
233
|
+
status = body.dig('responseStatus', 'type')
|
|
232
234
|
|
|
233
|
-
if status ==
|
|
234
|
-
mfa_method = body.dig(
|
|
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,
|
|
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(
|
|
249
|
+
status = body.dig('responseStatus', 'type')
|
|
248
250
|
end
|
|
249
251
|
|
|
250
|
-
unless status ==
|
|
252
|
+
unless status == 'SUCCESSFUL'
|
|
251
253
|
raise "Garmin login failed (#{body.dig("responseStatus", "message") || status})"
|
|
252
254
|
end
|
|
253
255
|
|
|
254
|
-
body[
|
|
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[
|
|
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[
|
|
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(
|
|
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[
|
|
304
|
+
@oauth_consumer['consumer_key'], @oauth_consumer['consumer_secret'],
|
|
303
305
|
site: "https://connectapi.#{@domain}",
|
|
304
|
-
scheme: :header, signature_method:
|
|
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[
|
|
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[
|
|
320
|
-
oauth_token_secret: parsed[
|
|
321
|
-
mfa_token: parsed[
|
|
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[
|
|
332
|
+
@oauth_consumer['consumer_key'], @oauth_consumer['consumer_secret'],
|
|
331
333
|
site: "https://connectapi.#{@domain}",
|
|
332
|
-
scheme: :header, signature_method:
|
|
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[
|
|
338
|
-
req[
|
|
339
|
-
body_params = { audience:
|
|
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[
|
|
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[
|
|
374
|
-
req[
|
|
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[
|
|
437
|
-
lng = r[
|
|
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[
|
|
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
|
-
|
|
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[
|
|
458
|
+
summary = detail['summaryDTO'] || {}
|
|
457
459
|
|
|
458
|
-
start_t = parse_garmin_time(summary[
|
|
459
|
-
moving_time = summary[
|
|
460
|
-
elapsed_time = summary[
|
|
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[
|
|
465
|
-
distance: summary[
|
|
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[
|
|
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[
|
|
474
|
+
location_country: detail['locationName'],
|
|
473
475
|
summary_polyline: polyline,
|
|
474
|
-
average_heartrate: summary[
|
|
475
|
-
max_heartrate: summary[
|
|
476
|
-
average_cadence: summary[
|
|
477
|
-
max_cadence: summary[
|
|
478
|
-
average_power: summary[
|
|
479
|
-
max_power: summary[
|
|
480
|
-
calories: summary[
|
|
481
|
-
average_temperature: summary[
|
|
482
|
-
average_speed: summary[
|
|
483
|
-
elevation_gain: summary[
|
|
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
|
|
498
|
-
val.is_a?(Hash) ? (val[
|
|
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
|
|
505
|
+
return 'other' if ts.empty?
|
|
504
506
|
case ts
|
|
505
|
-
when /running/ then
|
|
506
|
-
when /cycling/, /biking/ then
|
|
507
|
-
when /hiking/ then
|
|
508
|
-
when /walking/ then
|
|
509
|
-
when /swimming/ then
|
|
510
|
-
when /skiing/, /snowboard/ then
|
|
511
|
-
when /workout/, /strength/, /fitness/ then
|
|
512
|
-
else
|
|
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[
|
|
539
|
-
saved_domain = o1[
|
|
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[
|
|
543
|
-
oauth_token_secret: o1[
|
|
544
|
-
mfa_token: o1[
|
|
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[
|
|
548
|
-
@saved_expires_at = @access_token&.dig(
|
|
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[
|
|
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(
|
|
569
|
+
polyline = gps&.dig('geoPolylineDTO', 'polyline')
|
|
568
570
|
|
|
569
571
|
if polyline.is_a?(Array)
|
|
570
|
-
points = polyline.map { |pt| [pt[
|
|
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[
|
|
584
|
-
req[
|
|
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,
|
|
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[
|
|
613
|
-
lng = r[
|
|
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[
|
|
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[
|
|
623
|
-
heartRate: r[
|
|
624
|
-
cadence: r[
|
|
625
|
-
power: r[
|
|
626
|
-
speed: r[
|
|
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[
|
|
629
|
-
temperature: r[
|
|
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
|