git-fit 0.10.3 → 0.10.5

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.
@@ -7,10 +7,11 @@ module GitFit
7
7
  class JSON
8
8
  PLACEHOLDER_POLYLINE = 'gqqrFurkeU??'
9
9
 
10
- def initialize(db:, output:, activity_filter: nil)
10
+ def initialize(db:, output:, activity_filter: nil, tracks_output: nil)
11
11
  @db = db
12
12
  @output = output
13
13
  @activity_filter = activity_filter
14
+ @tracks_output = tracks_output
14
15
  end
15
16
 
16
17
  def call
@@ -24,23 +25,40 @@ module GitFit
24
25
 
25
26
  group_members_cache = {}
26
27
 
28
+ tracks = @tracks_output ? {} : nil
29
+
27
30
  formatted = activities.map do |a|
28
31
  gid = run_id_to_group[a[:run_id]]
29
32
  if gid
30
33
  group_members_cache[gid] ||= @db[:dedup_group_members].where(group_id: gid).all
31
- composite_activity(a, gid, group_members_cache[gid])
34
+ composite_activity(a, gid, group_members_cache[gid], tracks)
32
35
  else
33
- format_activity(a)
36
+ format_activity(a, tracks)
34
37
  end
35
38
  end
36
39
 
37
40
  File.write(@output, ::JSON.pretty_generate(formatted))
41
+ File.write(@tracks_output, ::JSON.generate(tracks)) if @tracks_output
38
42
  formatted.size
39
43
  end
40
44
 
41
45
  private
42
46
 
