git-fit 0.23.2 → 0.24.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 +4 -4
- data/lib/git-fit.rb +2 -0
- data/lib/git_fit/cli/sync.rb +15 -6
- data/lib/git_fit/cli.rb +3 -1
- data/lib/git_fit/config_template.rb +8 -0
- data/lib/git_fit/elevation/backfill.rb +23 -1
- data/lib/git_fit/elevation/correct.rb +1 -1
- data/lib/git_fit/std/resolver.rb +1 -1
- data/lib/git_fit/sync/codoon.rb +336 -0
- data/lib/git_fit/sync/joyrun.rb +423 -0
- data/lib/git_fit/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2be1dd5148fb72d95b76d20b31193422edbb0e8be620062a6d2c34d04f577aa0
|
|
4
|
+
data.tar.gz: 2192297d4bce4c23cdd8f9420f9f21e37caa1c562fec5eb2f6abd1235fa7977e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f9156265923a164e1dd88e46d924c7d607a13027dfdcbb5c8206ae768d58f8c27c3ac09e462840c3968eb1cd152f473f0eb4462d484319ab349ec1d9e1573ead
|
|
7
|
+
data.tar.gz: 998b8597afbea703c34761105394e64c3d8c76f75f4109e55a3a43c6b73073a75711d95a2876ab03a1cbff1c2606cc2cb6067d39b775ab049a71087bea9ed830
|
data/lib/git-fit.rb
CHANGED
|
@@ -69,6 +69,8 @@ require_relative 'git_fit/sync/garmin_com'
|
|
|
69
69
|
require_relative 'git_fit/sync/garmin_cn'
|
|
70
70
|
require_relative 'git_fit/sync/strava'
|
|
71
71
|
require_relative 'git_fit/sync/keep'
|
|
72
|
+
require_relative 'git_fit/sync/codoon'
|
|
73
|
+
require_relative 'git_fit/sync/joyrun'
|
|
72
74
|
require_relative 'git_fit/sync/igpsport'
|
|
73
75
|
require_relative 'git_fit/sync/xoss'
|
|
74
76
|
require_relative 'git_fit/sync/xingzhe'
|
data/lib/git_fit/cli/sync.rb
CHANGED
|
@@ -32,19 +32,24 @@ module GitFit
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def auth(source)
|
|
35
|
-
sources = %w[garmin garmin_cn strava]
|
|
35
|
+
sources = %w[garmin garmin_cn strava joyrun]
|
|
36
36
|
unless sources.include?(source)
|
|
37
37
|
say_status :error, "Supported: #{sources.join(', ')}", :red
|
|
38
38
|
return
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
config = git_fit_config.sync_config(source)
|
|
42
|
-
if
|
|
42
|
+
if source == 'joyrun'
|
|
43
|
+
config = config.merge(
|
|
44
|
+
'phone' => options[:phone],
|
|
45
|
+
'sms_code' => options[:sms_code],
|
|
46
|
+
)
|
|
47
|
+
elsif config.empty?
|
|
43
48
|
say_status :warn, "No config for #{source}. Set env vars or config.yml", :yellow
|
|
44
49
|
return
|
|
45
50
|
end
|
|
46
51
|
|
|
47
|
-
adapter = build_auth_adapter(source)
|
|
52
|
+
adapter = build_auth_adapter(source, config: config, interactive_auth: source == 'joyrun')
|
|
48
53
|
unless adapter.respond_to?(:authenticate)
|
|
49
54
|
say_status :error, "#{source} does not support interactive auth", :red
|
|
50
55
|
return
|
|
@@ -63,6 +68,10 @@ module GitFit
|
|
|
63
68
|
puts 'Local config:'
|
|
64
69
|
puts " #{source}:"
|
|
65
70
|
puts " secret: (已自动写入 #{GitFit::Config.default_path})"
|
|
71
|
+
elsif adapter.respond_to?(:generated_secrets) && (secrets = adapter.generated_secrets)
|
|
72
|
+
puts ''
|
|
73
|
+
puts 'GitHub Actions Secrets:'
|
|
74
|
+
secrets.each { |name, value| puts " SYNC_JOYRUN_#{name} = #{value}" }
|
|
66
75
|
end
|
|
67
76
|
else
|
|
68
77
|
say_status :error, 'Authentication failed', :red
|
|
@@ -73,13 +82,13 @@ module GitFit
|
|
|
73
82
|
|
|
74
83
|
private
|
|
75
84
|
|
|
76
|
-
def build_auth_adapter(source)
|
|
85
|
+
def build_auth_adapter(source, config: nil, interactive_auth: false)
|
|
77
86
|
klass = GitFit::Sync::Base.adapters.find { |a| a.config_key == source }
|
|
78
87
|
return nil unless klass
|
|
79
88
|
|
|
80
|
-
cfg = git_fit_config.sync_config(source).dup
|
|
89
|
+
cfg = (config || git_fit_config.sync_config(source)).dup
|
|
81
90
|
cfg['config_path'] = options[:config] if options[:config]
|
|
82
|
-
klass.new(config: cfg, db: nil)
|
|
91
|
+
klass.new(config: cfg, db: nil, interactive_auth: interactive_auth)
|
|
83
92
|
end
|
|
84
93
|
end
|
|
85
94
|
end
|
data/lib/git_fit/cli.rb
CHANGED
|
@@ -76,12 +76,14 @@ module GitFit
|
|
|
76
76
|
super
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
-
desc 'auth SOURCE', 'Authenticate with a sync source (strava, garmin, garmin_cn)'
|
|
79
|
+
desc 'auth SOURCE', 'Authenticate with a sync source (strava, garmin, garmin_cn, joyrun)'
|
|
80
80
|
option :A, type: :boolean, desc: 'Garmin Strategy A: curl-impersonate shellout'
|
|
81
81
|
option :B, type: :boolean, desc: 'Garmin Strategy B: Playwright'
|
|
82
82
|
option :auto, type: :boolean, desc: 'Garmin auto: B first, fallback to A (default)'
|
|
83
83
|
option :sync, type: :boolean, desc: 'Garmin: promote cached token to auth_seed'
|
|
84
84
|
option :mfa_code, type: :string, desc: 'Garmin MFA code (from email), re-run to continue'
|
|
85
|
+
option :phone, type: :string, desc: 'Joyrun phone number'
|
|
86
|
+
option :sms_code, type: :string, desc: 'Joyrun SMS code'
|
|
85
87
|
def auth(source)
|
|
86
88
|
if source == 'garmin'
|
|
87
89
|
GitFit::Auth::Garmin.new(options, git_fit_config).call
|
|
@@ -35,6 +35,14 @@ module GitFit
|
|
|
35
35
|
# phone: "" # env: GIT_FIT_KEEP_PASSWORD
|
|
36
36
|
# password: ""
|
|
37
37
|
|
|
38
|
+
# codoon: # env: GIT_FIT_CODOON_PHONE
|
|
39
|
+
# phone: "" # env: GIT_FIT_CODOON_PASSWORD
|
|
40
|
+
# password: ""
|
|
41
|
+
|
|
42
|
+
# joyrun: # env: GIT_FIT_JOYRUN_UID
|
|
43
|
+
# uid: ""
|
|
44
|
+
# sid: "" # env: GIT_FIT_JOYRUN_SID / git fit auth joyrun
|
|
45
|
+
|
|
38
46
|
# igpsport: # env: GIT_FIT_IGPSPORT_PHONE
|
|
39
47
|
# phone: "" # env: GIT_FIT_IGPSPORT_PASSWORD
|
|
40
48
|
# password: ""
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module GitFit
|
|
4
4
|
module Elevation
|
|
5
5
|
class Backfill
|
|
6
|
-
SOURCES = %w[igpsport xoss strava garmin garmin_cn xingzhe keep].freeze
|
|
6
|
+
SOURCES = %w[igpsport xoss strava garmin garmin_cn xingzhe keep codoon joyrun].freeze
|
|
7
7
|
BATCH_SIZE = 100
|
|
8
8
|
|
|
9
9
|
def initialize(db, force: false)
|
|
@@ -63,6 +63,7 @@ module GitFit
|
|
|
63
63
|
when 'strava' then compute_from_l2(source, natural_id)
|
|
64
64
|
when 'xingzhe' then compute_from_xingzhe(natural_id)
|
|
65
65
|
when 'keep' then compute_from_keep(natural_id)
|
|
66
|
+
when 'codoon', 'joyrun' then compute_from_std(source, natural_id)
|
|
66
67
|
else return
|
|
67
68
|
end
|
|
68
69
|
|
|
@@ -105,6 +106,27 @@ module GitFit
|
|
|
105
106
|
nil
|
|
106
107
|
end
|
|
107
108
|
|
|
109
|
+
def compute_from_std(source, natural_id)
|
|
110
|
+
l2_path = File.join('data', 'std', source, "#{natural_id}.json")
|
|
111
|
+
return nil unless File.exist?(l2_path)
|
|
112
|
+
|
|
113
|
+
l2 = JSON.parse(File.read(l2_path))
|
|
114
|
+
return nil unless l2.is_a?(Array)
|
|
115
|
+
|
|
116
|
+
alts = l2.filter_map { |point| point['altitude']&.to_f if point.is_a?(Hash) }
|
|
117
|
+
return nil if alts.size < 2
|
|
118
|
+
|
|
119
|
+
{
|
|
120
|
+
elevation_gain: elevation_gain_from_alts(alts, threshold: 3.0).round(1),
|
|
121
|
+
elevation_loss: elevation_loss_from_alts(alts, threshold: 3.0).round(1),
|
|
122
|
+
elevation_min: alts.min&.round(1),
|
|
123
|
+
elevation_max: alts.max&.round(1),
|
|
124
|
+
}
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
warn "Elevation backfill [#{source}][#{natural_id}]: #{e.message}"
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
108
130
|
def compute_from_keep(natural_id)
|
|
109
131
|
l2_path = File.join('data', 'std', 'keep', "#{natural_id}.json")
|
|
110
132
|
return nil unless File.exist?(l2_path)
|
|
@@ -6,7 +6,7 @@ module GitFit
|
|
|
6
6
|
# threshold → activities.elevation_*_terrain. Raw device/API values stay in
|
|
7
7
|
# elevation_* (written by Backfill); this class only fills the _terrain columns.
|
|
8
8
|
class Correct
|
|
9
|
-
SOURCES = %w[garmin garmin_cn strava keep igpsport xoss xingzhe 2bulu apple_health].freeze
|
|
9
|
+
SOURCES = %w[garmin garmin_cn strava keep igpsport xoss xingzhe 2bulu apple_health codoon joyrun].freeze
|
|
10
10
|
BARO_SOURCES = %w[xingzhe 2bulu apple_health].freeze
|
|
11
11
|
BATCH_SIZE = 100
|
|
12
12
|
STATIC_HYST_BARO = 2.0
|
data/lib/git_fit/std/resolver.rb
CHANGED
|
@@ -5,7 +5,7 @@ module GitFit
|
|
|
5
5
|
module Resolver
|
|
6
6
|
# garmin_cn 必须排在 garmin 前面:"garmin_cn_7".start_with?("garmin_") == true
|
|
7
7
|
SOURCE_PREFIXES = %w[
|
|
8
|
-
garmin_cn garmin strava keep igpsport xoss xingzhe
|
|
8
|
+
garmin_cn garmin strava keep igpsport xoss xingzhe codoon joyrun
|
|
9
9
|
apple_health gpx fit tcx
|
|
10
10
|
].freeze
|
|
11
11
|
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require 'cgi'
|
|
5
|
+
require 'openssl'
|
|
6
|
+
require 'time'
|
|
7
|
+
|
|
8
|
+
module GitFit
|
|
9
|
+
module Sync
|
|
10
|
+
class Codoon < Base
|
|
11
|
+
BASE_URL = 'https://api.codoon.com'
|
|
12
|
+
TOKEN_PATH = '/token'
|
|
13
|
+
LIST_PATH = '/api/get_old_route_log'
|
|
14
|
+
DETAIL_PATH = '/api/get_single_log'
|
|
15
|
+
BASIC_AUTH = 'MDk5Y2NlMjhjMDVmNmMzOWFkNWUwNGU1MWVkNjA3MDQ6YzM5ZDNmYmVhMWU4NWJlY2VlNDFjMTk5N2FjZjBlMzY='
|
|
16
|
+
CLIENT_ID = '099cce28c05f6c39ad5e04e51ed60704'
|
|
17
|
+
SIGNATURE_KEY = 'ecc140ad6e1e12f7d972af04add2c7ee'
|
|
18
|
+
DID = '24-ffffffff-faac-3052-0033-c5870033c587'
|
|
19
|
+
USER_AGENT = 'CodoonSport(8.9.0 1170;Android 7;Sony XZ1)'
|
|
20
|
+
PAGE_SIZE = 500
|
|
21
|
+
CHINA_OFFSET = '+08:00'
|
|
22
|
+
|
|
23
|
+
SPORT_MAP = { 0 => 'hike', 1 => 'run', 2 => 'ride' }.freeze
|
|
24
|
+
|
|
25
|
+
register_adapter
|
|
26
|
+
register_config :phone, :password
|
|
27
|
+
|
|
28
|
+
def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil)
|
|
29
|
+
super
|
|
30
|
+
@phone = @config['phone']
|
|
31
|
+
@password = @config['password']
|
|
32
|
+
@access_token = nil
|
|
33
|
+
@user_id = nil
|
|
34
|
+
@last_http_code = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def before_call
|
|
38
|
+
return credentials_missing unless credentials_present?
|
|
39
|
+
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def authenticate
|
|
44
|
+
params = {
|
|
45
|
+
'client_id' => CLIENT_ID,
|
|
46
|
+
'email' => @phone,
|
|
47
|
+
'grant_type' => 'password',
|
|
48
|
+
'password' => @password,
|
|
49
|
+
'scope' => 'user',
|
|
50
|
+
}
|
|
51
|
+
resp = http_request(:get, TOKEN_PATH, params: params)
|
|
52
|
+
data = parse_json(resp)
|
|
53
|
+
valid_login = resp.code.to_i == 200 && data.is_a?(Hash) &&
|
|
54
|
+
data['access_token'].is_a?(String) && !data['access_token'].empty? &&
|
|
55
|
+
!data['user_id'].nil?
|
|
56
|
+
return login_failed(resp, data) unless valid_login
|
|
57
|
+
|
|
58
|
+
@access_token = data['access_token']
|
|
59
|
+
@user_id = data['user_id'].to_s
|
|
60
|
+
@auth_method = 'login'
|
|
61
|
+
true
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
warn "Codoon: login failed: #{e.message}\n Fix: 检查 GIT_FIT_CODOON_PHONE/PASSWORD"
|
|
64
|
+
false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def pending_ids
|
|
68
|
+
existing = existing_run_ids
|
|
69
|
+
ids = []
|
|
70
|
+
page = 1
|
|
71
|
+
loop do
|
|
72
|
+
items, has_more = fetch_page(page)
|
|
73
|
+
break if items.nil? || items.empty?
|
|
74
|
+
|
|
75
|
+
items.each do |item|
|
|
76
|
+
id = item['log_id'].to_s
|
|
77
|
+
next if id.empty?
|
|
78
|
+
|
|
79
|
+
type = map_sport(item['sports_type'])
|
|
80
|
+
next if skip_type?(type)
|
|
81
|
+
|
|
82
|
+
has_raw = raw_file_exists?(id)
|
|
83
|
+
next if existing.include?(id) && has_raw
|
|
84
|
+
|
|
85
|
+
ids << { id: id, route_id: item['route_id'].to_s, type: type, has_raw: has_raw }
|
|
86
|
+
end
|
|
87
|
+
page += 1
|
|
88
|
+
break unless has_more
|
|
89
|
+
end
|
|
90
|
+
ids
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def process_one(pending)
|
|
94
|
+
return load_raw_activity(pending[:id]) if pending[:has_raw]
|
|
95
|
+
|
|
96
|
+
resp = http_request(:post, DETAIL_PATH, body: { route_id: pending[:route_id] },
|
|
97
|
+
token: @access_token)
|
|
98
|
+
data = parse_json(resp)
|
|
99
|
+
return false unless resp.code.to_i == 200 && data.is_a?(Hash) && data['data'].is_a?(Hash)
|
|
100
|
+
|
|
101
|
+
write_source_archive(data, pending[:id])
|
|
102
|
+
upsert_from_detail(pending[:id], data['data'])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.signature(auth, path_with_query, body: '', timestamp: 0, key: nil)
|
|
106
|
+
path, query = path_with_query.split('?', 2)
|
|
107
|
+
query = URI.decode_www_form_component(query.to_s)
|
|
108
|
+
message = "Authorization=#{auth}&Davinci=0&Did=#{DID}&Timestamp=#{timestamp}" \
|
|
109
|
+
"|path=#{path}|body=#{body}|#{query}"
|
|
110
|
+
key ||= SIGNATURE_KEY
|
|
111
|
+
Base64.strict_encode64(OpenSSL::HMAC.digest('sha1', key, message))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def fetch_page(page)
|
|
117
|
+
resp = http_request(:post, LIST_PATH,
|
|
118
|
+
body: { limit: PAGE_SIZE, page: page, user_id: @user_id },
|
|
119
|
+
token: @access_token)
|
|
120
|
+
data = parse_json(resp)
|
|
121
|
+
return [[], false] unless resp.code.to_i == 200 && data.is_a?(Hash) && data['data'].is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
[Array(data.dig('data', 'log_list')), data.dig('data', 'has_more') ? true : false]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def http_request(method, path, params: nil, body: nil, token: nil)
|
|
127
|
+
uri = URI.join(BASE_URL, path)
|
|
128
|
+
uri.query = URI.encode_www_form(params) if params
|
|
129
|
+
json_body = body ? JSON.generate(body) : ''
|
|
130
|
+
timestamp = method == :get ? 0 : Time.now.to_i
|
|
131
|
+
auth = token ? "Bearer #{token}" : "Basic #{BASIC_AUTH}"
|
|
132
|
+
|
|
133
|
+
request = method == :get ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
|
|
134
|
+
request['User-Agent'] = USER_AGENT
|
|
135
|
+
request['did'] = DID
|
|
136
|
+
request['davinci'] = '0'
|
|
137
|
+
request['authorization'] = auth
|
|
138
|
+
request['timestamp'] = timestamp.to_s
|
|
139
|
+
request['signature'] = self.class.signature(auth, uri.request_uri, body: json_body, timestamp: timestamp)
|
|
140
|
+
unless method == :get
|
|
141
|
+
request['Content-Type'] =
|
|
142
|
+
token ? 'application/json; charset=utf-8' : 'application/x-www-form-urlencode; charset=utf-8'
|
|
143
|
+
request.body = json_body
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
147
|
+
http.use_ssl = true
|
|
148
|
+
http.open_timeout = 30
|
|
149
|
+
http.read_timeout = 60
|
|
150
|
+
response = http.start { |client| client.request(request) }
|
|
151
|
+
@last_http_code = response.code.to_i
|
|
152
|
+
response
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def load_raw_activity(id)
|
|
156
|
+
data = JSON.parse(File.read(raw_path(id)))
|
|
157
|
+
detail = data['data']
|
|
158
|
+
return false unless detail.is_a?(Hash)
|
|
159
|
+
|
|
160
|
+
upsert_from_detail(id, detail)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def upsert_from_detail(log_id, detail)
|
|
164
|
+
attrs = build_activity(log_id, detail)
|
|
165
|
+
return false unless attrs
|
|
166
|
+
|
|
167
|
+
points = attrs.delete(:standard_points)
|
|
168
|
+
write_std(points, log_id)
|
|
169
|
+
upsert_activity(attrs)
|
|
170
|
+
attrs
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def build_activity(log_id, detail)
|
|
174
|
+
start_time = parse_beijing_time(detail['start_time'])
|
|
175
|
+
end_time = parse_beijing_time(detail['end_time'])
|
|
176
|
+
moving_time = detail['total_time'].to_i
|
|
177
|
+
return nil unless start_time && moving_time.positive?
|
|
178
|
+
|
|
179
|
+
points = standard_points(detail['points'])
|
|
180
|
+
return nil if points.empty?
|
|
181
|
+
|
|
182
|
+
hr_values = heart_rate_values(detail['heart_rate'])
|
|
183
|
+
cadence_values = cadence_values(detail)
|
|
184
|
+
distance = detail['total_length'].to_f
|
|
185
|
+
elapsed = end_time ? [(end_time - start_time).round, 0].max : moving_time
|
|
186
|
+
sport = map_sport(detail['sports_type'])
|
|
187
|
+
|
|
188
|
+
official_cadence = numeric(detail['average_step_cadence'])
|
|
189
|
+
attrs = {
|
|
190
|
+
run_id: "codoon_#{log_id}",
|
|
191
|
+
name: "#{sport.capitalize} from Codoon",
|
|
192
|
+
distance: distance.positive? ? distance : nil,
|
|
193
|
+
moving_time: moving_time,
|
|
194
|
+
elapsed_time: elapsed,
|
|
195
|
+
sport_category: sport,
|
|
196
|
+
sport_type: detail['sports_type'].to_s,
|
|
197
|
+
start_date: Time.at(start_time.to_i).utc.iso8601,
|
|
198
|
+
start_date_local: start_time.iso8601,
|
|
199
|
+
average_heartrate: average(hr_values),
|
|
200
|
+
max_heartrate: hr_values.max,
|
|
201
|
+
average_cadence: official_cadence&.positive? ? official_cadence : average(cadence_values),
|
|
202
|
+
max_cadence: cadence_values.max,
|
|
203
|
+
calories: numeric(detail['total_calories'])&.round,
|
|
204
|
+
average_speed: distance.positive? && moving_time.positive? ? (distance / moving_time).round(2) : nil,
|
|
205
|
+
source: 'codoon',
|
|
206
|
+
}
|
|
207
|
+
attrs.merge!(point_attributes(points))
|
|
208
|
+
attrs[:standard_points] = points
|
|
209
|
+
attrs
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def point_attributes(points)
|
|
213
|
+
return { summary_polyline: nil } if points.empty?
|
|
214
|
+
|
|
215
|
+
alts = points.filter_map { |point| numeric(point[:altitude]) }
|
|
216
|
+
{
|
|
217
|
+
summary_polyline: Geo::Polyline.encode(points.map { |point| [point[:latitude], point[:longitude]] }),
|
|
218
|
+
elevation_gain: elevation_delta(alts, :gain),
|
|
219
|
+
elevation_loss: elevation_delta(alts, :loss),
|
|
220
|
+
elevation_min: alts.min&.round(1),
|
|
221
|
+
elevation_max: alts.max&.round(1),
|
|
222
|
+
}
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def standard_points(points)
|
|
226
|
+
Array(points).filter_map do |point|
|
|
227
|
+
lat = numeric(point['latitude'])
|
|
228
|
+
lng = numeric(point['longitude'])
|
|
229
|
+
point_time = parse_beijing_time(point['time_stamp'])
|
|
230
|
+
next unless lat && lng
|
|
231
|
+
next unless point_time
|
|
232
|
+
|
|
233
|
+
lat, lng = Geo::CoordTransform.gcj02_to_wgs84_exact(lat, lng)
|
|
234
|
+
{
|
|
235
|
+
latitude: lat.round(7),
|
|
236
|
+
longitude: lng.round(7),
|
|
237
|
+
altitude: numeric(point['elevation']),
|
|
238
|
+
timestamp: Time.at(point_time.to_i).utc.iso8601,
|
|
239
|
+
}
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def heart_rate_values(values)
|
|
244
|
+
return [] unless values.is_a?(Hash)
|
|
245
|
+
|
|
246
|
+
values.values.filter_map { |value| hr_value(value) }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def cadence_values(detail)
|
|
250
|
+
Array(detail['user_steps_list_perm']).filter_map do |entry|
|
|
251
|
+
value = entry.is_a?(Array) ? entry[1] : entry['value'] || entry['step']
|
|
252
|
+
value = numeric(value)
|
|
253
|
+
value if value&.positive?
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def hr_value(value)
|
|
258
|
+
value = numeric(value)
|
|
259
|
+
value if value&.positive?
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def parse_beijing_time(value)
|
|
263
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
264
|
+
|
|
265
|
+
Time.parse("#{value.to_s.sub(/\.\d+\z/, '')}#{CHINA_OFFSET}")
|
|
266
|
+
rescue ArgumentError, TypeError
|
|
267
|
+
nil
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def map_sport(value)
|
|
271
|
+
SPORT_MAP[numeric(value)&.to_i] || 'other'
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def credentials_present?
|
|
275
|
+
[@phone, @password].all? { |value| value.is_a?(String) && !value.empty? }
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def credentials_missing
|
|
279
|
+
warn "Codoon: credentials not configured\n Fix: 检查 GIT_FIT_CODOON_PHONE/PASSWORD"
|
|
280
|
+
false
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def login_failed(resp, data)
|
|
284
|
+
reason = data.is_a?(Hash) ? data['description'] || data['error'] : nil
|
|
285
|
+
detail = reason ? ": #{reason}" : ''
|
|
286
|
+
warn "Codoon: login failed (HTTP #{resp.code}#{detail})\n Fix: 检查 GIT_FIT_CODOON_PHONE/PASSWORD"
|
|
287
|
+
false
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def parse_json(resp)
|
|
291
|
+
JSON.parse(resp.body)
|
|
292
|
+
rescue JSON::ParserError, TypeError
|
|
293
|
+
nil
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def numeric(value)
|
|
297
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
298
|
+
|
|
299
|
+
Float(value)
|
|
300
|
+
rescue ArgumentError, TypeError
|
|
301
|
+
nil
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def average(values)
|
|
305
|
+
return nil if values.empty?
|
|
306
|
+
|
|
307
|
+
(values.sum.to_f / values.size).round(1)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def elevation_delta(altitudes, direction)
|
|
311
|
+
return nil if altitudes.size < 2
|
|
312
|
+
|
|
313
|
+
total = 0.0
|
|
314
|
+
(1...altitudes.size).each do |index|
|
|
315
|
+
delta = altitudes[index] - altitudes[index - 1]
|
|
316
|
+
total += delta if direction == :gain && delta.positive?
|
|
317
|
+
total -= delta if direction == :loss && delta.negative?
|
|
318
|
+
end
|
|
319
|
+
total.round(1)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def raw_path(platform_id)
|
|
323
|
+
File.join(raw_dir, "#{platform_id}.json")
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def write_std(points, platform_id)
|
|
327
|
+
dir = std_dir
|
|
328
|
+
FileUtils.mkdir_p(dir)
|
|
329
|
+
tmp = File.join(dir, ".#{platform_id}.json.tmp")
|
|
330
|
+
final = File.join(dir, "#{platform_id}.json")
|
|
331
|
+
File.write(tmp, JSON.generate(points))
|
|
332
|
+
File.rename(tmp, final)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module GitFit
|
|
8
|
+
module Sync
|
|
9
|
+
class Joyrun < Base
|
|
10
|
+
BASE_URL = 'https://api.thejoyrun.com'
|
|
11
|
+
LOGIN_PATH = '/user/login/phonecode'
|
|
12
|
+
LIST_PATH = '/userRunList.aspx'
|
|
13
|
+
DETAIL_PATH = '/Run/GetInfo.aspx'
|
|
14
|
+
SALT_V1 = '1fd6e28fd158406995f77727b35bf20a'
|
|
15
|
+
SALT_V2 = '0C077B1E70F5FDDE6F497C1315687F9C'
|
|
16
|
+
BASE_HEADERS = {
|
|
17
|
+
'Accept-Language' => 'en_US',
|
|
18
|
+
'User-Agent' => 'okhttp/3.10.0',
|
|
19
|
+
'Host' => 'api.thejoyrun.com',
|
|
20
|
+
'Connection' => 'Keep-Alive',
|
|
21
|
+
}.freeze
|
|
22
|
+
DEVICE_HEADERS = {
|
|
23
|
+
'MODELTYPE' => 'Xiaomi MI 5',
|
|
24
|
+
'SYSVERSION' => '8.0.0',
|
|
25
|
+
'APPVERSION' => '4.2.0',
|
|
26
|
+
}.freeze
|
|
27
|
+
SPORT_MAP = { 0 => 'hike', 1 => 'run', 2 => 'ride' }.freeze
|
|
28
|
+
|
|
29
|
+
register_adapter
|
|
30
|
+
register_config :uid, :sid
|
|
31
|
+
|
|
32
|
+
attr_reader :generated_secrets
|
|
33
|
+
|
|
34
|
+
def initialize(config:, db:, activity_filter: nil, privacy: nil, time_budget: nil,
|
|
35
|
+
interactive_auth: false)
|
|
36
|
+
super(config: config, db: db, activity_filter: activity_filter, privacy: privacy,
|
|
37
|
+
time_budget: time_budget)
|
|
38
|
+
@uid = present_string(@config['uid'])
|
|
39
|
+
@sid = present_string(@config['sid'])
|
|
40
|
+
@phone = present_string(@config['phone'])
|
|
41
|
+
@sms_code = present_string(@config['sms_code'])
|
|
42
|
+
@interactive_auth = interactive_auth
|
|
43
|
+
@generated_secrets = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def before_call
|
|
47
|
+
return super if credentials_present?
|
|
48
|
+
return false unless @interactive_auth
|
|
49
|
+
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def authenticate
|
|
54
|
+
return true if credentials_present?
|
|
55
|
+
return false unless @interactive_auth
|
|
56
|
+
|
|
57
|
+
@phone ||= prompt('Joyrun phone number: ')
|
|
58
|
+
@sms_code ||= prompt('Joyrun SMS code: ')
|
|
59
|
+
login_with_sms(@phone, @sms_code)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def login_with_sms(phone, code)
|
|
63
|
+
resp = http_request(:get, LOGIN_PATH, params: {
|
|
64
|
+
'phoneNumber' => phone,
|
|
65
|
+
'identifyingCode' => code,
|
|
66
|
+
})
|
|
67
|
+
data = parse_json(resp)
|
|
68
|
+
return login_failed(resp, data) unless resp.code.to_i == 200 && data['ret'].to_s == '0'
|
|
69
|
+
|
|
70
|
+
@sid = data.dig('data', 'sid').to_s
|
|
71
|
+
@uid = data.dig('data', 'user', 'uid').to_s
|
|
72
|
+
return login_failed(resp, data) if @sid.empty? || @uid.empty?
|
|
73
|
+
|
|
74
|
+
@generated_secrets = { 'UID' => @uid, 'SID' => @sid }
|
|
75
|
+
@auth_method = 'sms'
|
|
76
|
+
true
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
warn "Joyrun: SMS login failed: #{e.message}"
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def pending_ids
|
|
83
|
+
existing = existing_run_ids
|
|
84
|
+
resp = http_request(:post, LIST_PATH, body: { 'year' => 0 })
|
|
85
|
+
data = parse_json(resp)
|
|
86
|
+
raise SyncError, sid_error_message unless sid_valid?(resp, data)
|
|
87
|
+
return [] unless data.is_a?(Hash) && data['datas'].is_a?(Array)
|
|
88
|
+
|
|
89
|
+
data['datas'].filter_map do |item|
|
|
90
|
+
id = item['fid'].to_s
|
|
91
|
+
next if id.empty? || (existing.include?(id) && raw_file_exists?(id))
|
|
92
|
+
|
|
93
|
+
type = map_sport(item['type'])
|
|
94
|
+
next if skip_type?(type)
|
|
95
|
+
|
|
96
|
+
{ id: id, type: type, has_raw: raw_file_exists?(id) }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def process_one(pending)
|
|
101
|
+
return load_raw_activity(pending[:id]) if pending[:has_raw]
|
|
102
|
+
|
|
103
|
+
resp = http_request(:post, DETAIL_PATH, body: { 'fid' => pending[:id], 'wgs' => 1 })
|
|
104
|
+
data = parse_json(resp)
|
|
105
|
+
raise SyncError, sid_error_message unless sid_valid?(resp, data)
|
|
106
|
+
return false unless data.is_a?(Hash) && data['runrecord'].is_a?(Hash)
|
|
107
|
+
|
|
108
|
+
write_source_archive(data, pending[:id])
|
|
109
|
+
upsert_from_detail(data['runrecord'])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.signature(params, uid, sid, salt, timestamp: nil)
|
|
113
|
+
timestamp ||= params['timestamp'] || Time.now.to_i
|
|
114
|
+
signed = params.merge('timestamp' => timestamp)
|
|
115
|
+
pre_string = signed.sort.map { |key, value| "#{key}#{value}" }.join
|
|
116
|
+
pre_string = "#{pre_string}#{salt}#{uid}#{sid}"
|
|
117
|
+
Digest::MD5.hexdigest(pre_string).upcase
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def http_request(method, path, params: nil, body: nil)
|
|
123
|
+
timestamp = Time.now.to_i
|
|
124
|
+
sign_v1 = self.class.signature(params || body || {}, @uid, @sid, SALT_V1, timestamp: timestamp)
|
|
125
|
+
sign_v2 = self.class.signature(params || body || {}, @uid, @sid, SALT_V2, timestamp: timestamp)
|
|
126
|
+
uri = URI.join(BASE_URL, path)
|
|
127
|
+
|
|
128
|
+
if method == :get
|
|
129
|
+
query = (params || {}).merge('timestamp' => timestamp, 'signature' => sign_v1)
|
|
130
|
+
uri.query = URI.encode_www_form(query)
|
|
131
|
+
request = Net::HTTP::Get.new(uri)
|
|
132
|
+
else
|
|
133
|
+
form = (body || {}).merge('timestamp' => timestamp, 'signature' => sign_v1)
|
|
134
|
+
uri.query = URI.encode_www_form(params) if params
|
|
135
|
+
request = Net::HTTP::Post.new(uri)
|
|
136
|
+
request.body = URI.encode_www_form(form)
|
|
137
|
+
request['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
138
|
+
end
|
|
139
|
+
headers_for_request(request)
|
|
140
|
+
request['_sign'] = sign_v2
|
|
141
|
+
|
|
142
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
143
|
+
http.use_ssl = true
|
|
144
|
+
http.open_timeout = 30
|
|
145
|
+
http.read_timeout = 60
|
|
146
|
+
http.start { |client| client.request(request) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def headers_for_request(request)
|
|
150
|
+
BASE_HEADERS.each { |key, value| request[key] = value }
|
|
151
|
+
DEVICE_HEADERS.each { |key, value| request[key] = value }
|
|
152
|
+
return unless credentials_present?
|
|
153
|
+
|
|
154
|
+
cookie = "sid=#{@sid}&uid=#{@uid}"
|
|
155
|
+
request['ypcookie'] = cookie
|
|
156
|
+
request['Cookie'] = "ypcookie=#{URI.encode_www_form_component(cookie).downcase}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def load_raw_activity(id)
|
|
160
|
+
data = JSON.parse(File.read(raw_path(id)))
|
|
161
|
+
record = data['runrecord']
|
|
162
|
+
return false unless record.is_a?(Hash)
|
|
163
|
+
|
|
164
|
+
upsert_from_detail(record)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def upsert_from_detail(record)
|
|
168
|
+
attrs = build_activity(record)
|
|
169
|
+
return false unless attrs
|
|
170
|
+
|
|
171
|
+
points = attrs.delete(:standard_points)
|
|
172
|
+
write_std(points, record['fid'])
|
|
173
|
+
upsert_activity(attrs)
|
|
174
|
+
attrs
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def build_activity(record)
|
|
178
|
+
started_at = numeric(record['starttime'])
|
|
179
|
+
ended_at = numeric(record['endtime'])
|
|
180
|
+
return nil if started_at.nil? || started_at <= 0 || ended_at.nil? || ended_at <= 0
|
|
181
|
+
|
|
182
|
+
start_time = Time.at(numeric(record['starttime']) || 0).utc
|
|
183
|
+
end_time = Time.at(ended_at).utc
|
|
184
|
+
points = build_points(record, start_time, end_time)
|
|
185
|
+
distance = numeric(record['meter'])
|
|
186
|
+
moving_time = numeric(record['second'])&.round
|
|
187
|
+
sport = map_sport(record['type'])
|
|
188
|
+
hr_values = Array(parse_literal(record['heartrate'])).filter_map do |value|
|
|
189
|
+
value = numeric(value)
|
|
190
|
+
value&.positive? ? value : nil
|
|
191
|
+
end
|
|
192
|
+
location = [record['city'], record['province']].filter_map(&:to_s).reject(&:empty?).join(':')
|
|
193
|
+
|
|
194
|
+
attrs = {
|
|
195
|
+
run_id: "joyrun_#{record['fid']}",
|
|
196
|
+
name: "#{sport.capitalize} from Joyrun",
|
|
197
|
+
distance: distance&.positive? ? distance : nil,
|
|
198
|
+
moving_time: moving_time&.positive? ? moving_time : nil,
|
|
199
|
+
elapsed_time: end_time > start_time ? (end_time - start_time).round : nil,
|
|
200
|
+
sport_category: sport,
|
|
201
|
+
sport_type: record['type'].to_s,
|
|
202
|
+
start_date: start_time.utc.iso8601,
|
|
203
|
+
start_date_local: start_time.utc.getlocal('+08:00').iso8601,
|
|
204
|
+
location_country: location.empty? ? nil : location,
|
|
205
|
+
summary_polyline: if points.any?
|
|
206
|
+
Geo::Polyline.encode(points.map do |point|
|
|
207
|
+
point.values_at(:latitude, :longitude)
|
|
208
|
+
end)
|
|
209
|
+
end,
|
|
210
|
+
average_heartrate: average(hr_values),
|
|
211
|
+
max_heartrate: hr_values.max,
|
|
212
|
+
average_speed: distance&.positive? && moving_time&.positive? ? (distance / moving_time).round(2) : nil,
|
|
213
|
+
source: 'joyrun',
|
|
214
|
+
}
|
|
215
|
+
attrs.merge!(point_attributes(points))
|
|
216
|
+
attrs[:standard_points] = points
|
|
217
|
+
attrs
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def build_points(record, start_time, end_time)
|
|
221
|
+
coordinates = parse_literal(record['content'].to_s.gsub(']-[', '],['))
|
|
222
|
+
return [] unless coordinates.is_a?(Array)
|
|
223
|
+
|
|
224
|
+
coordinates = coordinates.filter_map do |pair|
|
|
225
|
+
next unless pair.is_a?(Array) && pair.size >= 2
|
|
226
|
+
|
|
227
|
+
lat = numeric(pair[0])
|
|
228
|
+
lng = numeric(pair[1])
|
|
229
|
+
next unless lat && lng
|
|
230
|
+
|
|
231
|
+
[lat / 1_000_000.0, lng / 1_000_000.0]
|
|
232
|
+
end
|
|
233
|
+
return [] if coordinates.empty?
|
|
234
|
+
|
|
235
|
+
heart_rates = array_or_empty(parse_literal(record['heartrate']))
|
|
236
|
+
altitudes = array_or_empty(parse_literal(record['altitude']))
|
|
237
|
+
pauses = Array(parse_literal(record['pause'])).filter_map do |entry|
|
|
238
|
+
next unless entry.is_a?(Array) && entry.size >= 2
|
|
239
|
+
|
|
240
|
+
[numeric(entry[0])&.to_i, numeric(entry[1])&.to_i]
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
current_time = start_time
|
|
244
|
+
segment = 0
|
|
245
|
+
pause_index = 0
|
|
246
|
+
coordinates.each_with_index.map do |coordinate, index|
|
|
247
|
+
timestamp = index == coordinates.size - 1 ? end_time : current_time
|
|
248
|
+
point = {
|
|
249
|
+
latitude: coordinate[0],
|
|
250
|
+
longitude: coordinate[1],
|
|
251
|
+
altitude: numeric(at_altitude(altitudes, index)),
|
|
252
|
+
timestamp: timestamp.utc.iso8601,
|
|
253
|
+
heartrate: hr_value(at_value(heart_rates, index)),
|
|
254
|
+
segment: segment,
|
|
255
|
+
}
|
|
256
|
+
unless index == coordinates.size - 1
|
|
257
|
+
current_time += 5
|
|
258
|
+
pause = pauses[pause_index]
|
|
259
|
+
if pause && pause[0] && pause[1] && pause[0] - 1 == index
|
|
260
|
+
current_time += pause[1]
|
|
261
|
+
pause_index += 1
|
|
262
|
+
segment += 1
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
point
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def point_attributes(points)
|
|
270
|
+
return { summary_polyline: nil } if points.empty?
|
|
271
|
+
|
|
272
|
+
alts = points.filter_map { |point| point[:altitude] }
|
|
273
|
+
{
|
|
274
|
+
elevation_gain: elevation_delta(alts, :gain),
|
|
275
|
+
elevation_loss: elevation_delta(alts, :loss),
|
|
276
|
+
elevation_min: alts.min&.round(1),
|
|
277
|
+
elevation_max: alts.max&.round(1),
|
|
278
|
+
}
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def at_value(values, index)
|
|
282
|
+
value = values[index]
|
|
283
|
+
value.is_a?(Array) ? value.first : value
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def at_altitude(values, index)
|
|
287
|
+
at_value(values, index)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def hr_value(value)
|
|
291
|
+
value = numeric(value)
|
|
292
|
+
value&.positive? ? value : nil
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def sid_valid?(resp, data)
|
|
296
|
+
resp.code.to_i == 200 && !(data.is_a?(Hash) && data.key?('ret') && data['ret'].to_s != '0')
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def sid_error_message
|
|
300
|
+
"Joyrun: sid expired or invalid\n Re-auth: git fit auth joyrun"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def map_sport(value)
|
|
304
|
+
SPORT_MAP[numeric(value)&.to_i] || 'other'
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def credentials_present?
|
|
308
|
+
!@uid.empty? && !@sid.empty?
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def login_failed(resp, data)
|
|
312
|
+
reason = data.is_a?(Hash) ? data['msg'] : nil
|
|
313
|
+
warn "Joyrun: SMS login failed (HTTP #{resp.code}#{reason ? ": #{reason}" : ''})"
|
|
314
|
+
false
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def prompt(label)
|
|
318
|
+
warn label
|
|
319
|
+
$stdin.gets.to_s.strip
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def present_string(value)
|
|
323
|
+
value.to_s
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def parse_json(resp)
|
|
327
|
+
JSON.parse(resp.body)
|
|
328
|
+
rescue JSON::ParserError, TypeError
|
|
329
|
+
{}
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def parse_literal(value)
|
|
333
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
334
|
+
|
|
335
|
+
JSON.parse(value)
|
|
336
|
+
rescue JSON::ParserError
|
|
337
|
+
parse_python_literal(value)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def parse_python_literal(value)
|
|
341
|
+
text = value.to_s.gsub(']-[', '],[')
|
|
342
|
+
return nil unless text.start_with?('[') && text.end_with?(']')
|
|
343
|
+
|
|
344
|
+
elements = split_top_level(text[1..-2])
|
|
345
|
+
elements.map { |element| parse_literal_element(element) }
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def split_top_level(text)
|
|
349
|
+
groups = []
|
|
350
|
+
depth = 0
|
|
351
|
+
current = +''
|
|
352
|
+
text.each_char do |char|
|
|
353
|
+
case char
|
|
354
|
+
when '[' then depth += 1
|
|
355
|
+
when ']' then depth -= 1
|
|
356
|
+
when ','
|
|
357
|
+
if depth.zero?
|
|
358
|
+
groups << current
|
|
359
|
+
current = +''
|
|
360
|
+
next
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
current << char
|
|
364
|
+
end
|
|
365
|
+
groups << current unless current.empty?
|
|
366
|
+
groups
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def parse_literal_element(element)
|
|
370
|
+
text = element.strip
|
|
371
|
+
return parse_python_literal(text) if text.start_with?('[')
|
|
372
|
+
|
|
373
|
+
value = Float(text)
|
|
374
|
+
value == value.to_i ? value.to_i : value
|
|
375
|
+
rescue ArgumentError, TypeError
|
|
376
|
+
nil
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def array_or_empty(value)
|
|
380
|
+
value.is_a?(Array) ? value : []
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def numeric(value)
|
|
384
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
385
|
+
|
|
386
|
+
Float(value)
|
|
387
|
+
rescue ArgumentError, TypeError
|
|
388
|
+
nil
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def average(values)
|
|
392
|
+
return nil if values.empty?
|
|
393
|
+
|
|
394
|
+
(values.sum.to_f / values.size).round(1)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def elevation_delta(altitudes, direction)
|
|
398
|
+
return nil if altitudes.size < 2
|
|
399
|
+
|
|
400
|
+
total = 0.0
|
|
401
|
+
(1...altitudes.size).each do |index|
|
|
402
|
+
delta = altitudes[index] - altitudes[index - 1]
|
|
403
|
+
total += delta if direction == :gain && delta.positive?
|
|
404
|
+
total -= delta if direction == :loss && delta.negative?
|
|
405
|
+
end
|
|
406
|
+
total.round(1)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def raw_path(platform_id)
|
|
410
|
+
File.join(raw_dir, "#{platform_id}.json")
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def write_std(points, platform_id)
|
|
414
|
+
dir = std_dir
|
|
415
|
+
FileUtils.mkdir_p(dir)
|
|
416
|
+
tmp = File.join(dir, ".#{platform_id}.json.tmp")
|
|
417
|
+
final = File.join(dir, "#{platform_id}.json")
|
|
418
|
+
File.write(tmp, JSON.generate(points))
|
|
419
|
+
File.rename(tmp, final)
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
data/lib/git_fit/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.24.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lax
|
|
@@ -363,11 +363,13 @@ files:
|
|
|
363
363
|
- lib/git_fit/std/resolver.rb
|
|
364
364
|
- lib/git_fit/strava_web/file_check.rb
|
|
365
365
|
- lib/git_fit/sync/base.rb
|
|
366
|
+
- lib/git_fit/sync/codoon.rb
|
|
366
367
|
- lib/git_fit/sync/garmin_base.rb
|
|
367
368
|
- lib/git_fit/sync/garmin_base_di.rb
|
|
368
369
|
- lib/git_fit/sync/garmin_cn.rb
|
|
369
370
|
- lib/git_fit/sync/garmin_com.rb
|
|
370
371
|
- lib/git_fit/sync/igpsport.rb
|
|
372
|
+
- lib/git_fit/sync/joyrun.rb
|
|
371
373
|
- lib/git_fit/sync/keep.rb
|
|
372
374
|
- lib/git_fit/sync/lorem.rb
|
|
373
375
|
- lib/git_fit/sync/runner.rb
|