git-fit 0.11.3 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 276921601668a2e97d554de9e446f64e0fe6986e681d5eada7d9688757b51d98
4
- data.tar.gz: 925e3e271c03e659b406426d03f4714eaff62fb57961738455f43d6926bd0c25
3
+ metadata.gz: c3e935c4e4949dfd80ef84c659f9f64a727d9c1d3aab7c2b6303d6571f42156b
4
+ data.tar.gz: 771f645e5ae8a369c89237d3df03c00b06d5887f5331e7cfec306af0c57f8eb6
5
5
  SHA512:
6
- metadata.gz: 02537c373595d27fc4d65288a7608d4eb5713500eddb022e881aa9df09c68c423522cf6b065da4852bc65c155fd331836e20497ddb29dc64635a12896d3c70ae
7
- data.tar.gz: 8fd6c2b178cf4a6a0e38345e0e5884cdea0ce9da14573ab3f775adb918a335e6b1cad6a2d6d92fa135c94919fcd7ac25b2870a2b963b306af206d74ce3de226c
6
+ metadata.gz: 926a648129246714657066e5d7a21b9663682af63516a98d21646e9c58167b4c962150697a8d1d9e14068c834d1d010f9d3696628f8edae9b6ee8f2c614d36cc
7
+ data.tar.gz: 7bebd70ae9b51e18fc97278f7e691466ea5ca4aec07594c83bc7ca2c9ff913f85566dcf9b98b6013a279a3c2927216fd60ad82d860d8185b8fb133a873fbc5fe
data/lib/git-fit.rb CHANGED
@@ -63,7 +63,7 @@ require_relative 'git_fit/util'
63
63
  require_relative 'git_fit/sync/lorem'
64
64
  require_relative 'git_fit/sync/garmin_base'
65
65
  require_relative 'git_fit/sync/garmin_base_di'
66
- require_relative 'git_fit/sync/garmin'
66
+ require_relative 'git_fit/sync/garmin_com'
67
67
  require_relative 'git_fit/sync/garmin_cn'
68
68
  require_relative 'git_fit/sync/strava'
69
69
  require_relative 'git_fit/sync/keep'
@@ -15,7 +15,7 @@ module GitFit
15
15
  # Persists the pre-auth login session created during an interrupted MFA
16
16
  # flow. This is a *temporary* session (30-minute TTL matching the MFA code
17
17
  # validity window), distinct from the long-lived persistent session cookie
18
- # (garmin_playwright_session_*.json) that Strategy B keeps after success.
18
+ # (data/auth/garmin/session.json) that Strategy B keeps after success.
19
19
  module LoginSession
20
20
  DIR = 'data/cache'
21
21
  TTL_SECONDS = 30 * 60
@@ -2,7 +2,8 @@
2
2
 
3
3
  require_relative 'di_exchange'
4
4
  require_relative 'login_session'
5
- require_relative '../../sync/garmin_base'
5
+ require_relative '../../sync/garmin_com'
6
+ require_relative '../paths'
6
7
  require 'json'
7
8
  require 'fileutils'
8
9
 
@@ -18,7 +19,6 @@ module GitFit
18
19
  MOBILE_SERVICE = 'https://mobile.integration.garmin.com/gcm/android'
19
20
  LOGIN_DELAY_MIN = 30.0
20
21
  LOGIN_DELAY_MAX = 45.0
21
- SESSION_CACHE_DIR = 'data/cache'
22
22
  REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
23
23
  AUTH_FLAG = '-B'
24
24
 
@@ -204,8 +204,8 @@ module GitFit
204
204
  sleep(delay)
205
205
  end
206
206
 
207
- def _session_file_path(domain)
208
- File.join(SESSION_CACHE_DIR, "garmin_playwright_session_#{domain.tr('.', '_')}.json")
207
+ def _session_file_path(_domain)
208
+ Auth.file(GitFit::Sync::GarminCom.source_prefix, 'session.json')
209
209
  end
210
210
 
211
211
  def _load_session(session_path)
@@ -4,6 +4,8 @@ require_relative 'garmin/strategy_a'
4
4
  require_relative 'garmin/strategy_b'
5
5
  require_relative 'garmin/di_exchange'
6
6
  require_relative 'garmin/login_session'
7
+ require_relative 'paths'
8
+ require_relative '../sync/garmin_com'
7
9
  require_relative '../config'
8
10
  require_relative '../cli/gh_cli'
9
11
  require 'json'
@@ -189,7 +191,7 @@ module GitFit
189
191
 
190
192
  def token_path
191
193
  garmin_cfg['token_path'] ||
192
- File.join('data', 'auth', "garmin_#{domain.tr('.', '_')}_tokens.json")
194
+ Auth.file(GitFit::Sync::GarminCom.source_prefix, 'tokens.json')
193
195
  end
194
196
 
195
197
  def gh_ladder(seed)
@@ -10,7 +10,7 @@ module GitFit
10
10
  # commands, CI token checks).
11
11
  class GarminToken
12
12
  def self.for_intl(config)
13
- authenticate_with(GitFit::Sync::Garmin, config, 'garmin.com')
13
+ authenticate_with(GitFit::Sync::GarminCom, config, 'garmin.com')
14
14
  end
15
15
 
16
16
  def self.for_cn(config)
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitFit
4
+ module Auth
5
+ module_function
6
+
7
+ # Canonical per-source auth storage: data/auth/{source}/{name}. All sync
8
+ # adapters, the auth orchestrator and strategies must resolve token/session
9
+ # defaults through this helper so they can never drift apart.
10
+ def file(source, name)
11
+ File.join('data', 'auth', source, name)
12
+ end
13
+ end
14
+ end
@@ -184,5 +184,36 @@ module GitFit
184
184
  0
