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,262 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "tzinfo"
|
|
5
|
+
|
|
6
|
+
module GitFit
|
|
7
|
+
module Sync
|
|
8
|
+
class XingZhe < Base
|
|
9
|
+
BASE_HOST = "www.imxingzhe.com"
|
|
10
|
+
LOGIN_PATH = "/api/v1/user/login/"
|
|
11
|
+
WORKOUTS_PATH = "/api/v1/pgworkout/"
|
|
12
|
+
STREAM_PATH = "/api/v1/pgworkout/"
|
|
13
|
+
DETAIL_PATH = "/api/v1/pgworkout/"
|
|
14
|
+
RSA_PUBKEY = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDmuQkBbijudDAJgfffDeeIButqWHZvUwcRuvWdg89393FSdz3IJUHc0rgI/S3WuU8N0VePJLmVAZtCOK4qe4FY/eKmWpJmn7JfXB4HTMWjPVoyRZmSYjW4L8GrWmh51Qj7DwpTADadF3aq04o+s1b8LXJa8r6+TIqqL5WUHtRqmQIDAQAB"
|
|
15
|
+
|
|
16
|
+
SPORT_MAP = {
|
|
17
|
+
3 => "ride", 1 => "run", 2 => "hike",
|
|
18
|
+
5 => "swim", 6 => "walk", 11 => "ski", 13 => "workout"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
RAW_EXT = "json"
|
|
22
|
+
|
|
23
|
+
register_adapter
|
|
24
|
+
register_config :email, :password
|
|
25
|
+
|
|
26
|
+
def initialize(...)
|
|
27
|
+
super
|
|
28
|
+
@email = @config["email"]
|
|
29
|
+
@password = @config["password"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def before_call
|
|
33
|
+
return false if @email.nil? || @password.nil?
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def authenticate
|
|
38
|
+
login
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def pending_ids
|
|
42
|
+
existing = existing_run_ids
|
|
43
|
+
ids = []
|
|
44
|
+
offset = 0
|
|
45
|
+
limit = 50
|
|
46
|
+
loop do
|
|
47
|
+
acts = fetch_page(offset, limit)
|
|
48
|
+
break if acts.nil? || acts.empty?
|
|
49
|
+
acts.each do |act|
|
|
50
|
+
aid = act["id"].to_s
|
|
51
|
+
next if existing.include?(aid) && raw_file_exists?(aid)
|
|
52
|
+
type = map_sport(act["sport"])
|
|
53
|
+
next if skip_type?(type)
|
|
54
|
+
ids << { id: aid, type: type, summary: act }
|
|
55
|
+
end
|
|
56
|
+
offset += limit
|
|
57
|
+
end
|
|
58
|
+
ids
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def process_one(pending)
|
|
62
|
+
process_activity(pending[:summary], pending[:type])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def http_request(method, path, body: nil, params: nil, headers: {})
|
|
68
|
+
uri = URI("https://#{BASE_HOST}#{path}")
|
|
69
|
+
uri.query = URI.encode_www_form(params) if params
|
|
70
|
+
|
|
71
|
+
req = case method
|
|
72
|
+
when :get then Net::HTTP::Get.new(uri)
|
|
73
|
+
when :post then Net::HTTP::Post.new(uri)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
headers.each { |k, v| req[k] = v }
|
|
77
|
+
req.body = body if body
|
|
78
|
+
|
|
79
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
80
|
+
http.use_ssl = true
|
|
81
|
+
http.open_timeout = 30
|
|
82
|
+
http.read_timeout = 60
|
|
83
|
+
http.start { |h| h.request(req) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def login
|
|
87
|
+
encrypted = rsa_encrypt(@password)
|
|
88
|
+
resp = http_request(:post, LOGIN_PATH,
|
|
89
|
+
body: JSON.generate({ account: @email, password: encrypted }),
|
|
90
|
+
headers: { "Content-Type" => "application/json" }
|
|
91
|
+
)
|
|
92
|
+
return false unless resp.code.to_i == 200
|
|
93
|
+
|
|
94
|
+
data = JSON.parse(resp.body)
|
|
95
|
+
return false unless data["code"] == 0
|
|
96
|
+
|
|
97
|
+
cookie = extract_cookie(resp)
|
|
98
|
+
return false unless cookie
|
|
99
|
+
|
|
100
|
+
@headers = { "Cookie" => cookie, "Accept" => "application/json" }
|
|
101
|
+
true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def rsa_encrypt(password)
|
|
105
|
+
pem = "-----BEGIN PUBLIC KEY-----\n#{RSA_PUBKEY}\n-----END PUBLIC KEY-----"
|
|
106
|
+
key = OpenSSL::PKey::RSA.new(pem)
|
|
107
|
+
Base64.strict_encode64(key.public_encrypt(password, OpenSSL::PKey::RSA::PKCS1_PADDING))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def extract_cookie(resp)
|
|
111
|
+
set_cookie = resp["set-cookie"]
|
|
112
|
+
return nil unless set_cookie
|
|
113
|
+
m = set_cookie.match(/sessionid=([^;]+)/)
|
|
114
|
+
m ? "sessionid=#{m[1]}" : nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def sync_activities
|
|
118
|
+
pending_ids.filter_map { |p| process_one(p) }.size
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def fetch_page(offset, limit)
|
|
122
|
+
resp = http_request(:get, WORKOUTS_PATH,
|
|
123
|
+
params: { offset: offset, limit: limit },
|
|
124
|
+
headers: @headers
|
|
125
|
+
)
|
|
126
|
+
return nil unless resp.code.to_i == 200
|
|
127
|
+
|
|
128
|
+
JSON.parse(resp.body).dig("data", "data")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def tz_resolver
|
|
132
|
+
@tz_resolver ||= GitFit::Timezone::Resolver.new({})
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def resolve_local_time(start_utc, pts)
|
|
136
|
+
return start_utc.iso8601 unless pts&.any?
|
|
137
|
+
|
|
138
|
+
lat, lng = pts.first
|
|
139
|
+
return start_utc.iso8601 unless lat && lng
|
|
140
|
+
|
|
141
|
+
tz_string = tz_resolver.resolve(lat, lng)
|
|
142
|
+
tz = TZInfo::Timezone.get(tz_string)
|
|
143
|
+
tz.utc_to_local(start_utc).iso8601
|
|
144
|
+
rescue => e
|
|
145
|
+
warn "XingZhe: timezone resolution failed: #{e.message}"
|
|
146
|
+
start_utc.iso8601
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def process_activity(act, type)
|
|
150
|
+
aid = act["id"].to_s
|
|
151
|
+
start_t = Time.at(act["start_time"] / 1000.0).utc rescue nil
|
|
152
|
+
return false unless start_t
|
|
153
|
+
|
|
154
|
+
attrs = {
|
|
155
|
+
run_id: "xingzhe_#{aid}",
|
|
156
|
+
name: act["title"] || "XingZhe Activity",
|
|
157
|
+
distance: act["distance"].to_f,
|
|
158
|
+
moving_time: (t = act["duration"].to_i) > 0 ? t : nil,
|
|
159
|
+
elapsed_time: t > 0 ? t : nil,
|
|
160
|
+
sport_category: type,
|
|
161
|
+
sport_type: act["sport"].to_s,
|
|
162
|
+
start_date: start_t.iso8601,
|
|
163
|
+
start_date_local: start_t.iso8601,
|
|
164
|
+
average_speed: (act["avg_speed"].to_f / 3.6).round(2).nonzero?,
|
|
165
|
+
elevation_gain: act["elevation_gain"].to_f.nonzero?,
|
|
166
|
+
source: "xingzhe"
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
pts = nil
|
|
170
|
+
stream = fetch_stream(aid)
|
|
171
|
+
if stream
|
|
172
|
+
write_source_archive({
|
|
173
|
+
source: "xingzhe",
|
|
174
|
+
fetchedAt: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
175
|
+
stream: stream
|
|
176
|
+
}, aid)
|
|
177
|
+
if (locs = stream["location"]) && locs.size >= 2
|
|
178
|
+
pts = locs.map { |lng, lat| [lat, lng] }
|
|
179
|
+
write_std(pts, stream, aid)
|
|
180
|
+
attrs[:summary_polyline] = stream_to_polyline(aid)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
if attrs[:summary_polyline].nil? && (detail = fetch_activity_detail(aid)) && (segments = detail["segments_km"])
|
|
185
|
+
pts = segments.map { |s| [s["latitude"], s["longitude"]] }
|
|
186
|
+
attrs[:summary_polyline] = Geo::Polyline.encode(pts) if pts.size >= 2
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
attrs[:start_date_local] = resolve_local_time(start_t, pts)
|
|
190
|
+
|
|
191
|
+
upsert_activity(attrs)
|
|
192
|
+
attrs
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def raw_path(platform_id)
|
|
196
|
+
File.join(raw_dir, "#{platform_id}.#{RAW_EXT}")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def fetch_stream(id)
|
|
200
|
+
resp = http_request(:get, "#{STREAM_PATH}#{id}/stream/", headers: @headers || {})
|
|
201
|
+
return nil unless resp.code.to_i == 200
|
|
202
|
+
|
|
203
|
+
JSON.parse(resp.body).dig("data")
|
|
204
|
+
rescue JSON::ParserError
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def write_std(pts, stream, id)
|
|
209
|
+
dir = std_dir
|
|
210
|
+
FileUtils.mkdir_p(dir)
|
|
211
|
+
ts_arr = stream["timestamp"] || []
|
|
212
|
+
alt_arr = stream["altitude"] || []
|
|
213
|
+
spd_arr = stream["speed"] || []
|
|
214
|
+
dst_arr = stream["distance"] || []
|
|
215
|
+
|
|
216
|
+
extended = pts.each_with_index.map do |(lat, lng), i|
|
|
217
|
+
ts_val = ts_arr[i]
|
|
218
|
+
{
|
|
219
|
+
latitude: lat, longitude: lng,
|
|
220
|
+
altitude: alt_arr[i],
|
|
221
|
+
timestamp: ts_val ? Time.at(ts_val).utc.strftime("%Y-%m-%dT%H:%M:%SZ") : nil,
|
|
222
|
+
speed: spd_arr[i],
|
|
223
|
+
distance: dst_arr[i]
|
|
224
|
+
}
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
tmp = File.join(dir, ".#{id}.json.tmp")
|
|
228
|
+
final = File.join(dir, "#{id}.json")
|
|
229
|
+
File.write(tmp, JSON.generate(extended))
|
|
230
|
+
File.rename(tmp, final)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def stream_to_polyline(id)
|
|
234
|
+
raw = JSON.parse(File.read(raw_path(id)))
|
|
235
|
+
locs = raw.dig("stream", "location") || []
|
|
236
|
+
return nil if locs.size < 2
|
|
237
|
+
|
|
238
|
+
pts = locs.map { |lng, lat| [lat, lng] }
|
|
239
|
+
Geo::Polyline.encode(pts)
|
|
240
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def fetch_activity_detail(id)
|
|
245
|
+
resp = http_request(:get, "#{DETAIL_PATH}#{id}/")
|
|
246
|
+
return nil unless resp.code.to_i == 200
|
|
247
|
+
|
|
248
|
+
data = JSON.parse(resp.body)
|
|
249
|
+
data.dig("data", "workout")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def map_sport(sport)
|
|
253
|
+
SPORT_MAP[sport] || "other"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def existing_run_ids
|
|
257
|
+
@db[:activities].where(source: "xingzhe").select_map(:run_id)
|
|
258
|
+
.map { |id| id.sub("xingzhe_", "") }
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "tzinfo"
|
|
4
|
+
|
|
5
|
+
module GitFit
|
|
6
|
+
module Sync
|
|
7
|
+
class SyncError < RuntimeError; end unless const_defined?(:SyncError)
|
|
8
|
+
|
|
9
|
+
class XOSS < Base
|
|
10
|
+
FIT_FILE_PATH = "/api/v3/activity/%<id>s/fit_file/"
|
|
11
|
+
BASE_URL = "https://backend.xoss.co"
|
|
12
|
+
LOGIN_PATH = "/api/v1/jwt-token/"
|
|
13
|
+
REFRESH_PATH = "/api/v1/jwt-token/refresh/"
|
|
14
|
+
ACTIVITIES_PATH = "/api/v3/activity/"
|
|
15
|
+
ACCESS_TOKEN_TTL = 86400
|
|
16
|
+
|
|
17
|
+
SPORT_MAP = {
|
|
18
|
+
1 => "run",
|
|
19
|
+
2 => "ride",
|
|
20
|
+
3 => "other",
|
|
21
|
+
4 => "workout",
|
|
22
|
+
5 => "swim",
|
|
23
|
+
11 => "walk"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
register_adapter
|
|
27
|
+
register_config :phone, :password
|
|
28
|
+
|
|
29
|
+
def initialize(...)
|
|
30
|
+
super
|
|
31
|
+
@email = @config["email"]
|
|
32
|
+
@password = @config["password"]
|
|
33
|
+
@token_path = @config["token_path"] || File.join("data", "cache", "xoss_tokens.json")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def before_call
|
|
37
|
+
return false if @email.nil? || @password.nil?
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def authenticate
|
|
42
|
+
return true if load_tokens && access_token_valid?
|
|
43
|
+
return true if refresh_access_token
|
|
44
|
+
return true if login
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def pending_ids
|
|
49
|
+
existing = existing_run_ids
|
|
50
|
+
ids = []
|
|
51
|
+
offset = 1
|
|
52
|
+
limit = 50
|
|
53
|
+
loop do
|
|
54
|
+
acts = fetch_page(offset, limit)
|
|
55
|
+
break if acts.nil? || acts.empty?
|
|
56
|
+
acts.each do |act|
|
|
57
|
+
act_id = act["id"].to_s
|
|
58
|
+
next if existing.include?(act_id) && raw_file_exists?(act_id)
|
|
59
|
+
type = map_sport(act["sport"])
|
|
60
|
+
next if skip_type?(type)
|
|
61
|
+
ids << { id: act_id, type: type, summary: act }
|
|
62
|
+
end
|
|
63
|
+
offset += limit
|
|
64
|
+
end
|
|
65
|
+
ids
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def process_one(pending)
|
|
69
|
+
process_activity(pending[:summary])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def http_request(method, path, body: nil, params: nil, headers: {})
|
|
75
|
+
uri = URI("#{BASE_URL}#{path}")
|
|
76
|
+
uri.query = URI.encode_www_form(params) if params
|
|
77
|
+
|
|
78
|
+
req = case method
|
|
79
|
+
when :get then Net::HTTP::Get.new(uri)
|
|
80
|
+
when :post then Net::HTTP::Post.new(uri)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
headers.each { |k, v| req[k] = v }
|
|
84
|
+
req.body = body if body
|
|
85
|
+
|
|
86
|
+
proxy = proxy_uri
|
|
87
|
+
http = if proxy
|
|
88
|
+
Net::HTTP.new(uri.host, uri.port, proxy.host, proxy.port, proxy.user, proxy.password)
|
|
89
|
+
else
|
|
90
|
+
Net::HTTP.new(uri.host, uri.port)
|
|
91
|
+
end
|
|
92
|
+
http.use_ssl = true
|
|
93
|
+
http.open_timeout = 30
|
|
94
|
+
http.read_timeout = 60
|
|
95
|
+
http.start { |h| h.request(req) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def proxy_uri
|
|
99
|
+
uri = URI(ENV["http_proxy"] || ENV["HTTP_PROXY"] || ENV["https_proxy"] || ENV["HTTPS_PROXY"] || "")
|
|
100
|
+
uri.host ? uri : nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def login
|
|
104
|
+
retries ||= 0
|
|
105
|
+
resp = http_request(:post, LOGIN_PATH,
|
|
106
|
+
body: JSON.generate({ email: @email, password: @password }),
|
|
107
|
+
headers: { "Content-Type" => "application/json", "Origin" => "xoss" }
|
|
108
|
+
)
|
|
109
|
+
return false unless resp.code.to_i == 200
|
|
110
|
+
|
|
111
|
+
data = JSON.parse(resp.body)
|
|
112
|
+
return false unless data["code"] == 0
|
|
113
|
+
|
|
114
|
+
@access_token = data.dig("data", "access")
|
|
115
|
+
@refresh_token = data.dig("data", "refresh")
|
|
116
|
+
unless @access_token
|
|
117
|
+
remove_stale_tokens
|
|
118
|
+
return false
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
@auth_headers = { "Authorization" => "Bearer #{@access_token}", "Origin" => "xoss" }
|
|
122
|
+
save_tokens
|
|
123
|
+
true
|
|
124
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
125
|
+
if (retries += 1) <= 2
|
|
126
|
+
sleep 2
|
|
127
|
+
retry
|
|
128
|
+
end
|
|
129
|
+
raise SyncError, "XOSS login timeout: #{e.message}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def token_path
|
|
133
|
+
@token_path
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def load_tokens
|
|
137
|
+
return false unless File.exist?(token_path)
|
|
138
|
+
|
|
139
|
+
data = JSON.parse(File.read(token_path))
|
|
140
|
+
@access_token = data["access"]
|
|
141
|
+
@refresh_token = data["refresh"]
|
|
142
|
+
@expires_at = data["expires_at"]
|
|
143
|
+
@auth_headers = { "Authorization" => "Bearer #{@access_token}", "Origin" => "xoss" } if @access_token
|
|
144
|
+
true
|
|
145
|
+
rescue => e
|
|
146
|
+
warn "XOSS: failed to load cached tokens: #{e.message}"
|
|
147
|
+
false
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def save_tokens
|
|
151
|
+
return unless @access_token
|
|
152
|
+
|
|
153
|
+
FileUtils.mkdir_p(File.dirname(token_path))
|
|
154
|
+
File.write(token_path, JSON.generate({
|
|
155
|
+
access: @access_token,
|
|
156
|
+
refresh: @refresh_token,
|
|
157
|
+
expires_at: @expires_at || Time.now.to_i + ACCESS_TOKEN_TTL
|
|
158
|
+
}))
|
|
159
|
+
rescue => e
|
|
160
|
+
warn "XOSS: failed to save tokens: #{e.message}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def remove_stale_tokens
|
|
164
|
+
File.delete(token_path) if File.exist?(token_path)
|
|
165
|
+
rescue => e
|
|
166
|
+
warn "XOSS: failed to remove stale tokens: #{e.message}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def access_token_valid?
|
|
170
|
+
return false unless @access_token
|
|
171
|
+
return false unless @expires_at
|
|
172
|
+
Time.now.to_i < @expires_at - 60
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def refresh_access_token
|
|
176
|
+
return false unless @refresh_token
|
|
177
|
+
|
|
178
|
+
retries ||= 0
|
|
179
|
+
resp = http_request(:post, REFRESH_PATH,
|
|
180
|
+
body: JSON.generate({ refresh: @refresh_token }),
|
|
181
|
+
headers: { "Content-Type" => "application/json", "Origin" => "xoss" }
|
|
182
|
+
)
|
|
183
|
+
unless resp.code.to_i == 200
|
|
184
|
+
remove_stale_tokens
|
|
185
|
+
return false
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
data = JSON.parse(resp.body)
|
|
189
|
+
unless data["code"] == 0
|
|
190
|
+
remove_stale_tokens
|
|
191
|
+
return false
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
@access_token = data.dig("data", "access")
|
|
195
|
+
@refresh_token = data.dig("data", "refresh")
|
|
196
|
+
return false unless @access_token
|
|
197
|
+
|
|
198
|
+
@expires_at = Time.now.to_i + ACCESS_TOKEN_TTL
|
|
199
|
+
@auth_headers = { "Authorization" => "Bearer #{@access_token}", "Origin" => "xoss" }
|
|
200
|
+
save_tokens
|
|
201
|
+
true
|
|
202
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
203
|
+
if (retries += 1) <= 2
|
|
204
|
+
sleep 2
|
|
205
|
+
retry
|
|
206
|
+
end
|
|
207
|
+
raise SyncError, "XOSS token refresh timeout: #{e.message}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def raw_path(platform_id)
|
|
211
|
+
File.join(raw_dir, "#{platform_id}.fit")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def sync_activities
|
|
215
|
+
pending_ids.filter_map { |p| process_one(p) }.size
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def fetch_page(offset, limit)
|
|
219
|
+
retries ||= 0
|
|
220
|
+
resp = http_request(:get, ACTIVITIES_PATH,
|
|
221
|
+
params: { offset: offset, limit: limit },
|
|
222
|
+
headers: @auth_headers
|
|
223
|
+
)
|
|
224
|
+
return nil unless resp.code.to_i == 200
|
|
225
|
+
|
|
226
|
+
JSON.parse(resp.body).dig("data", "results")
|
|
227
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
228
|
+
if (retries += 1) <= 2
|
|
229
|
+
sleep 2
|
|
230
|
+
retry
|
|
231
|
+
end
|
|
232
|
+
raise SyncError, "XOSS fetch timeout: #{e.message}"
|
|
233
|
+
rescue JSON::ParserError
|
|
234
|
+
if (retries ||= 0; (retries += 1) <= 2)
|
|
235
|
+
sleep 2
|
|
236
|
+
retry
|
|
237
|
+
end
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def tz_resolver
|
|
242
|
+
@tz_resolver ||= GitFit::Timezone::Resolver.new({})
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def resolve_local_time(start_utc, wgs_points)
|
|
246
|
+
return start_utc.iso8601 unless wgs_points&.any?
|
|
247
|
+
|
|
248
|
+
lat = wgs_points.first["positionLat"]
|
|
249
|
+
lng = wgs_points.first["positionLong"]
|
|
250
|
+
return start_utc.iso8601 unless lat && lng
|
|
251
|
+
|
|
252
|
+
tz_string = tz_resolver.resolve(lat, lng)
|
|
253
|
+
tz = TZInfo::Timezone.get(tz_string)
|
|
254
|
+
tz.utc_to_local(start_utc).iso8601
|
|
255
|
+
rescue => e
|
|
256
|
+
warn "XOSS: timezone resolution failed: #{e.message}"
|
|
257
|
+
start_utc.iso8601
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def process_activity(act)
|
|
261
|
+
act_id = act["id"].to_s
|
|
262
|
+
type = map_sport(act["sport"])
|
|
263
|
+
|
|
264
|
+
start_t = Time.at(act["start_timestamp"]).utc rescue nil
|
|
265
|
+
return false unless start_t
|
|
266
|
+
|
|
267
|
+
distance = act["distance"].to_f
|
|
268
|
+
moving_time = act["duration"].to_i
|
|
269
|
+
avg_speed = distance > 0 && moving_time > 0 ? (distance / moving_time).round(2) : nil
|
|
270
|
+
|
|
271
|
+
attrs = {
|
|
272
|
+
run_id: "xoss_#{act_id}",
|
|
273
|
+
name: act["title"] || "XOSS Activity",
|
|
274
|
+
distance: distance,
|
|
275
|
+
moving_time: moving_time > 0 ? moving_time : nil,
|
|
276
|
+
elapsed_time: moving_time > 0 ? moving_time : nil,
|
|
277
|
+
sport_category: type,
|
|
278
|
+
sport_type: act["sub_sport"].to_s,
|
|
279
|
+
start_date: start_t.iso8601,
|
|
280
|
+
start_date_local: start_t.iso8601,
|
|
281
|
+
location_country: nil,
|
|
282
|
+
average_speed: avg_speed,
|
|
283
|
+
elevation_gain: act["elevation_gain"].to_f.nonzero?,
|
|
284
|
+
calories: act["total_calories"]&.to_i&.nonzero?,
|
|
285
|
+
source: "xoss"
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
wgs_points = []
|
|
289
|
+
if act["fit_file_hash"].to_s != ""
|
|
290
|
+
fit_data = download_fit(act_id)
|
|
291
|
+
if fit_data
|
|
292
|
+
write_source_archive(fit_data, act_id, "fit")
|
|
293
|
+
begin
|
|
294
|
+
wgs_points = FIT::Decoder.decode(raw_path(act_id))
|
|
295
|
+
rescue => e
|
|
296
|
+
warn "XOSS: FIT decode failed for #{act_id}: #{e.message}"
|
|
297
|
+
wgs_points = []
|
|
298
|
+
end
|
|
299
|
+
if wgs_points.any?
|
|
300
|
+
write_std(wgs_points, act_id)
|
|
301
|
+
pts = wgs_points.map { |pt| [pt["positionLat"], pt["positionLong"]] }
|
|
302
|
+
attrs[:summary_polyline] = Geo::Polyline.encode(pts)
|
|
303
|
+
attrs.merge!(compute_sensor_summary(wgs_points))
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
attrs[:start_date_local] = resolve_local_time(start_t, wgs_points)
|
|
309
|
+
|
|
310
|
+
upsert_activity(attrs)
|
|
311
|
+
attrs
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def download_fit(act_id)
|
|
315
|
+
resp = http_request(:get, format(FIT_FILE_PATH, id: act_id), headers: @auth_headers)
|
|
316
|
+
return nil unless resp.code.to_i == 302
|
|
317
|
+
|
|
318
|
+
fit_uri = URI(resp["location"])
|
|
319
|
+
fit_http = Net::HTTP.new(fit_uri.host, fit_uri.port)
|
|
320
|
+
fit_http.use_ssl = fit_uri.scheme == "https"
|
|
321
|
+
fit_http.open_timeout = 60
|
|
322
|
+
fit_http.read_timeout = 180
|
|
323
|
+
fit_resp = fit_http.start { |h| h.request(Net::HTTP::Get.new(fit_uri)) }
|
|
324
|
+
return nil unless fit_resp.code.to_i == 200
|
|
325
|
+
|
|
326
|
+
fit_resp.body
|
|
327
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
328
|
+
warn "XOSS: FIT download timeout for #{act_id}: #{e.message}"
|
|
329
|
+
nil
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def write_std(points, act_id)
|
|
333
|
+
dir = std_dir
|
|
334
|
+
FileUtils.mkdir_p(dir)
|
|
335
|
+
tmp = File.join(dir, ".#{act_id}.json.tmp")
|
|
336
|
+
final = File.join(dir, "#{act_id}.json")
|
|
337
|
+
File.write(tmp, JSON.generate(points))
|
|
338
|
+
File.rename(tmp, final)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def compute_sensor_summary(records)
|
|
342
|
+
return {} if records.empty?
|
|
343
|
+
|
|
344
|
+
cad = records.map { |r| r["cadence"] }.compact
|
|
345
|
+
pow = records.map { |r| r["power"] }.compact
|
|
346
|
+
tmp = records.map { |r| r["temperature"] }.compact
|
|
347
|
+
hr = records.map { |r| r["heart_rate"] }.compact
|
|
348
|
+
|
|
349
|
+
{
|
|
350
|
+
average_cadence: cad.any? ? (cad.sum.to_f / cad.size).round(1) : nil,
|
|
351
|
+
max_cadence: cad.max,
|
|
352
|
+
average_power: pow.any? ? (pow.sum.to_f / pow.size).round(1) : nil,
|
|
353
|
+
max_power: pow.max,
|
|
354
|
+
average_temperature: tmp.any? ? (tmp.sum.to_f / tmp.size).round(1) : nil,
|
|
355
|
+
average_heartrate: hr.any? ? (hr.sum.to_f / hr.size).round(1) : nil
|
|
356
|
+
}
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def map_sport(sport)
|
|
360
|
+
SPORT_MAP[sport] || "other"
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def existing_run_ids
|
|
364
|
+
@db[:activities].where(source: "xoss").select_map(:run_id)
|
|
365
|
+
.map { |id| id.sub("xoss_", "") }
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
data/lib/git_fit/version.rb
CHANGED