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,5 +1,7 @@
1
- require_relative "garmin_base"
2
- require "open3"
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'garmin_base'
4
+ require 'open3'
3
5
 
4
6
  module GitFit
5
7
  module Sync
@@ -31,16 +33,16 @@ module GitFit
31
33
  end
32
34
  end
33
35
 
34
- puts "Garmin: no valid DI token found"
35
- puts " First time: ./scripts/garmin_auth_local.sh"
36
- puts " Refresh: ./scripts/garmin_auth_local.sh --sync"
36
+ puts 'Garmin: no valid DI token found'
37
+ puts ' First time: ./scripts/garmin_auth_local.sh'
38
+ puts ' Refresh: ./scripts/garmin_auth_local.sh --sync'
37
39
  false
38
40
  end
39
41
 
40
42
  def before_call
41
- if @config["auth_seed"].to_s.empty? && !File.exist?(token_path)
42
- puts "Garmin: auth_seed not configured and no cached token"
43
- puts " Run: ./scripts/garmin_auth_local.sh"
43
+ if @config['auth_seed'].to_s.empty? && !File.exist?(token_path)
44
+ puts 'Garmin: auth_seed not configured and no cached token'
45
+ puts ' Run: ./scripts/garmin_auth_local.sh'
44
46
  return false
45
47
  end
46
48
  @start_time = Time.now
@@ -48,7 +50,7 @@ module GitFit
48
50
  end
49
51
 
50
52
  def reauth_hint
51
- "Re-auth: ./scripts/garmin_auth_local.sh --sync"
53
+ 'Re-auth: ./scripts/garmin_auth_local.sh --sync'
52
54
  end
53
55
 
54
56
  private
@@ -64,30 +66,30 @@ module GitFit
64
66
  data = JSON.parse(File.read(path))
65
67
  @access_token = data
66
68
  true
67
- rescue => e
69
+ rescue StandardError => e
68
70
  puts "Failed to load Garmin tokens: #{e.message}"
69
71
  false
70
72
  end
71
73
 
72
74
  def save_tokens
73
- return unless @access_token&.dig("access_token")
75
+ return unless @access_token&.dig('access_token')
74
76
 
75
77
  path = token_path
76
78
  FileUtils.mkdir_p(File.dirname(path))
77
79
  File.write(path, JSON.pretty_generate(@access_token))
78
- rescue => e
80
+ rescue StandardError => e
79
81
  puts "Failed to save Garmin tokens: #{e.message}"
80
82
  end
81
83
 
82
84
  def remove_stale_tokens
83
85
  path = token_path
84
86
  File.delete(path) if File.exist?(path)
85
- rescue => e
87
+ rescue StandardError => e
86
88
  puts "Warning: failed to remove stale tokens: #{e.message}"
87
89
  end
88
90
 
89
91
  def load_auth_seed
90
- seed = @config["auth_seed"]
92
+ seed = @config['auth_seed']
91
93
  return false unless seed && !seed.empty?
92
94
 
93
95
  decoded = JSON.parse(Base64.strict_decode64(seed))
@@ -95,49 +97,49 @@ module GitFit
95
97
  save_tokens
96
98
  true
97
99
  rescue JSON::ParserError
98
- puts "Garmin: auth_seed decode failed — invalid Base64 or JSON"
99
- puts " Fix: ./scripts/garmin_auth_local.sh --sync"
100
+ puts 'Garmin: auth_seed decode failed — invalid Base64 or JSON'
101
+ puts ' Fix: ./scripts/garmin_auth_local.sh --sync'
100
102
  false
101
- rescue => e
103
+ rescue StandardError => e
102
104
  puts "Garmin: auth_seed error: #{e.message}"
103
105
  false
104
106
  end
105
107
 
106
108
  def refresh_token
107
- return false unless @access_token&.dig("refresh_token")
109
+ return false unless @access_token&.dig('refresh_token')
108
110
 
109
- client_id = @access_token["di_client_id"] || DI_CLIENT_IDS.first
111
+ client_id = @access_token['di_client_id'] || DI_CLIENT_IDS.first
110
112
  basic = Base64.strict_encode64("#{client_id}:")