185
185
  end
186
186
  end
187
+
188
+ desc 'tbulu', 'Import 2bulu (两步路) activities from data/import/2bulu/'
189
+ option :activity, type: :array, desc: 'Filter by sport type', aliases: '--act'
190
+ option :checkpoint, type: :boolean, desc: 'Run WAL checkpoint after import'
191
+ def tbulu
192
+ config = GitFit::Config.new
193
+ conn = GitFit::DB::Connection.new(config.db_path)
194
+ conn.migrate!
195
+ db = conn.db
196
+ filter = options[:activity]&.map(&:strip)&.map(&:downcase)
197
+ time_budget = config.dig('sync', 'time_budget')
198
+
199
+ say_status :import, '2bulu', :green
200
+ adapter = GitFit::Import::Tbulu.new(
201
+ config: {}, db:, activity_filter: filter,
202
+ time_budget: time_budget
203
+ )
204
+
205
+ begin
206
+ count = adapter.call
207
+ if count == 0
208
+ say_status :warn, '2bulu: 0 activities (already imported or none found)', :yellow
209
+ else
210
+ say_status :done, "2bulu: #{count} activities", :green
211
+ end
212
+ checkpoint_db(db) if options[:checkpoint]
213
+ rescue StandardError => e
214
+ say_status :error, "2bulu: #{e.message}", :red
215
+ 0
216
+ end
217
+ end
187
218
  end
188
219
  end
@@ -4,6 +4,7 @@ require 'thor'
4
4
  require 'fileutils'
5
5
  require 'tzinfo'
6
6
  require 'stravaweb'
7
+ require_relative '../auth/paths'
7
8
 
8
9
  module GitFit
9
10
  class StravaCLI < Thor
@@ -60,7 +61,7 @@ module GitFit
60
61
  FileUtils.mkdir_p(output_dir)
61
62
  cache_dir = File.join('data', 'cache', 'strava_downloads')
62
63
  FileUtils.mkdir_p(cache_dir)
63
- cookie_path = File.join('data', 'cache', 'strava_session.yml')
64
+ cookie_path = web_cfg['cookie_path'] || GitFit::Auth.file('strava', 'session.yml')
64
65
 
65
66
  if activity_ids.empty?
66
67
  activity_ids = pending_ids(config, output_dir)
@@ -19,6 +19,7 @@ module GitFit
19
19
  # web_auth: # web original-file fetch (git fit strava fetch)
20
20
  # jwt: "" # env: GIT_FIT_STRAVA_WEB_AUTH_JWT (strava_remember_token)
21
21
  # auth_seed: "" # env: GIT_FIT_STRAVA_WEB_AUTH_AUTH_SEED (base64 JWT)
22
+ # cookie_path: "" # default data/auth/strava/session.yml
22
23
 
23
24
  # garmin: # env: GIT_FIT_GARMIN_EMAIL
24
25
  # email: "" # env: GIT_FIT_GARMIN_PASSWORD
@@ -44,12 +45,14 @@ module GitFit
44
45
  # xoss: # env: GIT_FIT_XOSS_EMAIL
45
46
  # email: "" # env: GIT_FIT_XOSS_PASSWORD
46
47
  # password: ""
48
+ # token_path: "" # env: GIT_FIT_XOSS_TOKEN_PATH
49
+ # # default data/auth/xoss/tokens.json
47
50
 
48
51
  # xingzhe: # env: GIT_FIT_XINGZHE_EMAIL
49
52
  # email: "" # env: GIT_FIT_XINGZHE_PASSWORD
50
53
  # password: ""
51
54
  # session_path: "" # env: GIT_FIT_XINGZHE_SESSION_PATH
52
- # # default data/cache/xingzhe_session.json
55
+ # # default data/auth/xingzhe/session.json
53
56
 
54
57
  export:
