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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 69674e0fc800279e2099fe879318f4b8f3ce751325515192474dc40223438293
4
- data.tar.gz: f91fd18620742288f923f302917667121d310a5707ac71cc185f77da5fc154a6
3
+ metadata.gz: 529a74a588a51b0b04ba1a0e15e629425dec93dd79986c6e6f45a0eda35d5914
4
+ data.tar.gz: 37a81d2e9285b8917c13b36eb343d3b97de27709cf1d71a094216c7a645b3168
5
5
  SHA512:
6
- metadata.gz: f204350fdd449be0942ba5d42917a54460198f61ffdfde00b6c51fa39cdb379c0e056851c05e0bae6577b2d3dca9a0428bc967247245528c65c94b1b9f32d591
7
- data.tar.gz: 9d4bf7e3c1c72ebfc4a35c05e2b5c150b87c17cfd67ca634ede7e4026b72978c89360434baac73b9e36addd1f88b8c61b8820c0089712086f9cc9dee8777e989
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
@@ -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).call
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'
@@ -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