111
113
 
112
114
  uri = URI("#{auth_base}/di-oauth2-service/oauth/token")
113
115
  req = Net::HTTP::Post.new(uri)
114
- req["Authorization"] = "Basic #{basic}"
115
- req["Content-Type"] = "application/x-www-form-urlencoded"
116
- req["Cache-Control"] = "no-cache"
117
- req["User-Agent"] = "GCM-Android-5.23"
116
+ req['Authorization'] = "Basic #{basic}"
117
+ req['Content-Type'] = 'application/x-www-form-urlencoded'
118
+ req['Cache-Control'] = 'no-cache'
119
+ req['User-Agent'] = 'GCM-Android-5.23'
118
120
  req.body = URI.encode_www_form(
119
- grant_type: "refresh_token",
121
+ grant_type: 'refresh_token',
120
122
  client_id: client_id,
121
- refresh_token: @access_token["refresh_token"]
123
+ refresh_token: @access_token['refresh_token']
122
124
  )
123
125
 
124
126
  resp = retry_on_429 { send_http(uri, req) }
125
127
  unless resp.code.to_i == 200
126
128
  msg = "DI refresh error: #{resp.code}"
127
129
  if resp.code.to_i == 400
128
- msg += " — refresh_token expired across rotation"
129
- msg += ", re-run: scripts/garmin_auth_local.sh"
130
+ msg += ' — refresh_token expired across rotation'
131
+ msg += ', re-run: scripts/garmin_auth_local.sh'
130
132
  elsif resp.code.to_i == 401
131
- msg += " — DI client_id rotated by Garmin"
132
- msg += ", re-run: scripts/garmin_auth_local.sh"
133
+ msg += ' — DI client_id rotated by Garmin'
134
+ msg += ', re-run: scripts/garmin_auth_local.sh'
133
135
  end
134
136
  raise AuthError, msg
135
137
  end
136
138
 
137
139
  result = JSON.parse(resp.body)
138
- result["expires_at"] = Time.now.to_i + (result["expires_in"] || 64800).to_i
139
- if result["refresh_token_expires_in"]
140
- result["refresh_token_expires_at"] = Time.now.to_i + result["refresh_token_expires_in"].to_i
140
+ result['expires_at'] = Time.now.to_i + (result['expires_in'] || 64800).to_i
141
+ if result['refresh_token_expires_in']
142
+ result['refresh_token_expires_at'] = Time.now.to_i + result['refresh_token_expires_in'].to_i
141
143
  end
142
144
  @access_token = result
143
145
  save_tokens
@@ -153,31 +155,31 @@ module GitFit
153
155
  # historical. In scripts/garmin_auth_local.sh, Playwright runs first (cookies
154
156
  # persist 365d → no repeat MFA), curl_cffi is the fallback.
155
157
  def strategy_a_login
156
- script = File.expand_path("../../../scripts/garmin_auth.py", __dir__)
158
+ script = File.expand_path('../../../scripts/garmin_auth.py', __dir__)
157
159
  return nil unless File.exist?(script)
158
160
 
159
161
  stdout, stderr, status = Open3.capture3(
160
- {"SYNC__GARMIN__EMAIL" => @email, "SYNC__GARMIN__PASSWORD" => @password, "SYNC__GARMIN__DOMAIN" => @domain},
161
- "python3", script
162
+ {'SYNC__GARMIN__EMAIL' => @email, 'SYNC__GARMIN__PASSWORD' => @password, 'SYNC__GARMIN__DOMAIN' => @domain},
163
+ 'python3', script
162
164
  )
163
165
  warn stderr unless stderr.empty?
164
166
  JSON.parse(stdout) if status.success?
165
- rescue => e
167
+ rescue StandardError => e
166
168
  puts "Strategy A (curl_cffi) failed: #{e.message}"
167
169
  nil
168
170
  end
169
171
 
170
172
  def strategy_b_login
171
- script = File.expand_path("../../../scripts/garmin_auth_playwright.py", __dir__)
173
+ script = File.expand_path('../../../scripts/garmin_auth_playwright.py', __dir__)
172
174
  return nil unless File.exist?(script)