55
58
  json:
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'time'
7
+ require 'tzinfo'
8
+
9
+ module GitFit
10
+ module Import
11
+ class Tbulu < Source::Base
12
+ DEFAULT_IMPORT_DIR = 'data/import/2bulu'
13
+
14
+ def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil)
15
+ super
16
+ @import_dir = config['import_dir'] || DEFAULT_IMPORT_DIR
17
+ @timezone_resolver = GitFit::Timezone::Resolver.new(config)
18
+ end
19
+
20
+ def before_call
21
+ return false unless File.directory?(@import_dir)
22
+ super
23
+ end
24
+
25
+ def authenticate
26
+ true
27
+ end
28
+
29
+ def call
30
+ return 0 unless before_call
31
+
32
+ files = scan_files
33
+ total = 0
34
+ files.each do |file_path|
35
+ break if time_expired? && total.positive?
36
+ r = process_one(file_path)
37
+ total += 1 if r
38
+ report_progress(total, files.size, File.basename(file_path)) if r
39
+ end
40
+
41
+ total
42
+ end
43
+
44
+ def pending_ids
45
+ []
46
+ end
47
+
48
+ def source_label
49
+ '2bulu'
50
+ end
51
+
52
+ def raw_dir
53
+ File.join('data', 'raw', '2bulu')
54
+ end
55
+
56
+ def std_dir
57
+ File.join('data', 'std', '2bulu')
58
+ end
59
+
60
+ private
61
+
62
+ def scan_files
63
+ Dir.glob(File.join(@import_dir, '*'), File::FNM_CASEFOLD).select do |f|
64
+ File.file?(f) && GitFit::Parser::Tbulu.sniff_format(File.binread(f))
65
+ end
66
+ end
67
+
68
+ def process_one(file_path)
69
+ content = File.binread(file_path)
70
+ format = GitFit::Parser::Tbulu.sniff_format(content)
71
+ return nil unless format
72
+
73
+ parser = GitFit::Parser::Tbulu.new
74
+ activities, points = parser.call_with_points(content)
75
+ return nil if activities.empty?
76
+
77
+ activity = activities.first
78
+ return nil if skip_type?(activity[:sport_category])
79
+
80
+ run_id = build_run_id(activity, points)
81
+ return nil if existing_run_id?(run_id)
82
+
83
+ ext = format == :tk ? '.2tk' : '.kml'
84
+ write_raw(content, run_id, ext)
85
+ write_std(activity, run_id, points)
86
+
87
+ activity[:source] = '2bulu'
88
+ activity[:run_id] = run_id
89
+ upsert_activity(activity)
90
+ activity
91
+ end
92
+
93
+ def build_run_id(activity, points)
94
+ date = extract_date_prefix(activity[:start_date])
95
+ "2bulu_#{date}_#{content_hash(points, activity)[0, 12]}"
96
+ end
97
+
98
+ def content_hash(points, activity)
99
+ point_part = points.map do |p|
100
+ t = p[3].respond_to?(:to_i) ? p[3].to_i : p[3]
101
+ [p[0].round(9), p[1].round(9), p[2].round(3), t].join('|')
102
+ end.join("\n")
103
+ sensor_part = [
104
+ activity[:steps].to_i,
105
+ activity[:calories].to_f.round(1),
106
+ activity[:total_up].to_f.round(1),
107
+ activity[:total_down].to_f.round(1),
108
+ ].join('|')
109
+ Digest::SHA256.hexdigest("#{point_part}\n#{sensor_part}")
110
+ end
111
+
112
+ def upsert_activity(activity)
113
+ # steps/total_up/total_down are hash-only signals, not DB columns
114
+ persistable = activity.reject { |k, _| %i[steps total_up total_down].include?(k) }
115
+ super(persistable)
116
+ end
117
+
118
+ def extract_date_prefix(start_date)
119
+ t = parse_start_time(start_date)
120
+ return '00000000' unless t
121
+ t.getutc.strftime('%Y%m%d')
122
+ end
123
+
124
+ def parse_start_time(start_date)
125
+ return nil unless start_date
126
+ start_date.respond_to?(:getutc) ? start_date : Time.parse(start_date.to_s)
127
+ rescue ArgumentError
128
+ nil
129
+ end
130
+
131
+ def write_raw(content, run_id, ext)
132
+ FileUtils.mkdir_p(raw_dir)
133
+ tmp = File.join(raw_dir, ".#{run_id}#{ext}.tmp")
134
+ final = File.join(raw_dir, "#{run_id}#{ext}")
135
+ File.binwrite(tmp, content)
136
+ File.rename(tmp, final)
137
+ end
138
+
139
+ def write_std(activity, run_id, points)
140
+ return unless points&.any?
141
+
142
+ std_data = points.map do |pt|
143
+ h = { latitude: pt[0], longitude: pt[1] }
144
+ h[:altitude] = pt[2] if pt[2]
145
+ h[:timestamp] = pt[3]&.iso8601 if pt[3]
146
+ h
147
+ end
148
+
149
+ natural_id = run_id.sub('2bulu_', '')
150
+ FileUtils.mkdir_p(std_dir)
151
+ tmp = File.join(std_dir, ".#{natural_id}.json.tmp")
152
+ final = File.join(std_dir, "#{natural_id}.json")
153
+ File.write(tmp, JSON.generate(std_data))
154
+ File.rename(tmp, final)
155
+
156
+ coords = points.map { |p| [p[0], p[1]] }
157
+ activity[:summary_polyline] = GitFit::Geo::Polyline.encode(coords)
158
+
159
+ first_pt = points.first
160
+ tz = @timezone_resolver.resolve(first_pt[0], first_pt[1])
161
+ return unless tz && activity[:start_date]
162
+ activity[:start_date_local] = utc_to_local(activity[:start_date], tz)
163
+ end
164
+
165
+ def existing_run_id?(run_id)
166
+ !@db[:activities].where(run_id: run_id).empty?
167
+ end
168
+
169
+ def utc_to_local(utc_iso8601, tz_name)
170
+ return utc_iso8601 unless utc_iso8601 && tz_name
171
+ t = Time.parse(utc_iso8601)
172
+ return utc_iso8601 unless t.utc?
173
+ tz = TZInfo::Timezone.get(tz_name)
174
+ local = tz.utc_to_local(t)
175
+ local.iso8601
176
+ rescue TZInfo::InvalidTimezoneIdentifier, ArgumentError
177
+ utc_iso8601
178
+ end
179
+ end
180
+ end
181
+ end
@@ -2,3 +2,4 @@
2
2
 
3
3
  require_relative 'import/local_file'
4
4
  require_relative 'import/apple_health'
