git-fit 0.11.2 → 0.12.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: 6128a0ade228a52d4521a69f4cf036c2edd0507db418bdab7a27439c7675463e
4
- data.tar.gz: 889d4c1a82660bd67a5e00344e66bd05ce36071926cde030dda1a88f6272008c
3
+ metadata.gz: d2f295d660e818e0baf5fcd3a624aabff068288c1cc93cbb4a661b046fd58a3c
4
+ data.tar.gz: ee63230c3e1ae93aa7ef1bec736b5011ccbfcf013b35e70c39afcc38acbe2c06
5
5
  SHA512:
6
- metadata.gz: ca1456122456538768205397ff39ac128f1cdec115dcbeacd9c37874fa62bae20011ed4b98560c68d762f1a2fb80ab8ec5e42e8a5539713ed66f73a6f7eec079
7
- data.tar.gz: 0edbddf20214183b02caa9dab86d17c20ae7f87a5cdfdf450e939c45a32bd23acc5c5a30209293b53c1def43e85ed090920804123aa21e5cdce9e604e6d54777
6
+ metadata.gz: 6498333581ad0d68044f77c8de1ffddb1a3a4f6634a92f99081df76e5dda26fc7eb25eeb0eaf8ac0b04637db40257d43ade12c2325a6ebdec63cb95430964113
7
+ data.tar.gz: edda04a53ebaadd13d4bd15c325251bf4ab0f7b500917fe323c52968ca55303162f5fe95a62f4baaf2734ff8e9dc278c1786e44506b32fedcbb9cad43dbfe5c2
@@ -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
@@ -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'
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'garmin_base'
4
- require 'open3'
5
4
 
6
5
  module GitFit
7
6
  module Sync
@@ -157,44 +156,6 @@ module GitFit
157
156
  save_tokens
158
157
  true
159
158
  end
160
-
161
- # @deprecated Kept for local script reference only.
162
- # email+password SSO strategies are not called by `authenticate`
163
- # because garmin.com DI auth requires MFA, handled by
164
- # git fit auth garmin outside Ruby.
165
- #
166
- # Note: method naming (strategy_a = curl_cffi, strategy_b = Playwright) is
167
- # historical. In git fit auth garmin, Playwright runs first (cookies
168
- # persist 365d → no repeat MFA), curl_cffi is the fallback.
169
- def strategy_a_login
170
- script = File.expand_path('../../../scripts/garmin_auth.py', __dir__)
171
- return nil unless File.exist?(script)
172
-
173
- stdout, stderr, status = Open3.capture3(
174
- {'SYNC__GARMIN__EMAIL' => @email, 'SYNC__GARMIN__PASSWORD' => @password, 'SYNC__GARMIN__DOMAIN' => @domain},
175
- 'python3', script
176
- )
177
- warn stderr unless stderr.empty?
178
- JSON.parse(stdout) if status.success?
179
- rescue StandardError => e
180
- puts "Strategy A (curl_cffi) failed: #{e.message}"
181
- nil
182
- end
183
-
184
- def strategy_b_login
185
- script = File.expand_path('../../../scripts/garmin_auth_playwright.py', __dir__)
186
- return nil unless File.exist?(script)
187
-
188
- stdout, stderr, status = Open3.capture3(
189
- {'SYNC__GARMIN__EMAIL' => @email, 'SYNC__GARMIN__PASSWORD' => @password, 'SYNC__GARMIN__DOMAIN' => @domain},
190
- 'python3', script
191
- )
192
- warn stderr unless stderr.empty?
193
- JSON.parse(stdout) if status.success?
194
- rescue StandardError => e
195
- puts "Strategy B (Playwright) failed: #{e.message}"
196
- nil
197
- end
198
159
  end
199
160
  end
200
161
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GitFit
4
- VERSION = '0.11.2'
4
+ VERSION = '0.12.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.2
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lax
@@ -294,6 +294,7 @@ files:
294
294
  - lib/git_fit/import.rb
295
295
  - lib/git_fit/import/apple_health.rb
296
296
  - lib/git_fit/import/local_file.rb
297
+ - lib/git_fit/import/tbulu.rb
297
298
  - lib/git_fit/install/actions.rb
298
299
  - lib/git_fit/install/actions/db-store/restore.yml.erb
299
300
  - lib/git_fit/install/actions/db-store/save.yml.erb
@@ -301,6 +302,7 @@ files:
301
302
  - lib/git_fit/parser/base.rb
302
303
  - lib/git_fit/parser/fit.rb
303
304
  - lib/git_fit/parser/gpx.rb
305
+ - lib/git_fit/parser/tbulu.rb
304
306
  - lib/git_fit/parser/tcx.rb
305
307
  - lib/git_fit/privacy/polyline_filter.rb
306
308
  - lib/git_fit/source.rb