173
175
 
174
176
  stdout, stderr, status = Open3.capture3(
175
- {"SYNC__GARMIN__EMAIL" => @email, "SYNC__GARMIN__PASSWORD" => @password, "SYNC__GARMIN__DOMAIN" => @domain},
176
- "python3", script
177
+ {'SYNC__GARMIN__EMAIL' => @email, 'SYNC__GARMIN__PASSWORD' => @password, 'SYNC__GARMIN__DOMAIN' => @domain},
178
+ 'python3', script
177
179
  )
178
180
  warn stderr unless stderr.empty?
179
181
  JSON.parse(stdout) if status.success?
180
- rescue => e
182
+ rescue StandardError => e
181
183
  puts "Strategy B (Playwright) failed: #{e.message}"
182
184
  nil
183
185
  end
@@ -1,4 +1,6 @@
1
- require_relative "garmin_base"
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'garmin_base'
2
4
 
3
5
  module GitFit
4
6
  module Sync
@@ -8,20 +10,20 @@ module GitFit
8
10
 
9
11
  def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil)
10
12
  super
11
- @domain = "garmin.cn"
12
- @ssl_verify = config.key?("ssl_verify") ? config["ssl_verify"] : false
13
+ @domain = 'garmin.cn'
14
+ @ssl_verify = config.key?('ssl_verify') ? config['ssl_verify'] : false
13
15
  end
14
16
 
15
17
  def raw_dir
16
- File.join("data", "raw", "garmin_cn")
18
+ File.join('data', 'raw', 'garmin_cn')
17
19
  end
18
20
 
19
21
  def std_dir
20
- File.join("data", "std", "garmin_cn")
22
+ File.join('data', 'std', 'garmin_cn')
21
23
  end
22
24
 
23
25
  def source_prefix
24
- "garmin_cn"
26
+ 'garmin_cn'
25
27
  end
26
28
  end
27
29
  end
@@ -1,28 +1,30 @@
1
- require_relative "base"
2
- require "faraday"
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require 'faraday'
3
5
 
4
6
  module GitFit
5
7
  module Sync
6
8
  class IGPSPORT < Base
7
- BASE_URL = "https://prod.zh.igpsport.com/service"
9
+ BASE_URL = 'https://prod.zh.igpsport.com/service'
8
10
  LOGIN_URL = "#{BASE_URL}/auth/account/login"
9
11
  QUERY_URL = "#{BASE_URL}/web-gateway/web-analyze/activity/queryMyActivity"
10
12
  DOWNLOAD_URL = "#{BASE_URL}/web-gateway/web-analyze/activity/getDownloadUrl"
11
13
 
12
14
  EXERCISE_MAP = {
13
- 0 => "ride", 1 => "run", 2 => "hike", 3 => "walk",
14
- 4 => "swim", 5 => "ski", 6 => "workout"
15
+ 0 => 'ride', 1 => 'run', 2 => 'hike', 3 => 'walk',
16
+ 4 => 'swim', 5 => 'ski', 6 => 'workout'
15
17
  }.freeze
16
18
 
17
- RAW_EXT = "fit"
19
+ RAW_EXT = 'fit'
18
20
 
19
21
  register_adapter
20
22
  register_config :phone, :password
21
23
 
22
24
  def initialize(...)
23
25
  super
24
- @phone = @config["phone"]
25
- @password = @config["password"]
26
+ @phone = @config['phone']
27
+ @password = @config['password']
26
28
  end
27
29
 
28
30
  def before_call
@@ -42,9 +44,9 @@ module GitFit
42
44
  acts = fetch_page(page)
43
45
  break if acts.nil? || acts.empty?
44
46
  acts.each do |act|
45
- ride_id = act["rideId"].to_s
47
+ ride_id = act['rideId'].to_s
46
48
  next if existing.include?(ride_id) && raw_file_exists?(ride_id)
47
- type = map_sport(act["exerciseType"])
49
+ type = map_sport(act['exerciseType'])
48
50
  next if skip_type?(type)