5
+ require_relative 'import/tbulu'
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module GitFit
7
+ module Parser
8
+ class Tbulu < Base
9
+ MAGIC = '##tk'.b
10
+ MOD32 = 2**32
11
+ HOUR8 = 8 * 3600 * 1000
12
+
13
+ SPORT_TYPE_MAP = {
14
+ 3 => 'hike',
15
+ 18 => 'hike',
16
+ 24 => 'run',
17
+ }.freeze
18
+
19
+ def self.sniff_format(content)
20
+ return :tk if content.start_with?(MAGIC)
21
+ return :kml if content.lstrip.start_with?('<kml', '<?xml')
22
+
23
+ nil
24
+ end
25
+
26
+ def call(input)
27
+ activities, = call_with_points(input)
28
+ activities
29
+ end
30
+
31
+ def call_with_points(input)
32
+ content = input.is_a?(String) ? input.b : input
33
+ return [[], []] if content.nil? || content.empty?
34
+
35
+ format = sniff_format(content)
36
+ case format
37
+ when :tk then parse_2tk_full(content)
38
+ when :kml then parse_kml_full(content)
39
+ else
40
+ [[], []]
41
+ end
42
+ end
43
+
44
+ def extract_points(input)
45
+ _, points = call_with_points(input)
46
+ points
47
+ end
48
+
49
+ private
50
+
51
+ def sniff_format(content)
52
+ self.class.sniff_format(content)
53
+ end
54
+
55
+ # ── 2tk ──────────────────────────────────────────────────
56
+
57
+ def parse_2tk_full(content)
58
+ header = extract_2tk_header(content)
59
+ ti = header['trackinfo']
60
+ return [[], []] if ti.nil?
61
+
62
+ start_ms = ti['starttime']
63
+ return [[], []] unless start_ms
64
+
65
+ points = extract_2tk_points(content, header, start_ms)
66
+ return [[], points] if points.empty?
67
+
68
+ times = points.map { |p| p[3] }.compact
69
+ return [[], points] if times.empty?
70
+
71
+ sport = classify_sport(ti['tracktype'], header)
72
+ attrs = build_activity_attrs({
73
+ run_id: build_run_id('2bulu', SecureRandom.hex(8)),
74
+ name: ti['title'],
75
+ distance: total_distance(points),
76
+ moving_time: moving_time_from_points(points),
77
+ elapsed_time: elapsed_time_from_points(points),
78
+ sport_category: sport,
79
+ sport_type: ti['tracktype'],
80
+ start_date: times.min,
81
+ start_date_local: times.min,
82
+ location_country: nil,
83
+ summary_polyline: nil,
84
+ average_heartrate: nil,
85
+ elevation_gain: elevation_gain(points),
86
+ source: '2bulu',
87
+ })
88
+ attach_sensor_stats(attrs, ti)
89
+ [[attrs], points]
90
+ end
91
+
92
+ def parse_2tk(content)
93
+ attrs, = parse_2tk_full(content)
94
+ attrs
95
+ end
96
+
97
+ def attach_sensor_stats(attrs, track_info)
98
+ attrs[:steps] = track_info['steps']
99
+ attrs[:calories] = track_info['calorie']
100
+ attrs[:total_up] = track_info['totalup']
101
+ attrs[:total_down] = track_info['totaldown']
102
+ end
103
+
104
+ def extract_2tk_header(content)
105
+ pos = content.index('{')
106
+ return {} unless pos
107
+
108
+ depth = 0
109
+ json_end = nil
110
+ (pos...content.bytesize).each do |i|
111
+ case content.getbyte(i)
112
+ when 0x7b then depth += 1
113
+ when 0x7d then depth -= 1
114
+ end
115
+ json_end = i if depth.zero?
116
+ break if depth.zero?
117
+ end
118
+ return {} unless json_end
119
+
120
+ JSON.parse(content[pos..json_end])
121
+ rescue JSON::ParserError
122
+ {}
123
+ end
124
+
125
+ def extract_2tk_points(content, _header, start_ms)
126
+ pos = content.index('{')
127
+ return [] unless pos
128
+
129
+ depth = 0
130
+ json_end = nil
131
+ (pos...content.bytesize).each do |i|
132
+ case content.getbyte(i)
133
+ when 0x7b then depth += 1
134
+ when 0x7d then depth -= 1
135
+ end
136
+ json_end = i if depth.zero?
137
+ break if depth.zero?
138
+ end
139
+ return [] unless json_end
140
+
141
+ bin_start = json_end + 1
142
+ pcount = content[bin_start + 10, 4].unpack1('L<')
143
+ pts_raw = content[bin_start + 14, pcount * 36]
144
+ return [] unless pts_raw && pts_raw.bytesize == pcount * 36
145
+
146
+ times = []
147
+ coords = []
148
+ (0...pcount).each do |i|
149
+ off = i * 36
150
+ lat, lng = pts_raw[off, 16].unpack('E2')
151
+ alt = pts_raw[off + 16, 4].unpack1('l<')
152
+ t = pts_raw[off + 24, 4].unpack1('L<')
153
+ q = ((start_ms - t).to_f / MOD32).round
154
+ times << t + q * MOD32
155
+ coords << [lat, lng, alt]
156
+ end
157
+
158
+ delta = timezone_fix(times, start_ms)
159
+ times.map! { |t| t + delta }
160
+
161
+ times.each_with_index.map do |t, i|
162
+ c = coords[i]
163
+ [c[0], c[1], c[2], Time.at(t / 1000.0).utc, nil]
164
+ end
165
+ end
166
+
167
+ def timezone_fix(times, start_ms)
168
+ return 0 if times.empty?
169
+ (-2..2).each do |k|
170
+ cand = times[0] + k * HOUR8
171
+ return k * HOUR8 if cand >= start_ms - 30 * 60_000 && cand <= start_ms + HOUR8
172
+ end
173
+ 0
174
+ end
175
+
176
+ # ── KML ─────────────────────────────────────────────────
177
+
178
+ def parse_kml_full(content)
179
+ doc = parse_xml(content)
180
+ return [[], []] unless doc
181
+
182
+ ext = extract_extended_data(doc)
183
+ points = extract_kml_points(doc)
184
+ return [[], points] if points.empty?
185
+
186
+ times = points.map { |p| p[3] }.compact
187
+ return [[], points] if times.empty?
188
+ start_date = times.min
189
+
190
+ sport = classify_sport(nil, ext)
191
+ distance = ext['Mileage'].to_f
192
+ distance = ext['Distance'].to_f if distance.zero?
193
+
194
+ attrs = build_activity_attrs({
195
+ run_id: build_run_id('2bulu', SecureRandom.hex(8)),
196
+ name: doc_text(doc, '//Document/name'),
197
+ distance: distance,
198
+ moving_time: moving_time_from_points(points),
199
+ elapsed_time: elapsed_time_from_points(points),
200
+ sport_category: sport,
201
+ sport_type: sport_type_name(ext['SportTypeId']),
202
+ start_date: start_date,
203
+ start_date_local: start_date,
204
+ location_country: nil,
205
+ summary_polyline: nil,
206
+ average_heartrate: nil,
207
+ elevation_gain: elevation_gain(points),
208
+ source: '2bulu',
209
+ })
210
+ attach_kml_stats(attrs, ext)
211
+ [[attrs], points]
212
+ end
213
+
214
+ def parse_kml(content)
215
+ attrs, = parse_kml_full(content)
216
+ attrs
217
+ end
218
+
219
+ def attach_kml_stats(attrs, ext)
220
+ attrs[:steps] = ext['Step'].to_i
221
+ attrs[:calories] = ext['Calorie'].to_f
222
+ attrs[:total_up] = ext['ElevationGain'].to_f
223
+ attrs[:total_down] = ext['ElevationLoss'].to_f
224
+ end
225
+
226
+ def extract_kml_points(doc)
227
+ whens = doc.xpath('//when').map(&:text)
228
+ coords = doc.xpath('//coord').map { |c| c.text.strip.split.map(&:to_f) }
229
+
230
+ if whens.empty? || coords.empty?
231
+ whens = []
232
+ coords = []
233
+ doc.xpath('//coordinates').each do |cs|
234
+ cs.text.strip.split.each do |triplet|
235
+ c = triplet.split(',').map(&:to_f)
236
+ coords << c
237
+ whens << nil
238
+ end
239
+ end
240
+ end
241
+
242
+ whens.each_with_index.map do |w, i|
243
+ c = coords[i] || [0, 0, 0]
244
+ [c[1], c[0], c[2], w ? parse_time(w) : nil, nil]
245
+ end
246
+ end
247
+
248
+ def extract_extended_data(doc)
249
+ ext = {}
250
+ doc.xpath('//Data').each do |d|
251
+ name = d['name']
252
+ value = d.at_xpath('value')&.text
253
+ ext[name] = value if name && value
254
+ end
255
+ ext
256
+ end
257
+
258
+ def parse_xml(content)
259
+ require 'nokogiri'
260
+ doc = Nokogiri::XML(content)
261
+ doc.remove_namespaces!
262
+ doc
263
+ rescue Nokogiri::XML::SyntaxError
264
+ nil
265
+ end
266
+
267
+ def doc_text(doc, xpath)
268
+ doc.at_xpath(xpath)&.text&.strip
269
+ end
270
+
271
+ def parse_time(str)
272
+ return nil unless str
273
+ Time.parse(str)
274
+ rescue ArgumentError
275
+ nil
276
+ end
277
+
278
+ # ── sport classification ────────────────────────────────
279
+
280
+ TRACKTYPE_MAP = {
281
+ '爬山' => 'hike',
282
+ '徒步' => 'hike',
283
+ '跑步' => 'run',
284
+ '骑行' => 'ride',
285
+ }.freeze
286
+
287
+ def classify_sport(tracktype, data)
288
+ sport_type_id = data['SportTypeId'] if data.is_a?(Hash)
289
+ mapped = SPORT_TYPE_MAP[sport_type_id.to_i] if sport_type_id
290
+ return mapped if mapped
291
+
292
+ track_type_id = data['TrackTypeId'] if data.is_a?(Hash)
293
+ return 'hike' if track_type_id.to_i == 8
294
+
295
+ TRACKTYPE_MAP[tracktype.to_s] || normalize_sport_type(tracktype.to_s)
296
+ end
297
+
298
+ def sport_type_name(id)
299
+ return nil if id.nil? || id.to_i.zero?
300
+ SPORT_TYPE_MAP[id.to_i]
301
+ end
302
+ end
303
+ end
304
+ end
@@ -9,3 +9,4 @@ require_relative 'parser/base'
9
9
  require_relative 'parser/gpx'
