git-fit 0.6.1 → 0.7.1
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 +10 -3
- data/lib/git_fit/cli/sync.rb +23 -34
- data/lib/git_fit/config.rb +9 -2
- data/lib/git_fit/config_template.rb +12 -0
- data/lib/git_fit/sync/base.rb +64 -1
- data/lib/git_fit/sync/garmin.rb +16 -0
- data/lib/git_fit/sync/garmin_base.rb +648 -0
- data/lib/git_fit/sync/garmin_base_di.rb +186 -0
- data/lib/git_fit/sync/garmin_cn.rb +29 -0
- data/lib/git_fit/sync/igpsport.rb +225 -0
- data/lib/git_fit/sync/keep.rb +499 -0
- data/lib/git_fit/sync/lorem.rb +1 -0
- data/lib/git_fit/sync/runner.rb +322 -0
- data/lib/git_fit/sync/strava.rb +187 -0
- data/lib/git_fit/sync/xingzhe.rb +262 -0
- data/lib/git_fit/sync/xoss.rb +369 -0
- data/lib/git_fit/version.rb +1 -1
- metadata +66 -1
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
require "thread"
|
|
2
|
+
|
|
3
|
+
module GitFit
|
|
4
|
+
module Sync
|
|
5
|
+
class Runner
|
|
6
|
+
POISON = Object.new.freeze
|
|
7
|
+
BATCH_SIZE = 5
|
|
8
|
+
|
|
9
|
+
def initialize(sources:, db:, config:, activity_filter: nil,
|
|
10
|
+
privacy: nil, num_workers: 3, time_budget: nil, total_timeout: nil)
|
|
11
|
+
@sources = sources
|
|
12
|
+
@db = db
|
|
13
|
+
@config = config
|
|
14
|
+
@activity_filter = activity_filter
|
|
15
|
+
@privacy = privacy
|
|
16
|
+
@num_workers = [@num_workers.to_i, 1].max
|
|
17
|
+
@time_budget = time_budget.is_a?(Numeric) ? time_budget.to_f : nil
|
|
18
|
+
@total_timeout = total_timeout.is_a?(Numeric) ? total_timeout.to_f : nil
|
|
19
|
+
@sync_start = Time.now
|
|
20
|
+
@watchdog = nil
|
|
21
|
+
|
|
22
|
+
@pending_ids = {}
|
|
23
|
+
@adapters = {}
|
|
24
|
+
@source_queue = Queue.new
|
|
25
|
+
@output_queue = Queue.new
|
|
26
|
+
@stats_lock = Mutex.new
|
|
27
|
+
@unfinished = 0
|
|
28
|
+
@completed = {}
|
|
29
|
+
@timed_out = false
|
|
30
|
+
@shutdown = false
|
|
31
|
+
@source_start_times = {}
|
|
32
|
+
@source_activated = {}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run
|
|
36
|
+
output_thread = Thread.new { output_loop }
|
|
37
|
+
start_watchdog if @total_timeout
|
|
38
|
+
init_pending
|
|
39
|
+
unless @source_queue.empty?
|
|
40
|
+
worker_threads = @num_workers.times.map { Thread.new { worker_loop } }
|
|
41
|
+
worker_threads.each(&:join)
|
|
42
|
+
end
|
|
43
|
+
output_queue << POISON
|
|
44
|
+
output_thread.join(60) || output_thread.kill
|
|
45
|
+
|
|
46
|
+
# TODO: Dedup 需要独立设计三种语义后再实现
|
|
47
|
+
# A. 同源防重: upsert_activity 已通过 run_id 唯一约束天然保证
|
|
48
|
+
# B. 跨设备同活动: 需轨迹相似度匹配 + 时间窗口聚类
|
|
49
|
+
# C. 跨平台迁移复制: 需 external_id 映射表 + 数据指纹比对
|
|
50
|
+
# 触发时机、匹配算法、合并策略见 docs/dedup-design.md
|
|
51
|
+
# Dedup::Service.new(@db).run if defined?(Dedup::Service) && ...
|
|
52
|
+
ensure
|
|
53
|
+
@watchdog&.kill
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
attr_reader :output_queue
|
|
59
|
+
|
|
60
|
+
def init_pending
|
|
61
|
+
@sources.each do |source|
|
|
62
|
+
cfg = @config.sync_config(source)
|
|
63
|
+
next if cfg.nil? || cfg.empty?
|
|
64
|
+
@source_queue << source
|
|
65
|
+
end
|
|
66
|
+
@unfinished = @source_queue.size
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def adapter_class(source, _cfg)
|
|
70
|
+
(GitFit::Sync::Base.adapters + GitFit::Sync::Base.test_adapters)
|
|
71
|
+
.find { |a| a.name.split("::").last.downcase == source }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def init_source(source)
|
|
75
|
+
klass = adapter_class(source, nil)
|
|
76
|
+
actual_key = klass&.config_key || source
|
|
77
|
+
cfg = @config.sync_config(actual_key)
|
|
78
|
+
unless klass
|
|
79
|
+
output_queue << { type: :banner, source:, text: "no adapter" }
|
|
80
|
+
clear_source(source)
|
|
81
|
+
return false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
output_queue << { type: :banner, source:, text: "authenticating" }
|
|
85
|
+
adapter = klass.new(config: cfg, db: @db, activity_filter: @activity_filter, privacy: @privacy, time_budget: @time_budget)
|
|
86
|
+
unless adapter.before_call
|
|
87
|
+
output_queue << { type: :banner, source:, text: "before_call failed" }
|
|
88
|
+
clear_source(source)
|
|
89
|
+
return false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
unless adapter.authenticate
|
|
93
|
+
output_queue << { type: :banner, source:, text: "auth failed" }
|
|
94
|
+
clear_source(source)
|
|
95
|
+
return false
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
output_queue << { type: :banner, source:, text: "fetching activity list" }
|
|
99
|
+
ids = adapter.pending_ids
|
|
100
|
+
if ids.nil? || ids.empty?
|
|
101
|
+
output_queue << { type: :banner, source:, text: "no new activities" }
|
|
102
|
+
clear_source(source)
|
|
103
|
+
return false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
@adapters[source] = adapter
|
|
107
|
+
@pending_ids[source] = ids
|
|
108
|
+
@source_start_times[source] = Time.now
|
|
109
|
+
@source_activated[source] = true
|
|
110
|
+
output_queue << { type: :banner, source:, text: "#{ids.size} pending" }
|
|
111
|
+
true
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def clear_source(source)
|
|
115
|
+
@pending_ids.delete(source)
|
|
116
|
+
@adapters.delete(source)
|
|
117
|
+
@source_start_times.delete(source)
|
|
118
|
+
@source_activated.delete(source)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def worker_loop
|
|
122
|
+
loop do
|
|
123
|
+
source = @source_queue.pop
|
|
124
|
+
break if source == POISON
|
|
125
|
+
|
|
126
|
+
begin
|
|
127
|
+
process_source(source)
|
|
128
|
+
rescue => e
|
|
129
|
+
@output_queue << { type: :error, source:, message: "Worker crashed: #{e.message}" }
|
|
130
|
+
@stats_lock.synchronize do
|
|
131
|
+
@pending_ids.delete(source)
|
|
132
|
+
@adapters.delete(source)
|
|
133
|
+
return if @completed[source]
|
|
134
|
+
@completed[source] = true
|
|
135
|
+
@unfinished -= 1
|
|
136
|
+
@unfinished = 0 if @unfinished < 0
|
|
137
|
+
if @unfinished <= 0
|
|
138
|
+
@shutdown = true
|
|
139
|
+
@num_workers.times { @source_queue << POISON }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def process_source(source)
|
|
147
|
+
unless init_source(source)
|
|
148
|
+
finish_activity(source)
|
|
149
|
+
return
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
total = @pending_ids[source].size
|
|
153
|
+
processed = 0
|
|
154
|
+
timed_out_local = false
|
|
155
|
+
|
|
156
|
+
loop do
|
|
157
|
+
activity = nil
|
|
158
|
+
@stats_lock.synchronize { activity = @pending_ids[source]&.shift }
|
|
159
|
+
break unless activity
|
|
160
|
+
|
|
161
|
+
if @stats_lock.synchronize { @shutdown }
|
|
162
|
+
@stats_lock.synchronize do
|
|
163
|
+
@pending_ids[source]&.unshift(activity) if @pending_ids[source]
|
|
164
|
+
end
|
|
165
|
+
timed_out_local = true
|
|
166
|
+
break
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
if source_budget_expired?(source)
|
|
170
|
+
@stats_lock.synchronize do
|
|
171
|
+
@timed_out = true
|
|
172
|
+
@pending_ids[source]&.unshift(activity) if @pending_ids[source]
|
|
173
|
+
end
|
|
174
|
+
timed_out_local = true
|
|
175
|
+
break
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
if @adapters[source].respond_to?(:skip_type?, true) && @adapters[source].send(:skip_type?, activity[:type])
|
|
179
|
+
next
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
begin
|
|
183
|
+
result = @adapters[source].process_one(activity)
|
|
184
|
+
processed += 1
|
|
185
|
+
if result
|
|
186
|
+
info = @adapters[source].build_progress_info(result)
|
|
187
|
+
@output_queue << {
|
|
188
|
+
type: :progress, source:, id: activity[:id],
|
|
189
|
+
info: info, done: processed, total: total
|
|
190
|
+
}
|
|
191
|
+
else
|
|
192
|
+
@output_queue << {
|
|
193
|
+
type: :progress, source:, id: activity[:id],
|
|
194
|
+
info: nil, done: processed, total: total
|
|
195
|
+
}
|
|
196
|
+
end
|
|
197
|
+
rescue => e
|
|
198
|
+
@output_queue << { type: :error, source:, message: e.message }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
break if processed >= BATCH_SIZE
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
finish_activity(source, timed_out_local)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def finish_activity(source, timed_out = false)
|
|
208
|
+
@stats_lock.synchronize do
|
|
209
|
+
if timed_out
|
|
210
|
+
remaining = @pending_ids[source]&.size.to_i
|
|
211
|
+
@output_queue << { type: :timeout, source:, remaining: }
|
|
212
|
+
@pending_ids[source]&.clear
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
more = @pending_ids[source]&.any?
|
|
216
|
+
if more && !@shutdown
|
|
217
|
+
@source_queue << source
|
|
218
|
+
else
|
|
219
|
+
return if @completed[source]
|
|
220
|
+
@completed[source] = true
|
|
221
|
+
@unfinished -= 1
|
|
222
|
+
@unfinished = 0 if @unfinished < 0
|
|
223
|
+
if @unfinished <= 0
|
|
224
|
+
@shutdown = true
|
|
225
|
+
@num_workers.times { @source_queue << POISON }
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def source_budget_expired?(source)
|
|
232
|
+
return false unless @time_budget&.positive?
|
|
233
|
+
start_time = @stats_lock.synchronize { @source_start_times[source] }
|
|
234
|
+
return false unless start_time
|
|
235
|
+
(Time.now - start_time) > @time_budget
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def start_watchdog
|
|
239
|
+
@watchdog = Thread.new do
|
|
240
|
+
sleep @total_timeout
|
|
241
|
+
@stats_lock.synchronize { @shutdown = true }
|
|
242
|
+
@output_queue << { type: :total_timeout, seconds: @total_timeout }
|
|
243
|
+
@num_workers.times { @source_queue << POISON }
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def output_loop
|
|
248
|
+
$stdout.sync = true
|
|
249
|
+
$stdout.puts "Runner started with #{@num_workers} workers, #{@sources.size} sources"
|
|
250
|
+
$stdout.flush
|
|
251
|
+
per_source = Hash.new { |h, k| h[k] = {} }
|
|
252
|
+
|
|
253
|
+
loop do
|
|
254
|
+
msg = @output_queue.pop
|
|
255
|
+
break if msg == POISON
|
|
256
|
+
|
|
257
|
+
s = msg[:source]
|
|
258
|
+
ps = per_source[s]
|
|
259
|
+
|
|
260
|
+
case msg[:type]
|
|
261
|
+
when :banner
|
|
262
|
+
text = msg[:text]
|
|
263
|
+
if text =~ /auth failed/
|
|
264
|
+
ps[:auth_failed] = true
|
|
265
|
+
elsif text =~ /no new activities/
|
|
266
|
+
ps[:up_to_date] = true
|
|
267
|
+
elsif text =~ /(\d+) pending/
|
|
268
|
+
ps[:total] = $1.to_i
|
|
269
|
+
end
|
|
270
|
+
$stdout.puts " #{s.rjust(10)} | #{text}"
|
|
271
|
+
when :progress
|
|
272
|
+
ps[:done] = msg[:done].nil? ? ps.fetch(:done, 0) + 1 : msg[:done]
|
|
273
|
+
total = msg[:total] || ps[:total]
|
|
274
|
+
next unless show_progress?(msg[:done], total)
|
|
275
|
+
line = if msg[:info]
|
|
276
|
+
" [#{msg[:done]}/#{total}] #{msg[:info]}"
|
|
277
|
+
else
|
|
278
|
+
" [#{msg[:done]}/#{total}] (skipped)"
|
|
279
|
+
end
|
|
280
|
+
$stdout.puts " #{s.rjust(10)} |#{line}"
|
|
281
|
+
when :error
|
|
282
|
+
ps[:errors] = (ps[:errors] || 0) + 1
|
|
283
|
+
$stdout.puts " #{s.rjust(10)} | ERROR: #{msg[:message]}"
|
|
284
|
+
when :timeout
|
|
285
|
+
$stdout.puts " #{s.rjust(10)} | TIME BUDGET — #{msg[:remaining]} remaining"
|
|
286
|
+
when :total_timeout
|
|
287
|
+
$stdout.puts " *** TOTAL TIMEOUT #{msg[:seconds]}s — forcing shutdown"
|
|
288
|
+
end
|
|
289
|
+
$stdout.flush
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
@sources.each do |s|
|
|
293
|
+
info = per_source[s] || {}
|
|
294
|
+
err = info[:errors] || 0
|
|
295
|
+
if info[:auth_failed]
|
|
296
|
+
$stdout.puts " done: #{s}: 0 synced (auth failed)"
|
|
297
|
+
elsif info[:up_to_date]
|
|
298
|
+
$stdout.puts " done: #{s}: up to date"
|
|
299
|
+
elsif (cnt = info[:done]) && cnt > 0
|
|
300
|
+
label = err > 0 ? "#{cnt} synced, #{err} errors" : "#{cnt} synced"
|
|
301
|
+
$stdout.puts " done: #{s}: #{label}"
|
|
302
|
+
elsif info.key?(:done)
|
|
303
|
+
$stdout.puts " done: #{s}: 0 synced"
|
|
304
|
+
else
|
|
305
|
+
$stdout.puts " done: #{s}: 0 synced"
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
$stdout.flush
|
|
309
|
+
rescue => e
|
|
310
|
+
$stdout.puts " Runner output error: #{e.message}"
|
|
311
|
+
$stdout.flush
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def show_progress?(done, total)
|
|
315
|
+
return true if done.nil? || done <= 3
|
|
316
|
+
return true if total && (total - done) <= 5
|
|
317
|
+
step = total && total > 0 ? [1, (total / 15.0).ceil].max : 1
|
|
318
|
+
step <= 1 || done % step == 0
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
data/lib/git_fit/sync/strava.rb
CHANGED
|
@@ -1,7 +1,194 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
require "faraday"
|
|
3
|
+
|
|
1
4
|
module GitFit
|
|
2
5
|
module Sync
|
|
6
|
+
class SyncError < RuntimeError; end unless const_defined?(:SyncError)
|
|
7
|
+
|
|
3
8
|
class Strava < Base
|
|
9
|
+
BASE_URL = "https://www.strava.com/api/v3"
|
|
10
|
+
|
|
11
|
+
register_adapter
|
|
4
12
|
register_config :client_id, :client_secret, :refresh_token
|
|
13
|
+
|
|
14
|
+
def authenticate
|
|
15
|
+
return false unless @config["client_id"].to_s != "" &&
|
|
16
|
+
@config["client_secret"].to_s != "" &&
|
|
17
|
+
@config["refresh_token"].to_s != ""
|
|
18
|
+
@access_token = refresh_access_token
|
|
19
|
+
@access_token ? true : false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def pending_ids
|
|
23
|
+
existing = existing_run_ids
|
|
24
|
+
ids = []
|
|
25
|
+
page = 1
|
|
26
|
+
loop do
|
|
27
|
+
activities = fetch_activities(@access_token, page)
|
|
28
|
+
break if activities.empty?
|
|
29
|
+
activities.each do |act|
|
|
30
|
+
act_id = act["id"].to_s
|
|
31
|
+
next if existing.include?(act_id) && raw_file_exists?(act_id)
|
|
32
|
+
type = map_sport_type(act["type"], act["sport_type"])
|
|
33
|
+
next if skip_type?(type)
|
|
34
|
+
ids << { id: act_id, type: type, summary: act }
|
|
35
|
+
end
|
|
36
|
+
page += 1
|
|
37
|
+
end
|
|
38
|
+
ids
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def process_one(pending)
|
|
42
|
+
result = sync_activity(pending[:summary], pending[:id], @access_token)
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def refresh_access_token
|
|
49
|
+
resp = Faraday.post("https://www.strava.com/oauth/token") do |req|
|
|
50
|
+
req.body = {
|
|
51
|
+
client_id: @config["client_id"],
|
|
52
|
+
client_secret: @config["client_secret"],
|
|
53
|
+
refresh_token: @config["refresh_token"],
|
|
54
|
+
grant_type: "refresh_token"
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
raise SyncError, "Strava token refresh failed: #{resp.status}" unless resp.success?
|
|
58
|
+
JSON.parse(resp.body)["access_token"]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def sync_activity(act, act_id, token)
|
|
62
|
+
type = map_sport_type(act["type"], act["sport_type"])
|
|
63
|
+
return false if skip_type?(type)
|
|
64
|
+
|
|
65
|
+
detail = fetch_detail(act_id, token)
|
|
66
|
+
return false unless detail
|
|
67
|
+
|
|
68
|
+
external_id = detail["external_id"]
|
|
69
|
+
|
|
70
|
+
streams = fetch_streams(act_id, token)
|
|
71
|
+
|
|
72
|
+
write_source_archive({ source: "strava", detail: detail, streams: streams }, act_id)
|
|
73
|
+
|
|
74
|
+
l2 = streams_to_l2(detail, streams)
|
|
75
|
+
write_standardized_json(l2, act_id) if l2
|
|
76
|
+
|
|
77
|
+
polyline = l2 ? Geo::Polyline.encode(l2.map { |pt| [pt[:positionLat], pt[:positionLong]] }) : act.dig("map", "summary_polyline")
|
|
78
|
+
|
|
79
|
+
attrs = build_attrs(act, type, polyline, external_id: external_id)
|
|
80
|
+
upsert_activity(attrs)
|
|
81
|
+
attrs
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def fetch_activities(token, page)
|
|
85
|
+
resp = Faraday.get("#{BASE_URL}/athlete/activities") do |req|
|
|
86
|
+
req.headers["Authorization"] = "Bearer #{token}"
|
|
87
|
+
req.params = { page: page, per_page: 100 }
|
|
88
|
+
end
|
|
89
|
+
return [] unless resp.success?
|
|
90
|
+
JSON.parse(resp.body)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def fetch_detail(act_id, token)
|
|
94
|
+
resp = Faraday.get("#{BASE_URL}/activities/#{act_id}") do |req|
|
|
95
|
+
req.headers["Authorization"] = "Bearer #{token}"
|
|
96
|
+
end
|
|
97
|
+
return nil unless resp.success?
|
|
98
|
+
JSON.parse(resp.body)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def fetch_streams(act_id, token)
|
|
102
|
+
resp = Faraday.get("#{BASE_URL}/activities/#{act_id}/streams") do |req|
|
|
103
|
+
req.headers["Authorization"] = "Bearer #{token}"
|
|
104
|
+
req.params = { keys: "lat_lng,altitude,time,heartrate,cadence,watts,temp,velocity_smooth,distance", key_by_type: true }
|
|
105
|
+
end
|
|
106
|
+
return {} unless resp.success?
|
|
107
|
+
JSON.parse(resp.body)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def write_standardized_json(l2, act_id)
|
|
111
|
+
dir = std_dir
|
|
112
|
+
FileUtils.mkdir_p(dir)
|
|
113
|
+
tmp = File.join(dir, ".#{act_id}.json.tmp")
|
|
114
|
+
final = File.join(dir, "#{act_id}.json")
|
|
115
|
+
File.write(tmp, JSON.generate(l2))
|
|
116
|
+
File.rename(tmp, final)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def streams_to_l2(detail, streams)
|
|
120
|
+
polyline = detail.dig("map", "polyline")
|
|
121
|
+
return nil unless polyline
|
|
122
|
+
|
|
123
|
+
coords = Geo::Polyline.decode(polyline)
|
|
124
|
+
return nil if coords.empty?
|
|
125
|
+
|
|
126
|
+
alt_data = streams["altitude"]&.dig("data")
|
|
127
|
+
time_data = streams["time"]&.dig("data")
|
|
128
|
+
hr_data = streams["heartrate"]&.dig("data")
|
|
129
|
+
cad_data = streams["cadence"]&.dig("data")
|
|
130
|
+
watts_data = streams["watts"]&.dig("data")
|
|
131
|
+
temp_data = streams["temp"]&.dig("data")
|
|
132
|
+
speed_data = streams["velocity_smooth"]&.dig("data")
|
|
133
|
+
dist_data = streams["distance"]&.dig("data")
|
|
134
|
+
|
|
135
|
+
coords.each_with_index.map do |latlng, i|
|
|
136
|
+
pt = { positionLat: latlng[0], positionLong: latlng[1] }
|
|
137
|
+
pt[:altitude] = alt_data[i] if alt_data && i < alt_data.size
|
|
138
|
+
pt[:timestamp] = time_data[i] if time_data && i < time_data.size
|
|
139
|
+
pt[:heartRate] = hr_data[i] if hr_data && i < hr_data.size
|
|
140
|
+
pt[:cadence] = cad_data[i] if cad_data && i < cad_data.size
|
|
141
|
+
pt[:power] = watts_data[i] if watts_data && i < watts_data.size
|
|
142
|
+
pt[:temperature] = temp_data[i] if temp_data && i < temp_data.size
|
|
143
|
+
pt[:speed] = speed_data[i] if speed_data && i < speed_data.size
|
|
144
|
+
pt[:distance] = dist_data[i] if dist_data && i < dist_data.size
|
|
145
|
+
pt
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def build_attrs(act, category, polyline = nil, external_id: nil)
|
|
150
|
+
start_t = Time.parse(act["start_date"]) rescue nil
|
|
151
|
+
local_t = Time.parse(act["start_date_local"]) rescue nil
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
run_id: "strava_#{act["id"]}",
|
|
155
|
+
name: act["name"],
|
|
156
|
+
distance: act["distance"],
|
|
157
|
+
moving_time: act["moving_time"],
|
|
158
|
+
elapsed_time: act["elapsed_time"],
|
|
159
|
+
sport_category: category,
|
|
160
|
+
sport_type: (act["sport_type"] || act["type"])&.downcase,
|
|
161
|
+
start_date: start_t&.iso8601,
|
|
162
|
+
start_date_local: local_t&.iso8601,
|
|
163
|
+
location_country: act["location_country"],
|
|
164
|
+
summary_polyline: polyline,
|
|
165
|
+
average_heartrate: act["average_heartrate"],
|
|
166
|
+
max_heartrate: act["max_heartrate"],
|
|
167
|
+
average_cadence: act["average_cadence"],
|
|
168
|
+
max_cadence: nil,
|
|
169
|
+
average_power: act["average_watts"],
|
|
170
|
+
max_power: act["max_watts"],
|
|
171
|
+
calories: act["calories"],
|
|
172
|
+
average_temperature: act["average_temp"],
|
|
173
|
+
average_speed: act["average_speed"],
|
|
174
|
+
elevation_gain: act["total_elevation_gain"],
|
|
175
|
+
source: "strava",
|
|
176
|
+
external_id: external_id
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def map_sport_type(type, sport_type)
|
|
181
|
+
SportMapper.canonicalize(sport_type || type)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def raw_path(platform_id)
|
|
185
|
+
File.join(raw_dir, "#{platform_id}.json")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def existing_run_ids
|
|
189
|
+
@db[:activities].where(source: "strava").select_map(:run_id)
|
|
190
|
+
.map { |id| id.sub("strava_", "") }
|
|
191
|
+
end
|
|
5
192
|
end
|
|
6
193
|
end
|
|
7
194
|
end
|