git-fit 0.10.4 → 0.10.6
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 +9 -0
- data/lib/git_fit/auth/garmin_token.rb +32 -0
- data/lib/git_fit/auth/strava.rb +223 -0
- data/lib/git_fit/cli/export.rb +20 -1
- data/lib/git_fit/cli/sync.rb +54 -0
- data/lib/git_fit/cli.rb +29 -0
- data/lib/git_fit/config_template.rb +9 -0
- data/lib/git_fit/dedup/field_elector.rb +81 -0
- data/lib/git_fit/dedup/group_consolidator.rb +58 -0
- data/lib/git_fit/dedup/log_writer.rb +33 -0
- data/lib/git_fit/dedup/matcher.rb +90 -0
- data/lib/git_fit/dedup/service.rb +207 -0
- data/lib/git_fit/dedup/trajectory.rb +243 -0
- data/lib/git_fit/export/gpx.rb +133 -0
- data/lib/git_fit/export/json.rb +31 -8
- data/lib/git_fit/export.rb +1 -0
- data/lib/git_fit/fit.rb +6 -0
- data/lib/git_fit/import/local_file.rb +3 -2
- data/lib/git_fit/std/resolver.rb +32 -0
- data/lib/git_fit/sync/base.rb +11 -0
- data/lib/git_fit/sync/igpsport.rb +1 -1
- data/lib/git_fit/sync/strava.rb +31 -18
- data/lib/git_fit/sync/xoss.rb +1 -1
- data/lib/git_fit/version.rb +1 -1
- metadata +25 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 529a74a588a51b0b04ba1a0e15e629425dec93dd79986c6e6f45a0eda35d5914
|
|
4
|
+
data.tar.gz: 37a81d2e9285b8917c13b36eb343d3b97de27709cf1d71a094216c7a645b3168
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ecb103ba98d635affa43058a695811d061d8be767754e3012efb23aa3a7eb4a85b5ab6041c371da1371fab1afc31100f94f8cf77e11a21ec900b38be29a3dcf
|
|
7
|
+
data.tar.gz: 27610f67729cf037ac1a05772103abcc0899de47a484b929d4d62a4f34184c23921245c84f62ea308861ffa62c4e520e0d39223ecb8908e0c36966e0f6825d6c
|
data/lib/git-fit.rb
CHANGED
|
@@ -58,6 +58,7 @@ require_relative 'git_fit/timezone/resolver'
|
|
|
58
58
|
require_relative 'git_fit/privacy/polyline_filter'
|
|
59
59
|
require_relative 'git_fit/import'
|
|
60
60
|
require_relative 'git_fit/sync/base'
|
|
61
|
+
require_relative 'git_fit/std/resolver'
|
|
61
62
|
require_relative 'git_fit/util'
|
|
62
63
|
require_relative 'git_fit/sync/lorem'
|
|
63
64
|
require_relative 'git_fit/sync/garmin_base'
|
|
@@ -74,6 +75,12 @@ require_relative 'git_fit/cli/sync'
|
|
|
74
75
|
require_relative 'git_fit/db/rebuild'
|
|
75
76
|
require_relative 'git_fit/db/connection'
|
|
76
77
|
require_relative 'git_fit/db/activity'
|
|
78
|
+
require_relative 'git_fit/dedup/matcher'
|
|
79
|
+
require_relative 'git_fit/dedup/trajectory'
|
|
80
|
+
require_relative 'git_fit/dedup/group_consolidator'
|
|
81
|
+
require_relative 'git_fit/dedup/log_writer'
|
|
82
|
+
require_relative 'git_fit/dedup/service'
|
|
83
|
+
require_relative 'git_fit/dedup/field_elector'
|
|
77
84
|
require_relative 'git_fit/export'
|
|
78
85
|
require_relative 'git_fit/db/cli'
|
|
79
86
|
require_relative 'git_fit/install/actions'
|
|
@@ -84,4 +91,6 @@ require_relative 'git_fit/cli/import_cli'
|
|
|
84
91
|
require_relative 'git_fit/cli/geo_cli'
|
|
85
92
|
require_relative 'git_fit/strava_web/file_check'
|
|
86
93
|
require_relative 'git_fit/cli/strava'
|
|
94
|
+
require_relative 'git_fit/auth/garmin_token'
|
|
95
|
+
require_relative 'git_fit/auth/strava'
|
|
87
96
|
require_relative 'git_fit/cli'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GitFit
|
|
4
|
+
module Auth
|
|
5
|
+
# Garmin token facade. Ported from workouts-cli
|
|
6
|
+
# (lib/workouts/auth/garmin_token.rb).
|
|
7
|
+
#
|
|
8
|
+
# Builds the appropriate sync adapter, authenticates, and returns the
|
|
9
|
+
# access token + domain for programmatic use (e.g. future migrate
|
|
10
|
+
# commands, CI token checks).
|
|
11
|
+
class GarminToken
|
|
12
|
+
def self.for_intl(config)
|
|
13
|
+
authenticate_with(GitFit::Sync::Garmin, config, 'garmin.com')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.for_cn(config)
|
|
17
|
+
authenticate_with(GitFit::Sync::GarminCN, config, 'garmin.cn')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.authenticate_with(klass, config, default_domain)
|
|
21
|
+
adapter = klass.new(config: config, db: nil, activity_filter: nil)
|
|
22
|
+
return nil unless adapter.authenticate
|
|
23
|
+
|
|
24
|
+
token_hash = adapter.instance_variable_get(:@access_token) || {}
|
|
25
|
+
domain = adapter.instance_variable_get(:@domain) || default_domain
|
|
26
|
+
|
|
27
|
+
{ access_token: token_hash['access_token'], domain: domain }
|
|
28
|
+
end
|
|
29
|
+
private_class_method :authenticate_with
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'webrick'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
require 'socket'
|
|
7
|
+
require 'faraday'
|
|
8
|
+
require 'uri'
|
|
9
|
+
|
|
10
|
+
module GitFit
|
|
11
|
+
module Auth
|
|
12
|
+
# Strava OAuth2 interactive flow. Ported from workouts-cli
|
|
13
|
+
# (lib/workouts/auth/strava.rb).
|
|
14
|
+
#
|
|
15
|
+
# Starts a loopback WEBrick server, prints an auth URL for the user to
|
|
16
|
+
# open, exchanges the returned code for a refresh_token, and writes it
|
|
17
|
+
# into sync.strava.refresh_token in the config file.
|
|
18
|
+
class Strava
|
|
19
|
+
STATUS_COLORS = { done: 32, warn: 33, error: 31, auth: 36 }.freeze
|
|
20
|
+
|
|
21
|
+
def initialize(cli_options, config)
|
|
22
|
+
@options = cli_options
|
|
23
|
+
@config = config
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Faithful port of workouts Auth::Strava#call — full loopback server
|
|
27
|
+
# lifecycle, inherently large surface area.
|
|
28
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
29
|
+
def call
|
|
30
|
+
strava_cfg = @config.sync_config('strava')
|
|
31
|
+
client_id = strava_cfg['client_id'].to_s
|
|
32
|
+
client_secret = strava_cfg['client_secret'].to_s
|
|
33
|
+
creds_missing = client_id.empty? || client_secret.empty?
|
|
34
|
+
|
|
35
|
+
puts "\e[33m⚠ Strava API credentials not configured — see browser page\e[0m" if creds_missing
|
|
36
|
+
|
|
37
|
+
port = TCPServer.open('127.0.0.1', 0) { |s| s.addr[1] }
|
|
38
|
+
redirect_uri = "http://localhost:#{port}/callback"
|
|
39
|
+
|
|
40
|
+
code = nil
|
|
41
|
+
exchange_error = nil
|
|
42
|
+
tokens = nil
|
|
43
|
+
|
|
44
|
+
log_file = File.open(File::NULL, 'w')
|
|
45
|
+
server = WEBrick::HTTPServer.new(
|
|
46
|
+
Port: port, Logger: WEBrick::Log.new(log_file),
|
|
47
|
+
AccessLog: [[log_file, '']]
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
server.mount_proc '/' do |_req, res|
|
|
51
|
+
fresh = load_fresh_config
|
|
52
|
+
scfg = fresh.dig('sync', 'strava') || {}
|
|
53
|
+
has_creds = scfg['client_id'].to_s != '' && scfg['client_secret'].to_s != ''
|
|
54
|
+
|
|
55
|
+
if has_creds
|
|
56
|
+
auth_url = "https://www.strava.com/oauth/authorize?#{URI.encode_www_form(
|
|
57
|
+
client_id: scfg['client_id'], redirect_uri: redirect_uri,
|
|
58
|
+
response_type: 'code', approval_prompt: 'force',
|
|
59
|
+
scope: 'read_all,profile:read_all,activity:read_all'
|
|
60
|
+
)}"
|
|
61
|
+
res.body = success_html(auth_url)
|
|
62
|
+
else
|
|
63
|
+
res.body = config_page_html
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
server.mount_proc '/callback' do |req, res|
|
|
68
|
+
fresh = load_fresh_config
|
|
69
|
+
scfg = fresh.dig('sync', 'strava') || {}
|
|
70
|
+
cid = scfg['client_id'].to_s
|
|
71
|
+
csec = scfg['client_secret'].to_s
|
|
72
|
+
|
|
73
|
+
code = req.query['code']
|
|
74
|
+
if code && !cid.empty? && !csec.empty? && !code.empty?
|
|
75
|
+
begin
|
|
76
|
+
resp = Faraday.post('https://www.strava.com/oauth/token') do |r|
|
|
77
|
+
r.body = { client_id: cid, client_secret: csec, code: code, grant_type: 'authorization_code' }
|
|
78
|
+
end
|
|
79
|
+
if resp.success?
|
|
80
|
+
tokens = JSON.parse(resp.body)
|
|
81
|
+
rt = tokens['refresh_token']
|
|
82
|
+
|
|
83
|
+
cfg = load_fresh_config
|
|
84
|
+
cfg['sync'] ||= {}
|
|
85
|
+
cfg['sync']['strava'] ||= {}
|
|
86
|
+
cfg['sync']['strava']['client_id'] ||= cid
|
|
87
|
+
cfg['sync']['strava']['client_secret'] ||= csec
|
|
88
|
+
cfg['sync']['strava']['refresh_token'] = rt
|
|
89
|
+
File.write(config_path, YAML.dump(cfg))
|
|
90
|
+
|
|
91
|
+
puts "\e[32m✓ Strava authorization successful\e[0m"
|
|
92
|
+
else
|
|
93
|
+
exchange_error = "Token exchange failed: HTTP #{resp.status}"
|
|
94
|
+
end
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
exchange_error = e.message
|
|
97
|
+
end
|
|
98
|
+
else
|
|
99
|
+
exchange_error = 'No authorization code received'
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if tokens
|
|
103
|
+
repo = `git remote get-url origin 2>/dev/null`.strip.sub(%r{.*github\.com[/:]}, '').sub(/\.git$/, '')
|
|
104
|
+
gh_url = repo.empty? ? 'https://github.com' : "https://github.com/#{repo}/settings/secrets/actions"
|
|
105
|
+
rt = tokens['refresh_token']
|
|
106
|
+
res.body = result_html(cid, csec, rt, gh_url, config_path)
|
|
107
|
+
else
|
|
108
|
+
err = exchange_error || 'Unknown error'
|
|
109
|
+
res.body = "<html><body><h1>#{err}</h1><p>Please close and try again.</p></body></html>"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
server.mount_proc '/shutdown' do |_req, res|
|
|
114
|
+
res.body = '<html><body><h1>Server closed</h1><p>You may close this window.</p></body></html>'
|
|
115
|
+
server.shutdown
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
Thread.new { server.start }
|
|
119
|
+
puts "\e[36m🔗 Open in your browser:\e[0m"
|
|
120
|
+
puts " http://localhost:#{port}"
|
|
121
|
+
|
|
122
|
+
begin
|
|
123
|
+
sleep 0.1 while server.status == :Running
|
|
124
|
+
rescue Interrupt
|
|
125
|
+
puts "\e[33m⚠ Server stopped by user\e[0m"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
if exchange_error
|
|
129
|
+
puts "\e[31m✗ #{exchange_error}\e[0m"
|
|
130
|
+
return
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
return unless tokens
|
|
134
|
+
|
|
135
|
+
rt = tokens['refresh_token']
|
|
136
|
+
puts "\e[32m✓ Done\e[0m"
|
|
137
|
+
puts ''
|
|
138
|
+
puts 'GitHub Actions Secrets:'
|
|
139
|
+
puts " SYNC_STRAVA_CLIENT_ID = #{client_id}"
|
|
140
|
+
puts " SYNC_STRAVA_CLIENT_SECRET = #{client_secret}"
|
|
141
|
+
puts " SYNC_STRAVA_REFRESH_TOKEN = #{rt}"
|
|
142
|
+
puts ''
|
|
143
|
+
puts 'Local config:'
|
|
144
|
+
puts ' strava:'
|
|
145
|
+
puts " refresh_token: #{rt}"
|
|
146
|
+
end
|
|
147
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def config_path
|
|
152
|
+
File.expand_path(@options[:config] || 'config/config.yml')
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def load_fresh_config
|
|
156
|
+
File.exist?(config_path) ? (YAML.safe_load_file(config_path) || {}) : {}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def success_html(auth_url)
|
|
160
|
+
<<~HTML
|
|
161
|
+
<!DOCTYPE html><html lang="zh-CN"><head>
|
|
162
|
+
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
163
|
+
<title>git-fit — Strava 授权</title>
|
|
164
|
+
<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#222;color:#eee;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;text-align:center}.card{background:#333;border-radius:12px;padding:3rem 2rem;max-width:520px;box-shadow:0 8px 32px rgba(0,0,0,0.3)}.icon{font-size:2.5rem;margin-bottom:0.5rem}h1{font-size:1.2rem;margin:0 0 0.3rem}p{color:#aaa;margin:0 0 1.5rem;font-size:0.9rem}.badge{display:inline-block;background:#2a2;color:#fff;padding:0.3rem 0.8rem;border-radius:4px;font-size:0.8rem;margin-bottom:1.5rem}.btn{display:inline-block;background:#FC4C02;color:#fff;text-decoration:none;padding:0.8rem 2rem;border-radius:6px;font-weight:600;font-size:1rem;transition:0.2s}.btn:hover{background:#e04400}.hint{color:#666;font-size:0.8rem;margin-top:1.5rem}</style></head><body>
|
|
165
|
+
<div class="card"><div class="icon">🏃</div><h1>Connect with Strava</h1>
|
|
166
|
+
<p>授权 git-fit 同步你的运动数据<br>Authorize git-fit to sync your activities</p>
|
|
167
|
+
<div class="badge">✓ client_id & client_secret 已配置</div><br>
|
|
168
|
+
<a class="btn" href="#{auth_url}">Connect with Strava ↗</a>
|
|
169
|
+
<div class="hint">授权后将自动跳转回此页面</div></div></body></html>
|
|
170
|
+
HTML
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def config_page_html
|
|
174
|
+
<<~HTML
|
|
175
|
+
<!DOCTYPE html><html lang="zh-CN"><head>
|
|
176
|
+
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
177
|
+
<title>Strava 授权 — 配置缺失</title>
|
|
178
|
+
<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#222;color:#eee;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;text-align:left}.card{background:#333;border-radius:12px;padding:2.5rem 2rem;max-width:640px;box-shadow:0 8px 32px rgba(0,0,0,0.3)}h1{color:#FC4C02;font-size:1.1rem;margin:0 0 1rem}ol{color:#aaa;line-height:1.8;padding-left:1.2rem}code{background:#1a1a1a;padding:0.1rem 0.3rem;border-radius:3px}.box{background:#1a1a1a;border-radius:6px;padding:0.8rem;font-family:monospace;font-size:0.8rem;white-space:pre;margin:0.5rem 0}.hint{color:#666;font-size:0.8rem;margin-top:1rem}</style></head><body>
|
|
179
|
+
<div class="card"><h1>⚠ 缺少 Strava API 凭证</h1><p style="color:#aaa;font-size:0.9rem">Missing Strava API credentials</p>
|
|
180
|
+
<ol><li>前往 <a href="https://www.strava.com/settings/api" style="color:#FC4C02">strava.com/settings/api</a></li>
|
|
181
|
+
<li>点击 "Create & Manage Your App"</li>
|
|
182
|
+
<li>填写 Application Name、Website(任意)、Authorization Callback Domain = <code>localhost</code></li>
|
|
183
|
+
<li>复制 Client ID 和 Client Secret</li></ol>
|
|
184
|
+
<p style="color:#aaa;font-size:0.85rem">配置后<b>刷新此页面</b>即可开始授权(无需重启)</p>
|
|
185
|
+
<div class="box"># config/config.yml
|
|
186
|
+
strava:
|
|
187
|
+
client_id: "your_client_id"
|
|
188
|
+
client_secret: "your_client_secret"
|
|
189
|
+
# refresh_token: (由 auth strava 自动填充)</div>
|
|
190
|
+
<div class="hint">配置 config.yml 后刷新此页面</div></div></body></html>
|
|
191
|
+
HTML
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def result_html(cid, csec, refresh_token, gh_url, config_path)
|
|
195
|
+
<<~HTML
|
|
196
|
+
<!DOCTYPE html><html lang="zh-CN"><head>
|
|
197
|
+
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
198
|
+
<title>git-fit — 授权成功</title>
|
|
199
|
+
<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#222;color:#eee;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0}.card{background:#333;border-radius:12px;padding:2.5rem 2rem;max-width:640px;box-shadow:0 8px 32px rgba(0,0,0,0.3);text-align:left}h1{text-align:center;font-size:1.2rem}.section{margin:1.5rem 0}.section h2{font-size:1rem;margin:0 0 0.5rem;color:#FC4C02}.box{background:#1a1a1a;border-radius:6px;padding:0.8rem;font-family:"SFMono-Regular",Consolas,monospace;font-size:0.8rem;position:relative;overflow-x:auto}.box code{display:block;white-space:pre}.copy{cursor:pointer;position:absolute;top:0.4rem;right:0.5rem;background:#555;color:#fff;border:none;border-radius:3px;padding:0.15rem 0.5rem;font-size:0.7rem}.copy:hover{background:#777}.action-btn{display:inline-block;background:#238636;color:#fff;text-decoration:none;padding:0.6rem 1.2rem;border-radius:6px;font-size:0.85rem;margin-top:0.5rem}.action-btn:hover{background:#2ea043}hr{border:none;border-top:1px solid #444;margin:1.2rem 0}</style></head><body>
|
|
200
|
+
<div class="card"><h1>✓ 授权成功<br><span style="font-size:0.8rem;color:#aaa;font-weight:400">Authorization Successful</span></h1>
|
|
201
|
+
<div class="section"><h2>📦 GitHub Actions Secrets</h2>
|
|
202
|
+
<p style="margin:0.3rem 0 0.6rem;font-size:0.8rem;color:#aaa">Settings → Secrets and variables → Actions → New repository secret</p>
|
|
203
|
+
<div class="box"><button class="copy" onclick="cn('n0')">📋 Name</button><button class="copy" style="margin-left:0.3rem" onclick="cv('v0')">📋 Value</button>
|
|
204
|
+
<code>Name: SYNC_STRAVA_CLIENT_ID<span id="n0" data-name="SYNC_STRAVA_CLIENT_ID"></span><span id="v0">#{cid}</span></code></div>
|
|
205
|
+
<div class="box" style="margin-top:0.5rem"><button class="copy" onclick="cn('n1')">📋 Name</button><button class="copy" style="margin-left:0.3rem" onclick="cv('v1')">📋 Value</button>
|
|
206
|
+
<code>Name: SYNC_STRAVA_CLIENT_SECRET<span id="n1" data-name="SYNC_STRAVA_CLIENT_SECRET"></span><span id="v1">#{csec}</span></code></div>
|
|
207
|
+
<div class="box" style="margin-top:0.5rem"><button class="copy" onclick="cn('n2')">📋 Name</button><button class="copy" style="margin-left:0.3rem" onclick="cv('v2')">📋 Value</button>
|
|
208
|
+
<code>Name: SYNC_STRAVA_REFRESH_TOKEN<span id="n2" data-name="SYNC_STRAVA_REFRESH_TOKEN"></span><span id="v2">#{refresh_token}</span></code></div>
|
|
209
|
+
<a class="action-btn" href="#{gh_url}" target="_blank">Open GitHub Secrets ↗</a></div>
|
|
210
|
+
<hr>
|
|
211
|
+
<div class="section compact"><h2>💻 本地配置 / Local Config</h2>
|
|
212
|
+
<div class="box" style="border-left:3px solid #2a2"><code>✓ 已自动写入 #{config_path.sub(Dir.home, '~')}
|
|
213
|
+
<span style="color:#888">strava:
|
|
214
|
+
refresh_token: #{refresh_token}</span></code></div></div>
|
|
215
|
+
<hr>
|
|
216
|
+
<p style="text-align:center;color:#666;font-size:0.8rem">复制完成后点击下方按钮关闭服务器<br>Click the button below to close the server</p>
|
|
217
|
+
<div style="text-align:center"><button onclick="fetch('/shutdown').then(function(){document.body.innerHTML='<h1 style=text-align:center;margin-top:40vh>✓ Server closed</h1>'})" style="background:#555;color:#fff;border:none;border-radius:6px;padding:0.6rem 1.5rem;font-size:0.9rem;cursor:pointer">🛑 Close Server</button></div></div>
|
|
218
|
+
<script>function cn(id){navigator.clipboard.writeText(document.getElementById(id).dataset.name).then(function(){var b=event.target;b.textContent='✓';setTimeout(function(){b.textContent='📋 Name'},2000)})}function cv(id){navigator.clipboard.writeText(document.getElementById(id).textContent).then(function(){var b=event.target;b.textContent='✓';setTimeout(function(){b.textContent='📋 Value'},2000)})}</script></body></html>
|
|
219
|
+
HTML
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
data/lib/git_fit/cli/export.rb
CHANGED
|
@@ -7,6 +7,7 @@ module GitFit
|
|
|
7
7
|
desc 'json', 'Export activities to JSON'
|
|
8
8
|
option :output, type: :string, aliases: '-o', desc: 'Output path'
|
|
9
9
|
option :activity, type: :array, desc: 'Filter by sport type', aliases: '--act'
|
|
10
|
+
option :tracks_output, type: :string, desc: 'Write polylines to a separate JSON file (run_id → polyline)'
|
|
10
11
|
no_commands do
|
|
11
12
|
def git_fit_config
|
|
12
13
|
@git_fit_config ||= GitFit::Config.new(options[:config])
|
|
@@ -20,7 +21,8 @@ module GitFit
|
|
|
20
21
|
db = conn.db
|
|
21
22
|
path = options[:output] || config.export_config.dig('json', 'path') || 'site/activities.json'
|
|
22
23
|
filter = options[:activity]&.map(&:strip)&.map(&:downcase)
|
|
23
|
-
count = Export::JSON.new(db: db, output: path, activity_filter: filter
|
|
24
|
+
count = Export::JSON.new(db: db, output: path, activity_filter: filter,
|
|
25
|
+
tracks_output: options[:tracks_output]).call
|
|
24
26
|
say_status :done, "Exported #{count} activities to #{path}", :green
|
|
25
27
|
rescue StandardError => e
|
|
26
28
|
say_status :error, "JSON export failed: #{e.message}", :red
|
|
@@ -43,6 +45,23 @@ module GitFit
|
|
|
43
45
|
say_status :error, "CSV export failed: #{e.message}", :red
|
|
44
46
|
end
|
|
45
47
|
|
|
48
|
+
desc 'gpx', 'Export activities to GPX files (one per activity)'
|
|
49
|
+
option :output, type: :string, aliases: '-o', desc: 'Output directory'
|
|
50
|
+
option :activity, type: :array, desc: 'Filter by sport type', aliases: '--act'
|
|
51
|
+
|
|
52
|
+
def gpx
|
|
53
|
+
config = git_fit_config
|
|
54
|
+
conn = GitFit::DB::Connection.new(config.db_path)
|
|
55
|
+
conn.migrate!
|
|
56
|
+
db = conn.db
|
|
57
|
+
path = options[:output] || config.export_config.dig('gpx', 'path') || 'site/gpx_out'
|
|
58
|
+
filter = options[:activity]&.map(&:strip)&.map(&:downcase)
|
|
59
|
+
count = Export::GPX.new(db: db, output: path, activity_filter: filter).call
|
|
60
|
+
say_status :done, "Exported #{count} GPX files to #{path}/", :green
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
say_status :error, "GPX export failed: #{e.message}", :red
|
|
63
|
+
end
|
|
64
|
+
|
|
46
65
|
desc 'stats', 'Export activity statistics to JSON'
|
|
47
66
|
option :output, type: :string, aliases: '-o', desc: 'Output path'
|
|
48
67
|
option :activity, type: :array, desc: 'Filter by sport type', aliases: '--act'
|
data/lib/git_fit/cli/sync.rb
CHANGED
|
@@ -30,6 +30,60 @@ module GitFit
|
|
|
30
30
|
rescue StandardError => e
|
|
31
31
|
say_status :error, e.message, :red
|
|
32
32
|
end
|
|
33
|
+
|
|
34
|
+
def auth(source)
|
|
35
|
+
sources = %w[garmin garmin_cn strava]
|
|
36
|
+
unless sources.include?(source)
|
|
37
|
+
say_status :error, "Supported: #{sources.join(', ')}", :red
|
|
38
|
+
return
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if source == 'garmin'
|
|
42
|
+
say_status :warn, 'garmin (intl) DI auth migration pending (Phase 3)', :yellow
|
|
43
|
+
return
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
config = git_fit_config.sync_config(source)
|
|
47
|
+
if config.empty?
|
|
48
|
+
say_status :warn, "No config for #{source}. Set env vars or config.yml", :yellow
|
|
49
|
+
return
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
adapter = build_auth_adapter(source)
|
|
53
|
+
unless adapter.respond_to?(:authenticate)
|
|
54
|
+
say_status :error, "#{source} does not support interactive auth", :red
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
say_status :auth, "Logging in to #{source}...", :green
|
|
59
|
+
if adapter.authenticate
|
|
60
|
+
say_status :done, "#{source} authenticated successfully", :green
|
|
61
|
+
if adapter.respond_to?(:generated_secret) && (secret = adapter.generated_secret)
|
|
62
|
+
puts ''
|
|
63
|
+
puts 'GitHub Actions Secrets:'
|
|
64
|
+
puts " SYNC_#{source.upcase}_SECRET = #{secret}"
|
|
65
|
+
puts " SYNC_#{source.upcase}_EMAIL = #{config['email']}"
|
|
66
|
+
puts " SYNC_#{source.upcase}_PASSWORD = #{config['password']}"
|
|
67
|
+
puts ''
|
|
68
|
+
puts 'Local config:'
|
|
69
|
+
puts " #{source}:"
|
|
70
|
+
puts ' secret: (已自动写入 config/config.yml)'
|
|
71
|
+
end
|
|
72
|
+
else
|
|
73
|
+
say_status :error, 'Authentication failed', :red
|
|
74
|
+
end
|
|
75
|
+
rescue GitFit::Sync::AuthError => e
|
|
76
|
+
say_status :error, "Authentication failed: #{e.message}", :red
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def build_auth_adapter(source)
|
|
82
|
+
klass = GitFit::Sync::Base.adapters.find { |a| a.config_key == source }
|
|
83
|
+
return nil unless klass
|
|
84
|
+
|
|
85
|
+
klass.new(config: git_fit_config.sync_config(source), db: nil)
|
|
86
|
+
end
|
|
33
87
|
end
|
|
34
88
|
end
|
|
35
89
|
end
|
data/lib/git_fit/cli.rb
CHANGED
|
@@ -72,12 +72,41 @@ module GitFit
|
|
|
72
72
|
super
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
desc 'auth SOURCE', 'Authenticate with a sync source (strava, garmin, garmin_cn)'
|
|
76
|
+
def auth(source)
|
|
77
|
+
if source == 'strava'
|
|
78
|
+
GitFit::Auth::Strava.new(options, git_fit_config).call
|
|
79
|
+
else
|
|
80
|
+
super
|
|
81
|
+
end
|
|
82
|
+
rescue GitFit::Sync::AuthError => e
|
|
83
|
+
say_status :error, "Auth failed: #{e.message}", :red
|
|
84
|
+
end
|
|
85
|
+
|
|
75
86
|
desc 'gh SUBCOMMAND', 'Manage GitHub secrets and variables'
|
|
76
87
|
subcommand 'gh', GhCLI
|
|
77
88
|
|
|
78
89
|
desc 'geo SUBCOMMAND', 'Detect geographical administrative regions'
|
|
79
90
|
subcommand 'geo', GeoCLI
|
|
80
91
|
|
|
92
|
+
desc 'dedup', 'Run cross-source dedup matching'
|
|
93
|
+
option :incremental, type: :boolean, default: false, desc: 'Skip already-grouped records'
|
|
94
|
+
option :log, type: :string, default: nil, desc: "Log path (or 'auto' for data/dedup/)"
|
|
95
|
+
option :verbose, type: :boolean, default: false, desc: 'Detailed per-match output'
|
|
96
|
+
def dedup
|
|
97
|
+
db = connect_db
|
|
98
|
+
log_path = options[:log] == 'auto' ? nil : options[:log]
|
|
99
|
+
count = GitFit::Dedup::Service.new(
|
|
100
|
+
db,
|
|
101
|
+
incremental: options[:incremental],
|
|
102
|
+
log_path: log_path,
|
|
103
|
+
verbose: options[:verbose],
|
|
104
|
+
).run
|
|
105
|
+
say_status :done, "Dedup: #{count} matches updated", :green
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
say_status :error, "Dedup failed: #{e.message}", :red
|
|
108
|
+
end
|
|
109
|
+
|
|
81
110
|
desc 'strava SUBCOMMAND', 'Strava web operations'
|
|
82
111
|
subcommand 'strava', StravaCLI
|
|
83
112
|
|
|
@@ -23,6 +23,12 @@ module GitFit
|
|
|
23
23
|
# garmin: # env: GIT_FIT_GARMIN_EMAIL
|
|
24
24
|
# email: "" # env: GIT_FIT_GARMIN_PASSWORD
|
|
25
25
|
# password: ""
|
|
26
|
+
# auth_seed: "" # git fit auth garmin (DI OAuth2, Phase 3)
|
|
27
|
+
# secret: "" # CN SSO secret (auto-written by git fit auth garmin_cn)
|
|
28
|
+
|
|
29
|
+
# garmin_cn: # env: GIT_FIT_GARMIN_CN_EMAIL
|
|
30
|
+
# email: "" # env: GIT_FIT_GARMIN_CN_PASSWORD
|
|
31
|
+
# password: ""
|
|
26
32
|
|
|
27
33
|
# keep: # env: GIT_FIT_KEEP_PHONE
|
|
28
34
|
# phone: "" # env: GIT_FIT_KEEP_PASSWORD
|
|
@@ -50,6 +56,9 @@ module GitFit
|
|
|
50
56
|
# csv:
|
|
51
57
|
# path: site/workouts.csv
|
|
52
58
|
|
|
59
|
+
# gpx:
|
|
60
|
+
# path: site/gpx_out
|
|
61
|
+
|
|
53
62
|
privacy:
|
|
54
63
|
start_end_range: 200
|
|
55
64
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module GitFit
|
|
6
|
+
module Dedup
|
|
7
|
+
class FieldElector
|
|
8
|
+
FIELD_ELECTION_RULES = {
|
|
9
|
+
distance: :median,
|
|
10
|
+
moving_time: :min,
|
|
11
|
+
elapsed_time: :max,
|
|
12
|
+
average_heartrate: :mean,
|
|
13
|
+
max_heartrate: :max,
|
|
14
|
+
average_power: :non_null,
|
|
15
|
+
max_power: :non_null,
|
|
16
|
+
average_cadence: :non_null,
|
|
17
|
+
max_cadence: :non_null,
|
|
18
|
+
summary_polyline: :best_polyline,
|
|
19
|
+
elevation_gain: :median,
|
|
20
|
+
calories: :median,
|
|
21
|
+
average_temperature: :non_null,
|
|
22
|
+
average_speed: :computed,
|
|
23
|
+
name: :best_name,
|
|
24
|
+
start_date: :earliest,
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
SOURCE_NAME_PRIORITY = {
|
|
28
|
+
garmin: 1, strava: 2, igpsport: 3, xoss: 4,
|
|
29
|
+
keep: 5, xingzhe: 6, apple_health: 7
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
def elect(field, values, sources = [])
|
|
33
|
+
rule = FIELD_ELECTION_RULES[field]
|
|
34
|
+
non_nil = values.compact
|
|
35
|
+
return nil if non_nil.empty?
|
|
36
|
+
|
|
37
|
+
case rule
|
|
38
|
+
when :median
|
|
39
|
+
sorted = non_nil.sort
|
|
40
|
+
sorted[sorted.size / 2]
|
|
41
|
+
when :mean
|
|
42
|
+
non_nil.sum / non_nil.size
|
|
43
|
+
when :min
|
|
44
|
+
non_nil.min
|
|
45
|
+
when :max
|
|
46
|
+
non_nil.max
|
|
47
|
+
when :non_null
|
|
48
|
+
non_nil.first
|
|
49
|
+
when :best_polyline
|
|
50
|
+
candidates = non_nil.select { |v| v.is_a?(String) && !v.empty? }
|
|
51
|
+
return nil if candidates.empty?
|
|
52
|
+
candidates.max_by { |v| decode_polyline_size(v) }
|
|
53
|
+
when :computed
|
|
54
|
+
nil
|
|
55
|
+
when :best_name
|
|
56
|
+
best_name(values, sources)
|
|
57
|
+
when :earliest
|
|
58
|
+
non_nil.min_by do |v|
|
|
59
|
+
Time.parse(v.to_s)
|
|
60
|
+
rescue StandardError
|
|
61
|
+
Time.at(0)
|
|
62
|
+
end
|
|
63
|
+
else
|
|
64
|
+
non_nil.first
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def decode_polyline_size(polyline)
|
|
71
|
+
return 0 unless polyline.is_a?(String) && !polyline.empty?
|
|
72
|
+
polyline.size / 3
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def best_name(values, sources)
|
|
76
|
+
pairs = values.zip(sources)
|
|
77
|
+
pairs.min_by { |_, src| SOURCE_NAME_PRIORITY[src&.to_sym] || 99 }&.first
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module GitFit
|
|
6
|
+
module Dedup
|
|
7
|
+
class GroupConsolidator
|
|
8
|
+
def consolidate(matched_pairs)
|
|
9
|
+
parent = {}
|
|
10
|
+
all_ids = []
|
|
11
|
+
|
|
12
|
+
matched_pairs.each do |pair|
|
|
13
|
+
a_id = pair[:a][:run_id]
|
|
14
|
+
b_id = pair[:b][:run_id]
|
|
15
|
+
all_ids << a_id << b_id
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
all_ids.uniq!
|
|
19
|
+
return {} if all_ids.empty?
|
|
20
|
+
|
|
21
|
+
all_ids.each { |id| parent[id] = id }
|
|
22
|
+
|
|
23
|
+
matched_pairs.each do |pair|
|
|
24
|
+
union(parent, pair[:a][:run_id], pair[:b][:run_id])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
|
28
|
+
all_ids.each do |run_id|
|
|
29
|
+
root = find(parent, run_id)
|
|
30
|
+
groups[root] << run_id
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
groups.transform_values! { |ids| ids.uniq.sort }
|
|
34
|
+
|
|
35
|
+
result = {}
|
|
36
|
+
groups.each_value do |sorted_ids|
|
|
37
|
+
group_id = Digest::SHA256.hexdigest(sorted_ids.join(','))[0, 16]
|
|
38
|
+
result[group_id] = sorted_ids
|
|
39
|
+
end
|
|
40
|
+
result
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def find(parent, x)
|
|
46
|
+
parent[x] = find(parent, parent[x]) if parent[x] != x
|
|
47
|
+
parent[x]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def union(parent, x, y)
|
|
51
|
+
rx = find(parent, x)
|
|
52
|
+
ry = find(parent, y)
|
|
53
|
+
return if rx == ry
|
|
54
|
+
parent[ry] = rx
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module GitFit
|
|
7
|
+
module Dedup
|
|
8
|
+
class LogWriter
|
|
9
|
+
def initialize(log_path = nil)
|
|
10
|
+
@log_path = log_path || default_path
|
|
11
|
+
FileUtils.mkdir_p(File.dirname(@log_path))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def write(stats:, matches:, elapsed:)
|
|
15
|
+
log = {
|
|
16
|
+
started_at: (Time.now.utc - elapsed).iso8601,
|
|
17
|
+
elapsed_seconds: elapsed.round(1),
|
|
18
|
+
stats: stats,
|
|
19
|
+
matches: matches,
|
|
20
|
+
}
|
|
21
|
+
File.write(@log_path, JSON.pretty_generate(log))
|
|
22
|
+
@log_path
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def default_path
|
|
28
|
+
timestamp = Time.now.utc.strftime('%Y%m%dT%H%M%SZ')
|
|
29
|
+
"data/dedup/#{timestamp}.json"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|