10
10
  require_relative 'parser/tcx'
11
11
  require_relative 'parser/fit'
12
+ require_relative 'parser/tbulu'
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'base'
4
4
  require_relative '../fit/decoder'
5
+ require_relative '../auth/paths'
5
6
  require 'ostruct'
6
7
  require 'base64'
7
8
  require 'oauth'
@@ -29,12 +30,17 @@ module GitFit
29
30
  OAUTH_CONSUMER_URL = 'https://thegarth.s3.amazonaws.com/oauth_consumer.json'
30
31
 
31
32
  SSO_HEADERS = {
32
- 'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148',
33
+ 'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) ' \
34
+ 'AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148',
33
35
  'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
34
36
  'Accept-Language' => 'en-US,en;q=0.9',
35
37
  }.freeze
36
38
 
37
39
  def initialize(...)
40
+ if instance_of?(GarminBase)
41
+ raise NotImplementedError,
42
+ 'GarminBase is abstract — instantiate a concrete subclass (Garmin, GarminCN)'
43
+ end
38
44
  super
39
45
  @domain = 'garmin.com'
40
46
  @email = @config['email']
@@ -53,19 +59,18 @@ module GitFit
53
59
  def authenticate
54
60
  @oauth_consumer = fetch_oauth_consumer
55
61
 
56
- if @secret
57
- if parse_secret_string(@secret)
58
- if access_token_valid?
59
- @auth_method = 'secret'
62
+ if @secret && parse_secret_string(@secret)
63
+ if access_token_valid?
64
+ @auth_method = 'secret'
65
+ return true
66
+ end
67
+ begin
68
+ if refresh_oauth2_via_oauth1
69
+ @auth_method = 'refreshed'
60
70
  return true