49
51
  ids << { id: ride_id, type: type, summary: act }
50
52
  end
@@ -61,14 +63,14 @@ module GitFit
61
63
 
62
64
  def login
63
65
  resp = Faraday.post(LOGIN_URL,
64
- JSON.generate({ appId: "igpsport-web", username: @phone, password: @password }),
65
- { "Content-Type" => "application/json" }
66
+ JSON.generate({ appId: 'igpsport-web', username: @phone, password: @password }),
67
+ { 'Content-Type' => 'application/json' }
66
68
  )
67
69
  return false unless resp.status == 200
68
70
  data = JSON.parse(resp.body)
69
- @token = data.dig("data", "access_token")
71
+ @token = data.dig('data', 'access_token')
70
72
  return false unless @token
71
- @auth_headers = { "Authorization" => "Bearer #{@token}" }
73
+ @auth_headers = { 'Authorization' => "Bearer #{@token}" }
72
74
  true
73
75
  end
74
76
 
@@ -88,16 +90,16 @@ module GitFit
88
90
  wgs_points = FIT::Decoder.decode(fit_path)
89
91
  return false if wgs_points.empty?
90
92
 
91
- first_ts = wgs_points.first["timestamp"]
93
+ first_ts = wgs_points.first['timestamp']
92
94
  if first_ts
93
- fit_epoch = Time.new(1989, 12, 31, 0, 0, 0, "+00:00").to_i
95
+ fit_epoch = Time.new(1989, 12, 31, 0, 0, 0, '+00:00').to_i
94
96
  t = Time.at(fit_epoch + first_ts)
95
97
  @fit_start_utc = t.utc
96
- @fit_start_local = t.getlocal("+08:00")
98
+ @fit_start_local = t.getlocal('+08:00')
97
99
  end
98
100
 
99
101
  write_standardized_json(wgs_points, ride_id)
100
- polyline = Geo::Polyline.encode(wgs_points.map { |pt| [pt["positionLat"], pt["positionLong"]] })
102
+ polyline = Geo::Polyline.encode(wgs_points.map { |pt| [pt['positionLat'], pt['positionLong']] })
101
103
  sensor_summary = compute_sensor_summary(wgs_points)
102
104
 
103
105
  attrs = build_attrs(act, ride_id, polyline, sensor_summary)
@@ -110,7 +112,7 @@ module GitFit
110
112
  def get_download_url(ride_id)
111
113
  resp = Faraday.get("#{DOWNLOAD_URL}/#{ride_id}", {}, @auth_headers)
112
114
  return nil unless resp.status == 200
113
- JSON.parse(resp.body)["data"]
115
+ JSON.parse(resp.body)['data']
114
116
  end
115
117
 
116
118
  def download_fit(url, ride_id)
@@ -128,7 +130,7 @@ module GitFit
128
130
  wgs_points = FIT::Decoder.decode(fit_path)
129
131
  return nil if wgs_points.empty?
130
132
 
131
- Geo::Polyline.encode(wgs_points.map { |pt| [pt["positionLat"], pt["positionLong"]] })
133
+ Geo::Polyline.encode(wgs_points.map { |pt| [pt['positionLat'], pt['positionLong']] })
132
134
  end
133
135
 
134
136
  def write_standardized_json(wgs_points, ride_id)
@@ -146,30 +148,30 @@ module GitFit
146
148
  @auth_headers
147
149
  )
148
150
  return nil unless resp.status == 200
149
- JSON.parse(resp.body).dig("data", "rows")
151
+ JSON.parse(resp.body).dig('data', 'rows')
150
152
  end
151
153
 
152
154
  def build_attrs(act, ride_id, polyline = nil, sensor = {})
153
- type = map_sport(act["exerciseType"])
154
- start_t = @fit_start_utc || parse_date(act["startTime"])
155
+ type = map_sport(act['exerciseType'])
156
+ start_t = @fit_start_utc || parse_date(act['startTime'])
155
157
  start_t_local = @fit_start_local || start_t