43
- def format_activity(a)
47
+ def extract_polyline(a)
48
+ return nil if a[:source] == 'keep' && a[:summary_polyline] == PLACEHOLDER_POLYLINE
49
+
50
+ a[:summary_polyline]
51
+ end
52
+
53
+ def pick_best_polyline(group_activities)
54
+ candidates = group_activities.filter_map { |a| extract_polyline(a) }
55
+ candidates.max_by { |p| p.to_s.length }
56
+ end
57
+
58
+ def format_activity(a, tracks = nil)
59
+ polyline = extract_polyline(a)
60
+ tracks[a[:run_id]] = polyline if tracks && polyline
61
+
44
62
  base = {
45
63
  run_id: a[:run_id],
46
64
  name: a[:name],
@@ -51,8 +69,7 @@ module GitFit
51
69
  start_date: a[:start_date],
52
70
  start_date_local: a[:start_date_local],
53
71
  location_country: a[:location_country],
54
- summary_polyline: a[:source] == 'keep' && a[:summary_polyline] == PLACEHOLDER_POLYLINE ?
55
- nil : a[:summary_polyline],
72
+ summary_polyline: tracks ? nil : polyline,
56
73
  average_heartrate: a[:average_heartrate] && a[:average_heartrate] != 0 ? a[:average_heartrate] : nil,
57
74
  max_heartrate: a[:max_heartrate],
58
75
  average_cadence: a[:average_cadence],
@@ -71,7 +88,7 @@ module GitFit
71
88
  base
72
89
  end
73
90
 
74
- def composite_activity(activity, group_id, members)
91
+ def composite_activity(activity, group_id, members, tracks = nil)
75
92
  run_ids = members.map { |m| m[:run_id] }
76
93
  group_activities = @db[:activities].where(run_id: run_ids).all
77
94
 
@@ -85,11 +102,17 @@ module GitFit
85
102
  end
86
103
  result
87
104
  else
88
- members.first&.slice(:run_id, :distance, :moving_time, :elapsed_time,
105
+ composite = members.first&.slice(:run_id, :distance, :moving_time, :elapsed_time,
89
106
  :average_heartrate, :max_heartrate, :elevation_gain,
90
107
  :average_speed, :average_cadence, :max_cadence) || {}
108
+ composite[:summary_polyline] = pick_best_polyline(group_activities)
109
+ composite
91
110
  end
92
111
 
112
+ polyline = composite[:summary_polyline]
113
+ tracks[activity[:run_id]] = polyline if tracks && polyline
114
+ composite[:summary_polyline] = tracks ? nil : polyline
115
+
93
116
  composite[:run_id] = activity[:run_id]
94
117
  composite[:source] = activity[:source]
95
118
  composite[:sport_type] = activity[:sport_type]
@@ -8,5 +8,6 @@ end
8
8
  require_relative 'export/defaults'
9
9
  require_relative 'export/json'
10
10
  require_relative 'export/csv'
11
+ require_relative 'export/gpx'
11
12
  require_relative 'export/calculator'
12
13
  require_relative 'export/stats'
data/lib/git_fit/fit.rb CHANGED
@@ -1,3 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ module GitFit
4
+ module FIT
5
+ FIT_EPOCH = 631_065_600
6
+ end
7
+ end
8
+
3
9
  require_relative 'fit/decoder'
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'zlib'
5
+ require 'openssl'
6
+ require 'json'
7
+ require 'time'
8
+ require_relative '../geo/coord_transform'
9
+
10
+ module GitFit
11
+ module Source
12
+ module Keep
13
+ PLACEHOLDER_POLYLINE = 'gqqrFurkeU??'
14
+
15
+ AES_KEY = Base64.decode64('NTZmZTU5OzgyZzpkODczYw==').freeze
16
+ AES_IV = Base64.decode64('MjM0Njg5MjQzMjkyMDMwMA==').freeze
17
+
18
+ TIMESTAMP_THRESHOLD = 3_600_000
19
+ HR_THRESHOLD = 100
20
+
21
+ module_function
22
+
23
+ def decode_geo_points(geo_data)
24
+ return nil unless geo_data
25
+ raw = Base64.decode64(geo_data)
26
+ decrypted = aes_decrypt(raw)
27
+ decompressed = zlib_inflate(decrypted)
28
+ JSON.parse(decompressed)
29
+ rescue StandardError
30
+ nil
31
+ end
32
+
33
+ def decode_hr_data(hr_data)
34
+ return nil unless hr_data
35
+ raw = Base64.decode64(hr_data)
36
+ decompressed = zlib_inflate(raw)
37
+ JSON.parse(decompressed)
38
+ rescue StandardError
39
+ nil
40
+ end
41
+
42
+ def aes_decrypt(data)
43
+ cipher = OpenSSL::Cipher.new('AES-128-CBC')
44
+ cipher.decrypt
45
+ cipher.key = AES_KEY
46
+ cipher.iv = AES_IV
47
+ cipher.update(data) + cipher.final
48
+ end
49
+
50
+ def zlib_inflate(data)
51
+ [-Zlib::MAX_WBITS, Zlib::MAX_WBITS, Zlib::MAX_WBITS + 16].each do |wbits|
52
+ inflater = Zlib::Inflate.new(wbits)
53
+ result = inflater.inflate(data)
54
+ begin
55
+ inflater.finish
56
+ rescue StandardError
57
+ nil
58
+ end
59
+ inflater.close
60
+ return result
61
+ rescue Zlib::DataError
62
+ next
63
+ end
64
+ raise Zlib::DataError, 'cannot inflate data'
65
+ end
66
+
67
+ def compute_wgs_points(payload, run_data)
68
+ geo_points = payload[:geo_points]
69
+ hr_data = payload[:hr_data]
70
+ return [] unless geo_points&.any?
71
+
72
+ start_time = run_data['startTime']
73
+ use_absolute_ts = geo_points.first['timestamp'].to_i > TIMESTAMP_THRESHOLD
74
+ effective_start = use_absolute_ts ? 0 : start_time
75
+
76
+ geo_points.map do |p|
77
+ lat, lng = Geo::CoordTransform.gcj02_to_wgs84_exact(p['latitude'], p['longitude'])
78
+ ts = p['timestamp'] || p['unixTimestamp']
79
+ pt_time = ts ? Time.at(effective_start / 1000.0 + ts.to_i / 10.0).utc : nil
80
+ hr = find_nearest_hr(hr_data, ts, start_time)
81
+ [lat, lng, p['altitude'], pt_time, hr]
82
+ end
83
+ end
84
+
85
+ def find_nearest_hr(hr_data, geo_ts, start_time, threshold: HR_THRESHOLD)
86
+ return nil unless hr_data&.any? && geo_ts
87
+
88
+ normalize = lambda { |ts|
89
+ if ts > TIMESTAMP_THRESHOLD
90
+ ts - start_time / 100
91
+ else
92
+ ts
93
+ end
94
+ }
95
+
96
+ target = normalize.call(geo_ts)
97
+
98
+ nearest = nil
99
+ min_diff = Float::INFINITY
100
+
101
+ hr_data.each do |item|
102
+ ts = item['timestamp']
103
+ next unless ts
104
+
105
+ diff = (normalize.call(ts) - target).abs
106
+ if diff <= threshold && diff < min_diff
107
+ nearest = item
108
+ min_diff = diff
109
+ end
110
+ end
111
+
112
+ hr = nearest&.dig('beatsPerMinute')
113
+ hr&.positive? ? hr : nil
114
+ end
115
+ end
116
+ end
117
+ end
@@ -7,5 +7,6 @@ end
7
7
 
8
8
  require_relative 'util/tally'
9
9
  require_relative 'source/base'
10
+ require_relative 'source/keep'
10
11
 
11
12
  GitFit::Source::Tally = GitFit::Util::Tally
@@ -235,6 +235,17 @@ module GitFit
235
235
  .select_map(:run_id)
236
236
  .map { |rid| rid.sub(/\A#{Regexp.escape(prefix)}/, '') }
237
237
  end
238
+
239
+ def normalize_fit_timestamps(points)
240
+ points.map do |pt|
241
+ ts = pt['timestamp']
242
+ next pt unless ts.is_a?(Numeric)
243
+
244
+ pt = pt.dup
245
+ pt['timestamp'] = Time.at(FIT::FIT_EPOCH + ts).utc.iso8601
246
+ pt
247
+ end
248
+ end
238
249
  end
239
250
  end
240
251
  end
@@ -140,7 +140,7 @@ module GitFit
140
140
  FileUtils.mkdir_p(dir)
141
141
  tmp = File.join(dir, ".#{ride_id}.json.tmp")
142
142
  final = File.join(dir, "#{ride_id}.json")
143
- File.write(tmp, JSON.generate(wgs_points))
143
+ File.write(tmp, JSON.generate(normalize_fit_timestamps(wgs_points)))
144
144
  File.rename(tmp, final)
145
145
  end
146
146
 
@@ -1,9 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'base'
4
- require 'base64'
5
- require 'zlib'
6
- require 'openssl'
7
4
  require 'time'
8
5
  require 'tzinfo'
9
6
 
@@ -14,11 +11,6 @@ module GitFit
14
11
  STATS_URL = 'https://api.gotokeep.com/pd/v3/stats/detail?dateUnit=all&type=%{sport}&lastDate=%{last_date}'
15
12
  LOG_URL = 'https://api.gotokeep.com/pd/v3/%{sport}log/%{run_id}'
16
13
 
17
- AES_KEY = Base64.decode64('NTZmZTU5OzgyZzpkODczYw==')
18
- AES_IV = Base64.decode64('MjM0Njg5MjQzMjkyMDMwMA==')
19
-
20
- PLACEHOLDER_POLYLINE = 'gqqrFurkeU??'
21
-
22
14
  KEEP_TYPES = %w[running hiking cycling].freeze
23
15
  KEEP_RAW_EXT = 'json'
24
16
 
@@ -38,9 +30,6 @@ module GitFit
38
30
  'mountaineering' => 'Hiking',
39
31
  }.freeze
40
32
 
41
- TIMESTAMP_THRESHOLD = 3_600_000
42
- HR_THRESHOLD = 100
43
-
44
33
  register_adapter
45
34
  register_config :phone, :password
46
35
 
@@ -231,7 +220,7 @@ module GitFit
231
220
  heartRates: payload[:hr_data],
232
221
  }, keep_id)
233
222
 
234
- wgs_points = compute_wgs_points(payload, run_data)
223
+ wgs_points = Source::Keep.compute_wgs_points(payload, run_data)
235
224
  write_standardized_json(wgs_points, payload, run_data, keep_id)
236
225
 
237
226
  attrs = build_activity_from_wgs(run_data, wgs_points)
@@ -273,35 +262,17 @@ module GitFit
273
262
  else
274
263
  payload = { geo_points: raw['geoPoints'], hr_data: raw['heartRates'] }
275
264
  run_data = raw['meta']
276
- compute_wgs_points(payload, run_data)
265
+ Source::Keep.compute_wgs_points(payload, run_data)
277
266
  end
278
267
  end
279
268
 
280
269
  def decrypt_raw_payload(run_data)
281
270
  {
282
- geo_points: decode_geo_points(run_data['geoPoints']),
283
- hr_data: decode_hr_data(run_data.dig('heartRate', 'heartRates')),
271
+ geo_points: Source::Keep.decode_geo_points(run_data['geoPoints']),
272
+ hr_data: Source::Keep.decode_hr_data(run_data.dig('heartRate', 'heartRates')),
284
273
  }
285
274
  end
286
275
 
287
- def compute_wgs_points(payload, run_data)
288
- geo_points = payload[:geo_points]
289
- hr_data = payload[:hr_data]
290
- return [] unless geo_points&.any?
291
-
292
- start_time = run_data['startTime']
293
- use_absolute_ts = geo_points.first['timestamp'].to_i > TIMESTAMP_THRESHOLD
294
- effective_start = use_absolute_ts ? 0 : start_time
295
-
296
- geo_points.map do |p|
297
- lat, lng = Geo::CoordTransform.gcj02_to_wgs84_exact(p['latitude'], p['longitude'])
298
- ts = p['timestamp'] || p['unixTimestamp']
299
- pt_time = ts ? Time.at(effective_start / 1000.0 + ts.to_i / 10.0).utc : nil
300
- hr = find_nearest_hr(hr_data, ts, start_time)
301
- [lat, lng, p['altitude'], pt_time, hr]
302
- end
303
- end
304
-
305
276
  def tz_resolver
306
277
  @tz_resolver ||= GitFit::Timezone::Resolver.new({})
307
278
  end
@@ -414,79 +385,6 @@ module GitFit
414
385
  id_str.split('_')[1]
415
386
  end
416
387
 
417
- def decode_geo_points(geo_data)
418
- return nil unless geo_data
419
- raw = Base64.decode64(geo_data)
420
- decrypted = aes_decrypt(raw)
421
- decompressed = zlib_inflate(decrypted)
422
- JSON.parse(decompressed)
423
- rescue StandardError
424
- nil
425
- end
426
-
427
- def decode_hr_data(hr_data)
428
- return nil unless hr_data
429
- raw = Base64.decode64(hr_data)
430
- decompressed = zlib_inflate(raw)
431
- JSON.parse(decompressed)
432
- rescue StandardError
433
- nil
434
- end
435
-
436
- def aes_decrypt(data)
437
- cipher = OpenSSL::Cipher.new('AES-128-CBC')
438
- cipher.decrypt
439
- cipher.key = AES_KEY
440
- cipher.iv = AES_IV
441
- cipher.update(data) + cipher.final
442
- end
443
-
444
- def zlib_inflate(data)
445
- [-Zlib::MAX_WBITS, Zlib::MAX_WBITS, Zlib::MAX_WBITS + 16].each do |wbits|
446
- begin
447
- inflater = Zlib::Inflate.new(wbits)
448
- result = inflater.inflate(data)
449
- inflater.finish rescue nil
450
- inflater.close
451
- return result
452
- rescue Zlib::DataError
453
- next
454
- end
455
- end
456
- raise Zlib::DataError, 'cannot inflate data'
457
- end
458
-
459
- def find_nearest_hr(hr_data, geo_ts, start_time, threshold: HR_THRESHOLD)
460
- return nil unless hr_data&.any? && geo_ts
461
-
462
- normalize = ->(ts) {
463
- if ts > TIMESTAMP_THRESHOLD
464
- ts - start_time / 100
465
- else
466
- ts
467
- end
468
- }
469
-
470
- target = normalize.call(geo_ts)
471
-
472
- nearest = nil
473
- min_diff = Float::INFINITY
474
-
475
- hr_data.each do |item|
476
- ts = item['timestamp']
477
- next unless ts
478
-
479
- diff = (normalize.call(ts) - target).abs
480
- if diff <= threshold && diff < min_diff
481
- nearest = item
482
- min_diff = diff
483
- end
484
- end
485
-
486
- hr = nearest&.dig('beatsPerMinute')
487
- hr&.positive? ? hr : nil
488
- end
489
-
490
388
  def compute_avg_hr(points)
491
389
  hrs = points.map { |p| p[4] }.compact
492
390
  return nil if hrs.empty?
@@ -134,27 +134,40 @@ module GitFit
134
134
  coords = Geo::Polyline.decode(polyline)
135
135
  return nil if coords.empty?
136
136
 
137
- alt_data = streams['altitude']&.dig('data')
138
- time_data = streams['time']&.dig('data')
139
- hr_data = streams['heartrate']&.dig('data')
140
- cad_data = streams['cadence']&.dig('data')
141
- watts_data = streams['watts']&.dig('data')
142
- temp_data = streams['temp']&.dig('data')
143
- speed_data = streams['velocity_smooth']&.dig('data')
144
- dist_data = streams['distance']&.dig('data')
137
+ streams_data = %w[altitude time heartrate cadence watts temp velocity_smooth distance].to_h do |k|
138
+ [k, streams[k]&.dig('data')]
139
+ end
140
+
141
+ start_time = begin
142
+ Time.parse(detail['start_date'])
143
+ rescue StandardError
144
+ nil
145
+ end
145
146
 
146
147
  coords.each_with_index.map do |latlng, i|
147
- pt = { positionLat: latlng[0], positionLong: latlng[1] }
148
- pt[:altitude] = alt_data[i] if alt_data && i < alt_data.size
149
- pt[:timestamp] = time_data[i] if time_data && i < time_data.size
150
- pt[:heartRate] = hr_data[i] if hr_data && i < hr_data.size
151
- pt[:cadence] = cad_data[i] if cad_data && i < cad_data.size
152
- pt[:power] = watts_data[i] if watts_data && i < watts_data.size
153
- pt[:temperature] = temp_data[i] if temp_data && i < temp_data.size
154
- pt[:speed] = speed_data[i] if speed_data && i < speed_data.size
155
- pt[:distance] = dist_data[i] if dist_data && i < dist_data.size
156
- pt
148
+ build_l2_point(latlng, i, streams_data, start_time)
149
+ end
150
+ end
151
+
152
+ def build_l2_point(latlng, i, streams_data, start_time)
153
+ pt = { positionLat: latlng[0], positionLong: latlng[1] }
154
+ pt[:altitude] = stream_at(streams_data, 'altitude', i)
155
+ ts = stream_at(streams_data, 'time', i)
156
+ if ts
157
+ pt[:timestamp] = start_time ? (start_time + ts).utc.iso8601 : ts
157
158
  end
159
+ pt[:heartRate] = stream_at(streams_data, 'heartrate', i)
160
+ pt[:cadence] = stream_at(streams_data, 'cadence', i)
161
+ pt[:power] = stream_at(streams_data, 'watts', i)
162
+ pt[:temperature] = stream_at(streams_data, 'temp', i)
163
+ pt[:speed] = stream_at(streams_data, 'velocity_smooth', i)
164
+ pt[:distance] = stream_at(streams_data, 'distance', i)
165
+ pt
166
+ end
167
+
168
+ def stream_at(streams_data, key, i)
169
+ data = streams_data[key]
170
+ data && i < data.size ? data[i] : nil
158
171
  end
159
172
 
160
173
  def build_attrs(act, category, polyline = nil, external_id: nil)
@@ -348,7 +348,7 @@ module GitFit
348
348
  FileUtils.mkdir_p(dir)
349
349
  tmp = File.join(dir, ".#{act_id}.json.tmp")
350
350
  final = File.join(dir, "#{act_id}.json")
351
- File.write(tmp, JSON.generate(points))
351
+ File.write(tmp, JSON.generate(normalize_fit_timestamps(points)))
352
352
  File.rename(tmp, final)
353
353
  end
354
354
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GitFit
4
- VERSION = '0.10.3'
4
+ VERSION = '0.10.5'
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.10.3
4
+ version: 0.10.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lax
@@ -177,6 +177,20 @@ dependencies:
177
177
  - - "~>"
178
178
  - !ruby/object:Gem::Version
179
179
  version: '1.0'
180
+ - !ruby/object:Gem::Dependency
181
+ name: webrick
182
+ requirement: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - "~>"
185
+ - !ruby/object:Gem::Version
186
+ version: '1.9'
187
+ type: :runtime
188
+ prerelease: false
189
+ version_requirements: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - "~>"
192
+ - !ruby/object:Gem::Version
193
+ version: '1.9'
180
194
  - !ruby/object:Gem::Dependency
181
195
  name: ostruct
182
196
  requirement: !ruby/object:Gem::Requirement
@@ -218,6 +232,8 @@ files:
218
232
  - db/migrations/002_iso8601_time_format.rb
219
233
  - exe/git-fit
220
234
  - lib/git-fit.rb
235
+ - lib/git_fit/auth/garmin_token.rb
236
+ - lib/git_fit/auth/strava.rb
221
237
  - lib/git_fit/cli.rb
222
238
  - lib/git_fit/cli/export.rb
223
239
  - lib/git_fit/cli/geo_cli.rb
@@ -232,10 +248,17 @@ files:
232
248
  - lib/git_fit/db/cli.rb
233
249
  - lib/git_fit/db/connection.rb
234
250
  - lib/git_fit/db/rebuild.rb
251
+ - lib/git_fit/dedup/field_elector.rb
252
+ - lib/git_fit/dedup/group_consolidator.rb
253
+ - lib/git_fit/dedup/log_writer.rb
254
+ - lib/git_fit/dedup/matcher.rb
255
+ - lib/git_fit/dedup/service.rb
256
+ - lib/git_fit/dedup/trajectory.rb
235
257
  - lib/git_fit/export.rb
236
258
  - lib/git_fit/export/calculator.rb
237
259
  - lib/git_fit/export/csv.rb
238
260
  - lib/git_fit/export/defaults.rb
261
+ - lib/git_fit/export/gpx.rb
239
262
  - lib/git_fit/export/json.rb
240
263
  - lib/git_fit/export/stats.rb
241
264
  - lib/git_fit/fit.rb
@@ -262,6 +285,7 @@ files:
262
285
  - lib/git_fit/privacy/polyline_filter.rb
263
286
  - lib/git_fit/source.rb
264
287
  - lib/git_fit/source/base.rb
288
+ - lib/git_fit/source/keep.rb
265
289
  - lib/git_fit/source/tally.rb
266
290
  - lib/git_fit/sport_mapper.rb
267
291
  - lib/git_fit/strava_web/file_check.rb