61
71
  end
62
- begin
63
- if refresh_oauth2_via_oauth1
64
- @auth_method = 'refreshed'
65
- return true
66
- end
67
- rescue AuthError
68
- end
72
+ rescue AuthError => e
73
+ warn e.message
69
74
  end
70
75
  end
71
76
 
@@ -144,6 +149,14 @@ module GitFit
144
149
  attrs
145
150
  end
146
151
 
152
+ def self.source_prefix
153
+ raise NotImplementedError, "#{self} must implement source_prefix"
154
+ end
155
+
156
+ def source_prefix
157
+ self.class.source_prefix
158
+ end
159
+
147
160
  private
148
161
 
149
162
  def access_token_valid?
@@ -164,7 +177,7 @@ module GitFit
164
177
  oauth_token: o1['oauth_token'],
165
178
  oauth_token_secret: o1['oauth_token_secret'],
166
179
  mfa_token: o1['mfa_token'],
167
- domain: @domain
180
+ domain: @domain,
168
181
  )
169
182
  @access_token = o2
170
183
  true
@@ -190,7 +203,7 @@ module GitFit
190
203
  return unless File.exist?(path)
191
204
 
192
205
  cfg = YAML.safe_load(File.read(path)) || {}
193
- source = @domain == 'garmin.cn' ? 'garmin_cn' : 'garmin'
206
+ source = source_prefix
194
207
  cfg['sync'] ||= {}
195
208
  cfg['sync'][source] ||= {}
196
209
  cfg['sync'][source]['secret'] = @generated_secret
@@ -222,8 +235,8 @@ module GitFit
222
235
  save_tokens
223
236
  true
224
237
  rescue StandardError => e
225
- raise AuthError, "Garmin OAuth refresh failed: #{e.message}" +
226
- " — #{reauth_hint}"
238
+ raise AuthError, "Garmin OAuth refresh failed: #{e.message}" \
239
+ " — #{reauth_hint}"
227
240
  end
228
241
 
229
242
  def fetch_oauth_consumer
@@ -258,16 +271,14 @@ module GitFit
258
271
  mfaVerificationCode: mfa_code,
259
272
  rememberMyBrowser: false,
260
273
  reconsentList: [],
261
- mfaSetup: false
274
+ mfaSetup: false,
262
275
  )
263
276
  resp = sso_post(sso_base, '/mobile/api/mfa/verifyCode', login_params, mfa_body, jar)
264
277
  body = JSON.parse(resp.body)
265
278
  status = body.dig('responseStatus', 'type')
266
279
  end
267
280
 
268
- unless status == 'SUCCESSFUL'
269
- raise "Garmin login failed (#{body.dig("responseStatus", "message") || status})"
270
- end
281
+ raise "Garmin login failed (#{body.dig('responseStatus', 'message') || status})" unless status == 'SUCCESSFUL'
271
282
 
272
283
  body['serviceTicketId']
273
284
  end
@@ -314,7 +325,8 @@ module GitFit
314
325
  def fetch_oauth1_token(ticket)
315
326
  base_url = "https://connectapi.#{@domain}/oauth-service/oauth/"
316
327
  login_url = "https://mobile.integration.#{@domain}/gcm/android"
317
- url = "#{base_url}preauthorized?ticket=#{CGI.escape(ticket)}&login-url=#{CGI.escape(login_url)}&accepts-mfa-tokens=true"
328
+ url = "#{base_url}preauthorized?ticket=#{CGI.escape(ticket)}" \
329
+ "&login-url=#{CGI.escape(login_url)}&accepts-mfa-tokens=true"
318
330
 
319
331
  oauth_consumer = OAuth::Consumer.new(
320
332
  @oauth_consumer['consumer_key'], @oauth_consumer['consumer_secret'],
@@ -328,7 +340,7 @@ module GitFit
328
340
  req['User-Agent'] = 'com.garmin.android.apps.connectmobile'
329
341
  oauth_consumer.sign!(req, nil)
330
342
 
331
- resp = retry_on_429(3, 5) { send_http(URI(url), req) }
343
+ resp = retry_on_rate_limit(3, 5) { send_http(URI(url), req) }
332
344
  raise "OAuth1 error: #{resp.code}" unless resp.code.to_i == 200
333
345
 
334
346
  parsed = CGI.parse(resp.body).transform_keys(&:to_s).transform_values(&:first)
@@ -337,7 +349,7 @@ module GitFit
337
349
  oauth_token: parsed['oauth_token'],
338
350
  oauth_token_secret: parsed['oauth_token_secret'],
339
351
  mfa_token: parsed['mfa_token'],
340
- domain: @domain
352
+ domain: @domain,
341
353
  )
342
354
  end
343
355
 
@@ -359,7 +371,7 @@ module GitFit
359
371
  req.body = URI.encode_www_form(body_params)
360
372
  oauth_consumer.sign!(req, token)
361
373
 
362
- resp = retry_on_429 { send_http(URI(url), req) }
374
+ resp = retry_on_rate_limit { send_http(URI(url), req) }
363
375
  raise "OAuth2 exchange error: #{resp.code}" unless resp.code.to_i == 200
364
376
 
365
377
  result = JSON.parse(resp.body)
@@ -374,7 +386,7 @@ module GitFit
374
386
 
375
387
  def existing_run_ids
376
388
  @db[:activities].where(source: source_prefix).select_map(:run_id)
377
- .map { |id| id.sub("#{source_prefix}_", '') }
389
+ .map { |id| id.sub("#{source_prefix}_", '') }
378
390
  end