156
- distance = act["rideDistance"].to_f
157
- moving_time = (act["totalMovingTime"] || act["recordTime"]).to_i
158
- avg_speed = act["avgSpeed"].to_f
158
+ distance = act['rideDistance'].to_f
159
+ moving_time = (act['totalMovingTime'] || act['recordTime']).to_i
160
+ avg_speed = act['avgSpeed'].to_f
159
161
 
160
162
  {
161
163
  run_id: "igpsport_#{ride_id}",
162
- name: act["title"] || "iGPSPORT Activity",
164
+ name: act['title'] || 'iGPSPORT Activity',
163
165
  distance: distance,
164
166
  moving_time: moving_time > 0 ? moving_time : nil,
165
167
  elapsed_time: moving_time > 0 ? moving_time : nil,
166
168
  sport_category: type,
167
- sport_type: EXERCISE_MAP[act["exerciseType"]] || "ride",
169
+ sport_type: EXERCISE_MAP[act['exerciseType']] || 'ride',
168
170
  start_date: start_t.iso8601,
169
171
  start_date_local: start_t_local.iso8601,
170
172
  location_country: nil,
171
- average_heartrate: act["avgHrm"]&.to_f&.nonzero?,
172
- max_heartrate: act["maxHrm"]&.to_f&.nonzero?,
173
+ average_heartrate: act['avgHrm']&.to_f&.nonzero?,
174
+ max_heartrate: act['maxHrm']&.to_f&.nonzero?,
173
175
  average_cadence: sensor[:average_cadence],
174
176
  max_cadence: sensor[:max_cadence],
175
177
  average_power: sensor[:average_power],
@@ -177,18 +179,18 @@ module GitFit
177
179
  calories: sensor[:calories],
178
180
  average_temperature: sensor[:average_temperature],
179
181
  average_speed: avg_speed > 0 ? avg_speed : nil,
180
- elevation_gain: act["totalAscent"].to_f.nonzero?,
182
+ elevation_gain: act['totalAscent'].to_f.nonzero?,
181
183
  summary_polyline: polyline,
182
- source: "igpsport"
184
+ source: 'igpsport',
183
185
  }
184
186
  end
185
187
 
186
188
  def compute_sensor_summary(records)
187
189
  return {} if records.empty?
188
190
 
189
- cad = records.map { |r| r["cadence"] }.compact
190
- pow = records.map { |r| r["power"] }.compact
191
- tmp = records.map { |r| r["temperature"] }.compact
191
+ cad = records.map { |r| r['cadence'] }.compact
192
+ pow = records.map { |r| r['power'] }.compact
193
+ tmp = records.map { |r| r['temperature'] }.compact
192
194
 
193
195
  {
194
196
  average_cadence: cad.any? ? (cad.sum.to_f / cad.size).round(1) : nil,
@@ -196,25 +198,25 @@ module GitFit
196
198
  average_power: pow.any? ? (pow.sum.to_f / pow.size).round(1) : nil,
197
199
  max_power: pow.max,
198
200
  average_temperature: tmp.any? ? (tmp.sum.to_f / tmp.size).round(1) : nil,
199
- calories: nil
201
+ calories: nil,
200
202
  }
201
203
  end
202
204
 
203
205
  def map_sport(exercise_type)
204
- EXERCISE_MAP[exercise_type] || "other"
206
+ EXERCISE_MAP[exercise_type] || 'other'
205
207
  end
206
208
 
207
209
  def parse_date(str)
208
210
  return nil unless str
209
- normalized = str.to_s.tr(".", "-")
211
+ normalized = str.to_s.tr('.', '-')
210
212
  Time.parse(normalized)
211
- rescue
213
+ rescue StandardError
212
214
  nil
213
215
  end
214
216
 
215
217
  def existing_run_ids
216
- @db[:activities].where(source: "igpsport").select_map(:run_id)
217
- .map { |id| id.sub("igpsport_", "") }
218
+ @db[:activities].where(source: 'igpsport').select_map(:run_id)
219
+ .map { |id| id.sub('igpsport_', '') }
218
220
  end
219
221
 
220
222
  def raw_path(platform_id)