379
391
 
380
392
  def fetch_activity_list(start, limit)
@@ -388,9 +400,9 @@ module GitFit
388
400
  def api_get(path)
389
401
  uri = URI("#{connectapi_base}#{path}")
390
402
  req = Net::HTTP::Get.new(uri)
391
- req['Authorization'] = "Bearer #{@access_token["access_token"]}"
403
+ req['Authorization'] = "Bearer #{@access_token['access_token']}"
392
404
  req['User-Agent'] = 'GCM-iOS-5.22.1.4'
393
- resp = retry_on_429 { send_http(uri, req) }
405
+ resp = retry_on_rate_limit { send_http(uri, req) }
394
406
  return nil unless resp.code.to_i == 200
395
407
  JSON.parse(resp.body)
396
408
  end
@@ -408,14 +420,14 @@ module GitFit
408
420
  http.start { |h| h.request(request) }
409
421
  end
410
422
 
411
- def retry_on_429(max_retries = 3, base_delay = 2)
423
+ def retry_on_rate_limit(max_retries = 3, base_delay = 2)
412
424
  retries = 0
413
425
  loop do
414
426
  resp = yield
415
427
  return resp unless resp.code.to_i == 429 && retries < max_retries
416
428
 
417
429
  retries += 1
418
- sleep(base_delay * (2 ** retries))
430
+ sleep(base_delay * (2**retries))
419
431
  end
420
432
  end
421
433
 
@@ -424,15 +436,17 @@ module GitFit
424
436
  end
425
437
 
426
438
  def resolve_local_time(start_utc, activity_id, polyline = nil)
427
- lat = lng = nil
439
+ nil
428
440
 
429
441
  lat, lng = first_gps_from_l2(activity_id)
430
442
 
431
443
  if lat.nil? && polyline
432
- decoded = GitFit::Geo::Polyline.decode(polyline) rescue nil
433
- if decoded && decoded.any?
434
- lat, lng = decoded.first
444
+ decoded = begin
445
+ GitFit::Geo::Polyline.decode(polyline)
446
+ rescue StandardError
447
+ nil
435
448
  end
449
+ lat, lng = decoded.first if decoded&.any?
436
450
  end
437
451
 
438
452
  tz_string = tz_resolver.resolve(lat, lng)
@@ -450,11 +464,11 @@ module GitFit
450
464
  l2 = JSON.parse(File.read(l2_path))
451
465
  return [nil, nil] unless l2.is_a?(Array) && l2.any?
452
466
 
453
- first = l2.find { |r|
467
+ first = l2.find do |r|
454
468
  lat = r['positionLat']
455
469
  lng = r['positionLong']
456
470
  lat && lng && lat.to_f != 0.0 && lng.to_f != 0.0
457
- }
471
+ end
458
472
  return [nil, nil] unless first
459
473
 
460
474
  [first['positionLat'].to_f, first['positionLong'].to_f]
@@ -462,10 +476,6 @@ module GitFit
462
476
  [nil, nil]
463
477
  end
464
478
 
465
- def source_prefix
466
- 'garmin'
467
- end
468
-
469
479
  def reauth_hint
470
480
  "Re-auth: set sync.#{source_prefix}.secret in config/config.yml"
471
481
  end
@@ -560,7 +570,7 @@ module GitFit
560
570
  oauth_token: o1['oauth_token'],
561
571
  oauth_token_secret: o1['oauth_token_secret'],
562
572
  mfa_token: o1['mfa_token'],
563
- domain: @domain
573
+ domain: @domain,
564
574
  )
565
575
  @access_token = data['oauth2']
566
576
  @saved_expires_at = @access_token&.dig('expires_at')
@@ -571,7 +581,7 @@ module GitFit
571
581
  end
572
582
 
573
583
  def token_path
574
- @config['token_path'] || File.join('data', 'auth', "garmin_#{@domain.tr(".", "_")}_tokens.json")
584
+ @config['token_path'] || GitFit::Auth.file(source_prefix, 'tokens.json')
575
585
  end
576
586
 
577
587
  FIT_EPOCH = 631_065_600
@@ -598,9 +608,9 @@ module GitFit
598
608
  def download_fit_raw(activity_id)
599
609
  uri = URI("#{connectapi_base}/download-service/files/activity/#{activity_id}")
600
610
  req = Net::HTTP::Get.new(uri)
601
- req['Authorization'] = "Bearer #{@access_token.fetch("access_token")}"
611
+ req['Authorization'] = "Bearer #{@access_token.fetch('access_token')}"
602
612
  req['User-Agent'] = 'GCM-iOS-5.22.1.4'
603
- resp = retry_on_429 { send_http(uri, req) }
613
+ resp = retry_on_rate_limit { send_http(uri, req) }
604
614
  return false unless resp.code.to_i == 200
605
615
 
606
616
  zip_data = resp.body
@@ -652,13 +662,13 @@ module GitFit
652
662
  nil
653
663
  end
654
664
 
655
- def write_standardized_json(l2, activity_id)
656
- return unless l2
665
+ def write_standardized_json(l2_data, activity_id)
666
+ return unless l2_data
657
667
 
658
668
  dir = std_dir
659
669
  FileUtils.mkdir_p(dir)
660
670
  tmp = File.join(dir, ".#{activity_id}.json.tmp")
661
- File.write(tmp, JSON.generate(l2))
671
+ File.write(tmp, JSON.generate(l2_data))
662
672
  File.rename(tmp, std_path(activity_id))
663
673
  end
664
674
  end
@@ -5,6 +5,10 @@ require_relative 'garmin_base'
5
5
  module GitFit
6
6
  module Sync
7
7
  class GarminBaseDI < GarminBase
8
+ def self.source_prefix
9
+ 'garmin'
10
+ end
11
+
8
12
  DI_CLIENT_IDS = %w[
9
13
  GARMIN_CONNECT_MOBILE_ANDROID_DI_2025Q2
10
14
  GARMIN_CONNECT_MOBILE_ANDROID_DI_2024Q4
@@ -131,10 +135,10 @@ module GitFit
131
135
  req.body = URI.encode_www_form(
132
136
  grant_type: 'refresh_token',
133
137
  client_id: client_id,
134
- refresh_token: @access_token['refresh_token']
138
+ refresh_token: @access_token['refresh_token'],
135
139
  )
136
140
 
137
- resp = retry_on_429 { send_http(uri, req) }
141
+ resp = retry_on_rate_limit { send_http(uri, req) }
138
142
  unless resp.code.to_i == 200
139
143
  msg = "DI refresh error: #{resp.code}"
140
144
  if resp.code.to_i == 400
@@ -8,6 +8,10 @@ module GitFit
8
8
  register_adapter
9
9
  register_config :email, :password, :secret, :token_path
10
10
 
11
+ def self.source_prefix
12
+ 'garmin_cn'
13
+ end
14
+
11
15
  def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil)
12
16
  super
13
17
  @domain = 'garmin.cn'
@@ -21,10 +25,6 @@ module GitFit
21
25
  def std_dir
22
26
  File.join('data', 'std', 'garmin_cn')
23
27
  end
24
-
25
- def source_prefix
26
- 'garmin_cn'
27
- end
28
28
  end
29
29
  end
30
30
  end
@@ -4,15 +4,21 @@ require_relative 'garmin_base_di'
4
4
 
5
5
  module GitFit
6
6
  module Sync
7
- class Garmin < GarminBaseDI
7
+ class GarminCom < GarminBaseDI
8
8
  register_adapter
9
- register_config :email, :password, :secret, :auth_seed, :token_path
10
9
  config_key 'garmin'
10
+ register_config :email, :password, :secret, :auth_seed, :token_path
11
11
 
12
12
  def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil)
13
13
  super
14
14
  @domain = 'garmin.com'
15
15
  end
16
+
17
+ protected
18
+
19
+ def source_label
20
+ 'Garmin'
21
+ end
16
22
  end
17
23
  end
18
24
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'base'
4
+ require_relative '../auth/paths'
4
5
  require 'fileutils'
5
6
  require 'openssl'
6
7
  require 'tzinfo'
@@ -32,7 +33,7 @@ module GitFit
32
33
  super
33
34
  @email = @config['email']
34
35
  @password = @config['password']
35
- @session_path = @config['session_path'] || File.join('data', 'cache', 'xingzhe_session.json')
36
+ @session_path = @config['session_path'] || GitFit::Auth.file('xingzhe', 'session.json')
36
37
  end
37
38
 
38
39
  def before_call
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'base'
4
+ require_relative '../auth/paths'
4
5
  require 'fileutils'
5
6
  require 'tzinfo'
6
7
 
@@ -32,7 +33,7 @@ module GitFit
32
33
  super
33
34
  @email = @config['email']
34
35
  @password = @config['password']
35
- @token_path = @config['token_path'] || File.join('data', 'auth', 'xoss_tokens.json')
36
+ @token_path = @config['token_path'] || GitFit::Auth.file('xoss', 'tokens.json')
36
37
  end
37
38
 
38
39
  def before_call
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GitFit
4
- VERSION = '0.11.3'
4
+ VERSION = '0.13.0'
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.11.3
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lax
@@ -252,6 +252,7 @@ files:
252
252
  - lib/git_fit/auth/garmin/strategy_a.rb
253
253
  - lib/git_fit/auth/garmin/strategy_b.rb
254
254
  - lib/git_fit/auth/garmin_token.rb
255
+ - lib/git_fit/auth/paths.rb
255
256
  - lib/git_fit/auth/strava.rb
256
257
  - lib/git_fit/cli.rb
257
258
  - lib/git_fit/cli/checkpointable.rb
@@ -294,6 +295,7 @@ files:
294
295
  - lib/git_fit/import.rb
295
296
  - lib/git_fit/import/apple_health.rb
296
297
  - lib/git_fit/import/local_file.rb
298
+ - lib/git_fit/import/tbulu.rb
297
299
  - lib/git_fit/install/actions.rb
298
300
  - lib/git_fit/install/actions/db-store/restore.yml.erb
299
301
  - lib/git_fit/install/actions/db-store/save.yml.erb
@@ -301,6 +303,7 @@ files:
301
303
  - lib/git_fit/parser/base.rb
302
304
  - lib/git_fit/parser/fit.rb
303
305
  - lib/git_fit/parser/gpx.rb
306
+ - lib/git_fit/parser/tbulu.rb
304
307
  - lib/git_fit/parser/tcx.rb
305
308
  - lib/git_fit/privacy/polyline_filter.rb
306
309
  - lib/git_fit/source.rb
@@ -310,10 +313,10 @@ files:
310
313
  - lib/git_fit/std/resolver.rb
311
314
  - lib/git_fit/strava_web/file_check.rb
312
315
  - lib/git_fit/sync/base.rb
313
- - lib/git_fit/sync/garmin.rb
314
316
  - lib/git_fit/sync/garmin_base.rb
315
317
  - lib/git_fit/sync/garmin_base_di.rb
316
318
  - lib/git_fit/sync/garmin_cn.rb
319
+ - lib/git_fit/sync/garmin_com.rb
317
320
  - lib/git_fit/sync/igpsport.rb
318
321
  - lib/git_fit/sync/keep.rb
319
322
  - lib/git_fit/sync/lorem.rb