git-fit 0.9.9 → 0.10.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/export.rb +34 -0
- data/lib/git_fit/cli/geo_cli.rb +1 -1
- data/lib/git_fit/cli/strava.rb +191 -0
- data/lib/git_fit/cli.rb +3 -0
- data/lib/git_fit/config.rb +4 -2
- data/lib/git_fit/config_template.rb +6 -0
- data/lib/git_fit/export/calculator.rb +233 -0
- data/lib/git_fit/export/csv.rb +44 -0
- data/lib/git_fit/export/stats.rb +22 -0
- data/lib/git_fit/export.rb +3 -0
- data/lib/git_fit/strava_web/file_check.rb +61 -0
- data/lib/git_fit/sync/strava.rb +2 -1
- data/lib/git_fit/version.rb +1 -1
- metadata +34 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0d17d1744736bfa39aa88b5e914e7c312ec88fb9f10e33f8341082286473ffc9
|
|
4
|
+
data.tar.gz: ea386f48c48f9ebed0830509ab813c45b7359dd0063f3b7389b6e7e99575120e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5af02664030285938853b892bc7fecef43cdff4ade002816525928dbfebc9f80dd9999d0dbadd1fe2da6ef38081ed67ee0eb69167c29d87349809e19ef60fd93
|
|
7
|
+
data.tar.gz: c94ba5c627f3c41227b6b9b3ede8af9ea2719ac977c7d4f672c7a8b258753466a08d5378737523c4ce79678c02100312bedc52b5134f573aaacff480556a8d4a
|
data/lib/git-fit.rb
CHANGED
|
@@ -82,4 +82,6 @@ require_relative 'git_fit/cli/gh_cli'
|
|
|
82
82
|
require_relative 'git_fit/cli/export'
|
|
83
83
|
require_relative 'git_fit/cli/import_cli'
|
|
84
84
|
require_relative 'git_fit/cli/geo_cli'
|
|
85
|
+
require_relative 'git_fit/strava_web/file_check'
|
|
86
|
+
require_relative 'git_fit/cli/strava'
|
|
85
87
|
require_relative 'git_fit/cli'
|
data/lib/git_fit/cli/export.rb
CHANGED
|
@@ -25,5 +25,39 @@ module GitFit
|
|
|
25
25
|
rescue StandardError => e
|
|
26
26
|
say_status :error, "JSON export failed: #{e.message}", :red
|
|
27
27
|
end
|
|
28
|
+
|
|
29
|
+
desc 'csv', 'Export activities to CSV'
|
|
30
|
+
option :output, type: :string, aliases: '-o', desc: 'Output path'
|
|
31
|
+
option :activity, type: :array, desc: 'Filter by sport type', aliases: '--act'
|
|
32
|
+
|
|
33
|
+
def csv
|
|
34
|
+
config = git_fit_config
|
|
35
|
+
conn = GitFit::DB::Connection.new(config.db_path)
|
|
36
|
+
conn.migrate!
|
|
37
|
+
db = conn.db
|
|
38
|
+
path = options[:output] || config.export_config.dig('csv', 'path') || 'site/workouts.csv'
|
|
39
|
+
filter = options[:activity]&.map(&:strip)&.map(&:downcase)
|
|
40
|
+
count = Export::CSV.new(db: db, output: path, activity_filter: filter).call
|
|
41
|
+
say_status :done, "Exported #{count} activities to #{path}", :green
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
say_status :error, "CSV export failed: #{e.message}", :red
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
desc 'stats', 'Export activity statistics to JSON'
|
|
47
|
+
option :output, type: :string, aliases: '-o', desc: 'Output path'
|
|
48
|
+
option :activity, type: :array, desc: 'Filter by sport type', aliases: '--act'
|
|
49
|
+
|
|
50
|
+
def stats
|
|
51
|
+
config = git_fit_config
|
|
52
|
+
conn = GitFit::DB::Connection.new(config.db_path)
|
|
53
|
+
conn.migrate!
|
|
54
|
+
db = conn.db
|
|
55
|
+
path = options[:output] || config.export_config.dig('stats', 'path') || 'site/stats.json'
|
|
56
|
+
filter = options[:activity]&.map(&:strip)&.map(&:downcase)
|
|
57
|
+
count = Export::Stats.new(db: db, output: path, activity_filter: filter).call
|
|
58
|
+
say_status :done, "Exported stats for #{count} activities to #{path}", :green
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
say_status :error, "Stats export failed: #{e.message}", :red
|
|
61
|
+
end
|
|
28
62
|
end
|
|
29
63
|
end
|
data/lib/git_fit/cli/geo_cli.rb
CHANGED
|
@@ -11,7 +11,7 @@ module GitFit
|
|
|
11
11
|
option :time, type: :numeric, aliases: '-t', desc: 'Time budget in seconds'
|
|
12
12
|
option :"dry-run", type: :boolean, desc: "Don't write to DB"
|
|
13
13
|
option :checkpoint, type: :boolean, desc: 'Run WAL checkpoint after detect'
|
|
14
|
-
option :"cache-dir", type: :string, desc:
|
|
14
|
+
option :"cache-dir", type: :string, desc: 'Geo cache directory (empty = no cache)', default: ''
|
|
15
15
|
def detect
|
|
16
16
|
config = GitFit::Config.new
|
|
17
17
|
conn = GitFit::DB::Connection.new(config.db_path)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'stravaweb'
|
|
6
|
+
|
|
7
|
+
module GitFit
|
|
8
|
+
class StravaCLI < Thor
|
|
9
|
+
VALID_FORMATS = %w[original tcx gpx].freeze
|
|
10
|
+
|
|
11
|
+
TOKEN_HELP = <<~HELP
|
|
12
|
+
Token expired or invalid — generate a fresh one:
|
|
13
|
+
1. Open https://www.strava.com/login and sign in (email + OTP)
|
|
14
|
+
2. F12 → Application → Cookies → https://www.strava.com
|
|
15
|
+
3. Copy the Value of strava_remember_token
|
|
16
|
+
4. Configure it:
|
|
17
|
+
Local (config/config.yml):
|
|
18
|
+
sync.strava.web_auth.jwt: "<JWT>"
|
|
19
|
+
Env:
|
|
20
|
+
export GIT_FIT_STRAVA_WEB_AUTH_JWT="<JWT>"
|
|
21
|
+
CI (base64 secret):
|
|
22
|
+
echo -n "<JWT>" | base64 -w0 | gh secret set GIT_FIT_STRAVA_WEB_AUTH_AUTH_SEED
|
|
23
|
+
Token lifetime: ~30 days (JWT exp claim)
|
|
24
|
+
HELP
|
|
25
|
+
|
|
26
|
+
desc 'fetch [ID ...]', 'Download original files from Strava via web export'
|
|
27
|
+
option :jwt, type: :string, desc: 'JWT token for web auth (overrides config)'
|
|
28
|
+
option :format, type: :string, default: 'original', desc: 'Export format: original, tcx, or gpx'
|
|
29
|
+
option :output,
|
|
30
|
+
type: :string,
|
|
31
|
+
desc: 'Output directory override',
|
|
32
|
+
default: File.join('data', 'raw', 'strava')
|
|
33
|
+
option :limit, type: :numeric, default: 10, desc: 'Max activities per run (default: 10)'
|
|
34
|
+
option :db, type: :string, desc: 'Database path override'
|
|
35
|
+
long_desc <<~DESC
|
|
36
|
+
Download original activity files from Strava via the website export endpoint.
|
|
37
|
+
|
|
38
|
+
With explicit IDs:
|
|
39
|
+
git fit strava fetch 12345 67890
|
|
40
|
+
|
|
41
|
+
Without IDs — reads the database to find Strava-sourced activities
|
|
42
|
+
that don't yet have an original file:
|
|
43
|
+
git fit strava fetch
|
|
44
|
+
|
|
45
|
+
Auth via strava_remember_token JWT.
|
|
46
|
+
DESC
|
|
47
|
+
def fetch(*activity_ids)
|
|
48
|
+
format = options[:format].to_s.downcase
|
|
49
|
+
unless VALID_FORMATS.include?(format)
|
|
50
|
+
raise ArgumentError, "unknown --format '#{format}' (valid: #{VALID_FORMATS.join(', ')})"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
config = git_fit_config
|
|
54
|
+
web_cfg = config.sync_config('strava')['web_auth'] || {}
|
|
55
|
+
jwt = options[:jwt] || web_cfg['jwt']
|
|
56
|
+
auth_seed = web_cfg['auth_seed']
|
|
57
|
+
|
|
58
|
+
output_dir = options[:output]
|
|
59
|
+
FileUtils.mkdir_p(output_dir)
|
|
60
|
+
cache_dir = File.join('data', 'cache', 'strava_downloads')
|
|
61
|
+
FileUtils.mkdir_p(cache_dir)
|
|
62
|
+
cookie_path = File.join('data', 'cache', 'strava_session.yml')
|
|
63
|
+
|
|
64
|
+
if activity_ids.empty?
|
|
65
|
+
activity_ids = pending_ids(config, output_dir)
|
|
66
|
+
return if activity_ids.nil?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
client = build_client(jwt, auth_seed, cookie_path)
|
|
70
|
+
fmt_sym = format.to_sym
|
|
71
|
+
stats = { success: 0, skipped: 0, failed: 0, token_errors: 0 }
|
|
72
|
+
|
|
73
|
+
activity_ids.each do |aid|
|
|
74
|
+
process_activity(client, aid, output_dir, cache_dir, fmt_sym, stats)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
client.persist_cookies!
|
|
78
|
+
|
|
79
|
+
say ''
|
|
80
|
+
say "fetch: #{stats[:success]} downloaded, #{stats[:skipped]} skipped, #{stats[:failed]} failed", :blue
|
|
81
|
+
say TOKEN_HELP, :yellow if stats[:token_errors] > 0 && stats[:success] == 0
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def git_fit_config
|
|
87
|
+
@git_fit_config ||= GitFit::Config.new(options[:config])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_client(jwt, auth_seed, cookie_path)
|
|
91
|
+
::StravaWeb::Fetch.new(jwt: jwt, auth_seed: auth_seed, cookie_path: cookie_path)
|
|
92
|
+
rescue ::StravaWeb::AuthError => e
|
|
93
|
+
say_status :error, "auth failed: #{e.message}", :red
|
|
94
|
+
say TOKEN_HELP, :yellow
|
|
95
|
+
exit 1
|
|
96
|
+
rescue Faraday::Error => e
|
|
97
|
+
say_status :error, "auth failed: network — #{e.message}", :red
|
|
98
|
+
exit 1
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def pending_ids(config, output_dir)
|
|
102
|
+
db_path_val = options[:db] || config.db_path
|
|
103
|
+
all_ids = begin
|
|
104
|
+
conn = GitFit::DB::Connection.new(db_path_val)
|
|
105
|
+
conn.migrate!
|
|
106
|
+
conn.db[:activities]
|
|
107
|
+
.where(source: 'strava')
|
|
108
|
+
.select_map(:run_id)
|
|
109
|
+
.map { |rid| rid.sub('strava_', '') }
|
|
110
|
+
rescue Sequel::DatabaseError => e
|
|
111
|
+
say_status :error, "DB error: #{e.message}", :red
|
|
112
|
+
say_status :error, "Fix: rm -f #{db_path_val} && git fit db migrate", :yellow
|
|
113
|
+
exit 1
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if all_ids.empty?
|
|
117
|
+
say 'No Strava activities in database.', :yellow
|
|
118
|
+
return nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
activity_ids = all_ids.reject do |id|
|
|
122
|
+
Dir.glob(File.join(output_dir, id, '*')).any?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
if activity_ids.empty?
|
|
126
|
+
say "All #{all_ids.size} Strava activities already have original files.", :green
|
|
127
|
+
return nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
total = activity_ids.size
|
|
131
|
+
limit = options[:limit].to_i
|
|
132
|
+
if limit > 0 && activity_ids.size > limit
|
|
133
|
+
activity_ids = activity_ids.first(limit)
|
|
134
|
+
say "Limited to #{limit} of #{total} pending", :cyan
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
say "Found #{all_ids.size} Strava activities in DB, #{total} without original files", :cyan
|
|
138
|
+
activity_ids
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def process_activity(client, aid, output_dir, cache_dir, fmt_sym, stats)
|
|
142
|
+
existing = Dir.glob(File.join(output_dir, aid, '*')).first
|
|
143
|
+
|
|
144
|
+
if existing
|
|
145
|
+
if StravaWeb::FileCheck.validate_existing_file(existing)
|
|
146
|
+
say "skip #{aid} (#{File.basename(existing)})", :yellow
|
|
147
|
+
stats[:skipped] += 1
|
|
148
|
+
return
|
|
149
|
+
else
|
|
150
|
+
say "warn #{aid}: existing file invalid — re-downloading", :yellow
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
begin
|
|
155
|
+
result = client.export(aid, format: fmt_sym)
|
|
156
|
+
|
|
157
|
+
unless result.content && !result.content.empty?
|
|
158
|
+
say "error #{aid}: empty response", :red
|
|
159
|
+
stats[:failed] += 1
|
|
160
|
+
return
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
unless StravaWeb::FileCheck.valid_format?(result.content, result.format)
|
|
164
|
+
say "error #{aid}: #{StravaWeb::FileCheck.describe_error(result.content)}", :red
|
|
165
|
+
stats[:failed] += 1
|
|
166
|
+
return
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
final_path = File.join(output_dir, aid, result.filename)
|
|
170
|
+
FileUtils.mkdir_p(File.dirname(final_path))
|
|
171
|
+
ext = result.format.to_s
|
|
172
|
+
temp_path = File.join(cache_dir, "#{aid}.#{ext}")
|
|
173
|
+
|
|
174
|
+
File.write(temp_path, result.content, mode: 'wb')
|
|
175
|
+
FileUtils.mv(temp_path, final_path)
|
|
176
|
+
say "ok #{aid} #{result.filename} (#{result.format})", :green
|
|
177
|
+
stats[:success] += 1
|
|
178
|
+
rescue ::StravaWeb::NotFoundError
|
|
179
|
+
say "skip #{aid}: no original file on Strava (HTTP 404)", :yellow
|
|
180
|
+
stats[:skipped] += 1
|
|
181
|
+
rescue ::StravaWeb::ExportError => e
|
|
182
|
+
stats[:token_errors] += 1 if e.message.include?('HTTP 401') || e.message.include?('HTTP 403')
|
|
183
|
+
say "fail #{aid}: #{e.message}", :red
|
|
184
|
+
stats[:failed] += 1
|
|
185
|
+
rescue Faraday::Error => e
|
|
186
|
+
say "fail #{aid}: network — #{e.class}: #{e.message}", :red
|
|
187
|
+
stats[:failed] += 1
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
data/lib/git_fit/cli.rb
CHANGED
|
@@ -78,6 +78,9 @@ module GitFit
|
|
|
78
78
|
desc 'geo SUBCOMMAND', 'Detect geographical administrative regions'
|
|
79
79
|
subcommand 'geo', GeoCLI
|
|
80
80
|
|
|
81
|
+
desc 'strava SUBCOMMAND', 'Strava web operations'
|
|
82
|
+
subcommand 'strava', StravaCLI
|
|
83
|
+
|
|
81
84
|
desc 'install SUBCOMMAND', 'Install project assets (actions)'
|
|
82
85
|
subcommand 'install', InstallCLI
|
|
83
86
|
|
data/lib/git_fit/config.rb
CHANGED
|
@@ -14,6 +14,7 @@ module GitFit
|
|
|
14
14
|
},
|
|
15
15
|
'export' => {
|
|
16
16
|
'json' => { 'path' => 'site/activities.json' },
|
|
17
|
+
'stats' => { 'path' => 'site/stats.json' },
|
|
17
18
|
'svg' => { 'dir' => 'site/svg' },
|
|
18
19
|
'csv' => { 'path' => 'site/workouts.csv' },
|
|
19
20
|
'gpx' => { 'path' => 'site/gpx_out' },
|
|
@@ -86,9 +87,10 @@ module GitFit
|
|
|
86
87
|
|
|
87
88
|
GitFit.registered_configs.each do |reg|
|
|
88
89
|
reg[:keys].each do |key|
|
|
89
|
-
|
|
90
|
+
segments = key.is_a?(Array) ? key.map(&:to_s) : [key.to_s]
|
|
91
|
+
env_name = "#{reg[:prefix]}_#{segments.join('_').upcase}"
|
|
90
92
|
next unless ENV.key?(env_name)
|
|
91
|
-
set_nested(@data, reg[:config_path] +
|
|
93
|
+
set_nested(@data, reg[:config_path] + segments, ENV[env_name])
|
|
92
94
|
end
|
|
93
95
|
end
|
|
94
96
|
end
|
|
@@ -16,6 +16,9 @@ module GitFit
|
|
|
16
16
|
# strava: # env: GIT_FIT_STRAVA_CLIENT_ID
|
|
17
17
|
# client_id: "" # env: GIT_FIT_STRAVA_CLIENT_SECRET
|
|
18
18
|
# refresh_token: "" # env: GIT_FIT_STRAVA_REFRESH_TOKEN
|
|
19
|
+
# web_auth: # web original-file fetch (git fit strava fetch)
|
|
20
|
+
# jwt: "" # env: GIT_FIT_STRAVA_WEB_AUTH_JWT (strava_remember_token)
|
|
21
|
+
# auth_seed: "" # env: GIT_FIT_STRAVA_WEB_AUTH_AUTH_SEED (base64 JWT)
|
|
19
22
|
|
|
20
23
|
# garmin: # env: GIT_FIT_GARMIN_EMAIL
|
|
21
24
|
# email: "" # env: GIT_FIT_GARMIN_PASSWORD
|
|
@@ -44,6 +47,9 @@ module GitFit
|
|
|
44
47
|
json:
|
|
45
48
|
path: site/activities.json
|
|
46
49
|
|
|
50
|
+
# csv:
|
|
51
|
+
# path: site/workouts.csv
|
|
52
|
+
|
|
47
53
|
privacy:
|
|
48
54
|
start_end_range: 200
|
|
49
55
|
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module GitFit
|
|
6
|
+
module Export
|
|
7
|
+
class Calculator
|
|
8
|
+
def initialize(db:, activity_filter: nil)
|
|
9
|
+
@db = db
|
|
10
|
+
@activity_filter = activity_filter
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
ds = @db[:activities]
|
|
15
|
+
ds = ds.where(sport_category: @activity_filter) if @activity_filter
|
|
16
|
+
|
|
17
|
+
all = ds.all
|
|
18
|
+
return empty_stats if all.empty?
|
|
19
|
+
|
|
20
|
+
{
|
|
21
|
+
summary: compute_summary(all),
|
|
22
|
+
monthly: compute_monthly(all),
|
|
23
|
+
yearly: compute_yearly(all),
|
|
24
|
+
streaks: compute_streaks(all),
|
|
25
|
+
activity_types: distinct_categories(all),
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def empty_stats
|
|
32
|
+
{ summary: {}, monthly: [], yearly: [], streaks: {}, activity_types: [] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def compute_summary(rows)
|
|
36
|
+
total_dist = 0.0
|
|
37
|
+
total_time = 0
|
|
38
|
+
total_elev = 0.0
|
|
39
|
+
breakdown = Hash.new { |h, k| h[k] = { count: 0, distance_km: 0.0, moving_time_h: 0.0 } }
|
|
40
|
+
|
|
41
|
+
rows.each do |r|
|
|
42
|
+
cat = r[:sport_category] || 'other'
|
|
43
|
+
d = r[:distance] || 0
|
|
44
|
+
t = r[:moving_time] || 0
|
|
45
|
+
e = r[:elevation_gain] || 0
|
|
46
|
+
|
|
47
|
+
total_dist += d
|
|
48
|
+
total_time += t
|
|
49
|
+
total_elev += e
|
|
50
|
+
|
|
51
|
+
breakdown[cat][:count] += 1
|
|
52
|
+
breakdown[cat][:distance_km] += d / 1000.0
|
|
53
|
+
breakdown[cat][:moving_time_h] += t / 3600.0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
cadences = rows.map { |r| r[:average_cadence] }.compact
|
|
57
|
+
powers = rows.map { |r| r[:average_power] }.compact
|
|
58
|
+
|
|
59
|
+
grouped_run_ids = if @db.table_exists?(:dedup_group_members)
|
|
60
|
+
@db[:dedup_group_members].select_map(:run_id)
|
|
61
|
+
else
|
|
62
|
+
[]
|
|
63
|
+
end
|
|
64
|
+
unique = rows.reject { |a| grouped_run_ids.include?(a[:run_id]) }
|
|
65
|
+
unique_count = unique.size + (@db.table_exists?(:dedup_groups) ? @db[:dedup_groups].count : 0)
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
total_activities: rows.size,
|
|
69
|
+
unique_activities: unique_count,
|
|
70
|
+
total_distance_km: total_dist / 1000.0,
|
|
71
|
+
total_moving_time_h: total_time / 3600.0,
|
|
72
|
+
total_elevation_gain_m: total_elev,
|
|
73
|
+
avg_cadence: cadences.any? ? (cadences.sum / cadences.size).round(1) : nil,
|
|
74
|
+
avg_power: powers.any? ? (powers.sum / powers.size).round(1) : nil,
|
|
75
|
+
sport_breakdown: breakdown.sort_by { |_, v| -v[:count] }.map do |cat, v|
|
|
76
|
+
{ category: cat, count: v[:count], distance_km: v[:distance_km].round(1),
|
|
77
|
+
moving_time_h: v[:moving_time_h].round(1) }
|
|
78
|
+
end,
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def compute_monthly(rows)
|
|
83
|
+
monthly = Hash.new do |h, k|
|
|
84
|
+
h[k] = { count: 0, distance_km: 0.0, moving_time_h: 0.0,
|
|
85
|
+
total_hr: 0.0, hr_count: 0, total_elev: 0.0,
|
|
86
|
+
total_cadence: 0.0, cadence_count: 0,
|
|
87
|
+
total_power: 0.0, power_count: 0 }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
rows.each do |r|
|
|
91
|
+
next unless r[:start_date_local] || r[:start_date]
|
|
92
|
+
|
|
93
|
+
ym = (r[:start_date_local] || r[:start_date])[0..6]
|
|
94
|
+
d = r[:distance] || 0
|
|
95
|
+
t = r[:moving_time] || 0
|
|
96
|
+
hr = r[:average_heartrate]
|
|
97
|
+
e = r[:elevation_gain] || 0
|
|
98
|
+
|
|
99
|
+
monthly[ym][:count] += 1
|
|
100
|
+
monthly[ym][:distance_km] += d / 1000.0
|
|
101
|
+
monthly[ym][:moving_time_h] += t / 3600.0
|
|
102
|
+
monthly[ym][:total_elev] += e
|
|
103
|
+
if hr && hr > 0
|
|
104
|
+
monthly[ym][:total_hr] += hr
|
|
105
|
+
monthly[ym][:hr_count] += 1
|
|
106
|
+
end
|
|
107
|
+
cad = r[:average_cadence]
|
|
108
|
+
pow = r[:average_power]
|
|
109
|
+
if cad && cad > 0
|
|
110
|
+
monthly[ym][:total_cadence] += cad
|
|
111
|
+
monthly[ym][:cadence_count] += 1
|
|
112
|
+
end
|
|
113
|
+
if pow && pow > 0
|
|
114
|
+
monthly[ym][:total_power] += pow
|
|
115
|
+
monthly[ym][:power_count] += 1
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
monthly.sort.map do |ym, v|
|
|
120
|
+
pace = if v[:moving_time_h] > 0 && v[:distance_km] > 0
|
|
121
|
+
format_pace(v[:moving_time_h] * 3600 / v[:distance_km])
|
|
122
|
+
end
|
|
123
|
+
{
|
|
124
|
+
year: ym[0..3].to_i,
|
|
125
|
+
month: ym[5..6].to_i,
|
|
126
|
+
count: v[:count],
|
|
127
|
+
distance_km: v[:distance_km].round(1),
|
|
128
|
+
moving_time_h: v[:moving_time_h].round(1),
|
|
129
|
+
avg_pace: pace,
|
|
130
|
+
avg_hr: v[:hr_count] > 0 ? (v[:total_hr] / v[:hr_count]).round(0) : nil,
|
|
131
|
+
avg_cadence: v[:cadence_count] > 0 ? (v[:total_cadence] / v[:cadence_count]).round(0) : nil,
|
|
132
|
+
avg_power: v[:power_count] > 0 ? (v[:total_power] / v[:power_count]).round(0) : nil,
|
|
133
|
+
elevation_gain_m: v[:total_elev].round(0),
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def compute_yearly(rows)
|
|
139
|
+
by_year = Hash.new { |h, k| h[k] = [] }
|
|
140
|
+
rows.each do |r|
|
|
141
|
+
next unless r[:start_date_local] || r[:start_date]
|
|
142
|
+
by_year[(r[:start_date_local] || r[:start_date])[0..3].to_i] << r
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
by_year.sort.map do |year, acts|
|
|
146
|
+
total_dist = acts.sum { |r| r[:distance] || 0 } / 1000.0
|
|
147
|
+
total_time = acts.sum { |r| r[:moving_time] || 0 }
|
|
148
|
+
longest = acts.map { |r| r[:distance] || 0 }.max / 1000.0
|
|
149
|
+
|
|
150
|
+
streak = calculate_streak(acts)
|
|
151
|
+
|
|
152
|
+
{ year: year, count: acts.size, distance_km: total_dist.round(1),
|
|
153
|
+
moving_time_h: (total_time / 3600.0).round(1),
|
|
154
|
+
longest_km: longest.round(1), streak: streak }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def compute_streaks(rows)
|
|
159
|
+
dates = rows.filter_map do |r|
|
|
160
|
+
next nil unless r[:start_date_local] || r[:start_date]
|
|
161
|
+
begin
|
|
162
|
+
Date.parse(r[:start_date_local] || r[:start_date])
|
|
163
|
+
rescue StandardError
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
end.uniq.sort
|
|
167
|
+
|
|
168
|
+
return { current: 0, all_time: 0, year_max: {} } if dates.empty?
|
|
169
|
+
|
|
170
|
+
max_streak = 1
|
|
171
|
+
current_streak = 1
|
|
172
|
+
|
|
173
|
+
(1...dates.length).each do |i|
|
|
174
|
+
if (dates[i] - dates[i - 1]).to_i == 1
|
|
175
|
+
current_streak += 1
|
|
176
|
+
max_streak = current_streak if current_streak > max_streak
|
|
177
|
+
else
|
|
178
|
+
current_streak = 1
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
today = Date.today
|
|
183
|
+
current = if dates.last == today || dates.last == today - 1
|
|
184
|
+
streak = 1
|
|
185
|
+
j = dates.length - 2
|
|
186
|
+
while j >= 0 && (dates[j + 1] - dates[j]).to_i == 1
|
|
187
|
+
streak += 1
|
|
188
|
+
j -= 1
|
|
189
|
+
end
|
|
190
|
+
streak
|
|
191
|
+
else
|
|
192
|
+
0
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
{ current: current, all_time: max_streak }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def distinct_categories(rows)
|
|
199
|
+
rows.map { |r| r[:sport_category] }.uniq.compact.sort
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def format_pace(sec_per_km)
|
|
203
|
+
min = (sec_per_km / 60).to_i
|
|
204
|
+
sec = sec_per_km.to_i % 60
|
|
205
|
+
"#{min}'#{format('%02d', sec)}\""
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def calculate_streak(rows)
|
|
209
|
+
dates = rows.filter_map do |r|
|
|
210
|
+
next nil unless r[:start_date_local] || r[:start_date]
|
|
211
|
+
begin
|
|
212
|
+
Date.parse(r[:start_date_local] || r[:start_date])
|
|
213
|
+
rescue StandardError
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
end.uniq.sort
|
|
217
|
+
return 0 if dates.empty?
|
|
218
|
+
|
|
219
|
+
max_s = 1
|
|
220
|
+
cur = 1
|
|
221
|
+
(1...dates.length).each do |i|
|
|
222
|
+
if (dates[i] - dates[i - 1]).to_i == 1
|
|
223
|
+
cur += 1
|
|
224
|
+
max_s = cur if cur > max_s
|
|
225
|
+
else
|
|
226
|
+
cur = 1
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
max_s
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'csv'
|
|
4
|
+
|
|
5
|
+
module GitFit
|
|
6
|
+
module Export
|
|
7
|
+
class CSV
|
|
8
|
+
HEADERS = %w[
|
|
9
|
+
run_id name distance moving_time elapsed_time
|
|
10
|
+
sport_category sport_type start_date start_date_local
|
|
11
|
+
location_country summary_polyline
|
|
12
|
+
average_heartrate max_heartrate average_cadence max_cadence
|
|
13
|
+
average_power max_power calories average_temperature
|
|
14
|
+
average_speed elevation_gain source dedup_group_id
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(db:, output:, activity_filter: nil)
|
|
18
|
+
@db = db
|
|
19
|
+
@output = output
|
|
20
|
+
@activity_filter = activity_filter
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
run_id_to_group = {}
|
|
25
|
+
@db[:dedup_group_members].each { |gm| run_id_to_group[gm[:run_id]] = gm[:group_id] }
|
|
26
|
+
|
|
27
|
+
dataset = @db[:activities].order(Sequel.desc(:start_date))
|
|
28
|
+
dataset = dataset.where(sport_category: @activity_filter) if @activity_filter
|
|
29
|
+
|
|
30
|
+
count = 0
|
|
31
|
+
::CSV.open(@output, 'w') do |csv|
|
|
32
|
+
csv << HEADERS
|
|
33
|
+
dataset.each do |a|
|
|
34
|
+
row = HEADERS[0...-1].map { |h| a[h.to_sym] }
|
|
35
|
+
row << run_id_to_group[a[:run_id]]
|
|
36
|
+
csv << row
|
|
37
|
+
count += 1
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
count
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module GitFit
|
|
6
|
+
module Export
|
|
7
|
+
class Stats
|
|
8
|
+
def initialize(db:, output:, activity_filter: nil)
|
|
9
|
+
@db = db
|
|
10
|
+
@output = output
|
|
11
|
+
@activity_filter = activity_filter
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
calculator = Calculator.new(db: @db, activity_filter: @activity_filter)
|
|
16
|
+
stats = calculator.call
|
|
17
|
+
File.write(@output, ::JSON.pretty_generate(stats))
|
|
18
|
+
stats[:summary][:total_activities] || 0
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/git_fit/export.rb
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module GitFit
|
|
6
|
+
module StravaWeb
|
|
7
|
+
# Content validation for downloaded Strava files (FIT/TCX/GPX/JSON).
|
|
8
|
+
module FileCheck
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def valid_format?(content, format)
|
|
12
|
+
case format
|
|
13
|
+
when :fit
|
|
14
|
+
content[0, 64].include?('.FIT'.b) || content.start_with?("\x0E\x10".b)
|
|
15
|
+
when :tcx
|
|
16
|
+
content.include?('TrainingCenterDatabase'.b)
|
|
17
|
+
when :gpx
|
|
18
|
+
content.include?('<gpx'.b)
|
|
19
|
+
when :json
|
|
20
|
+
JSON.parse(content)
|
|
21
|
+
true
|
|
22
|
+
else
|
|
23
|
+
!content.empty?
|
|
24
|
+
end
|
|
25
|
+
rescue StandardError
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate_existing_file(path)
|
|
30
|
+
return false unless File.exist?(path) && File.size(path) > 0
|
|
31
|
+
|
|
32
|
+
content = File.read(path, mode: 'rb', length: 4096)
|
|
33
|
+
case File.extname(path).downcase
|
|
34
|
+
when '.fit'
|
|
35
|
+
content.start_with?("\x0E\x10".b)
|
|
36
|
+
when '.tcx'
|
|
37
|
+
content.include?('TrainingCenterDatabase'.b)
|
|
38
|
+
when '.gpx'
|
|
39
|
+
content.include?('<gpx'.b)
|
|
40
|
+
when '.json'
|
|
41
|
+
JSON.parse(content)
|
|
42
|
+
true
|
|
43
|
+
else
|
|
44
|
+
!content.empty?
|
|
45
|
+
end
|
|
46
|
+
rescue StandardError
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def describe_error(content)
|
|
51
|
+
head = content[0, 200].b
|
|
52
|
+
if head.include?('<html') || head.include?('<!DOCTYPE')
|
|
53
|
+
"Strava returned HTML (likely rate-limit or auth page) — #{content.length} bytes"
|
|
54
|
+
else
|
|
55
|
+
sample = content[0, 80].force_encoding('UTF-8').scrub('?')
|
|
56
|
+
"unexpected content format — #{sample}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/git_fit/sync/strava.rb
CHANGED
|
@@ -11,7 +11,8 @@ module GitFit
|
|
|
11
11
|
BASE_URL = 'https://www.strava.com/api/v3'
|
|
12
12
|
|
|
13
13
|
register_adapter
|
|
14
|
-
register_config :client_id, :client_secret, :refresh_token
|
|
14
|
+
register_config :client_id, :client_secret, :refresh_token,
|
|
15
|
+
%w[web_auth jwt], %w[web_auth auth_seed]
|
|
15
16
|
|
|
16
17
|
def authenticate
|
|
17
18
|
return false unless @config['client_id'].to_s != '' &&
|
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.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lax
|
|
@@ -135,6 +135,20 @@ dependencies:
|
|
|
135
135
|
- - "~>"
|
|
136
136
|
- !ruby/object:Gem::Version
|
|
137
137
|
version: '2.0'
|
|
138
|
+
- !ruby/object:Gem::Dependency
|
|
139
|
+
name: stravaweb
|
|
140
|
+
requirement: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - "~>"
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: '0.0'
|
|
145
|
+
type: :runtime
|
|
146
|
+
prerelease: false
|
|
147
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - "~>"
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: '0.0'
|
|
138
152
|
- !ruby/object:Gem::Dependency
|
|
139
153
|
name: oauth
|
|
140
154
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -177,6 +191,20 @@ dependencies:
|
|
|
177
191
|
- - "~>"
|
|
178
192
|
- !ruby/object:Gem::Version
|
|
179
193
|
version: '0.6'
|
|
194
|
+
- !ruby/object:Gem::Dependency
|
|
195
|
+
name: csv
|
|
196
|
+
requirement: !ruby/object:Gem::Requirement
|
|
197
|
+
requirements:
|
|
198
|
+
- - "~>"
|
|
199
|
+
- !ruby/object:Gem::Version
|
|
200
|
+
version: '3.0'
|
|
201
|
+
type: :runtime
|
|
202
|
+
prerelease: false
|
|
203
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
204
|
+
requirements:
|
|
205
|
+
- - "~>"
|
|
206
|
+
- !ruby/object:Gem::Version
|
|
207
|
+
version: '3.0'
|
|
180
208
|
description: A git extension CLI for aggregating, managing, and exporting fitness
|
|
181
209
|
activity data from multiple sources (Garmin, Strava, Keep, etc.)
|
|
182
210
|
email:
|
|
@@ -196,6 +224,7 @@ files:
|
|
|
196
224
|
- lib/git_fit/cli/gh_cli.rb
|
|
197
225
|
- lib/git_fit/cli/import_cli.rb
|
|
198
226
|
- lib/git_fit/cli/install_cli.rb
|
|
227
|
+
- lib/git_fit/cli/strava.rb
|
|
199
228
|
- lib/git_fit/cli/sync.rb
|
|
200
229
|
- lib/git_fit/config.rb
|
|
201
230
|
- lib/git_fit/config_template.rb
|
|
@@ -204,8 +233,11 @@ files:
|
|
|
204
233
|
- lib/git_fit/db/connection.rb
|
|
205
234
|
- lib/git_fit/db/rebuild.rb
|
|
206
235
|
- lib/git_fit/export.rb
|
|
236
|
+
- lib/git_fit/export/calculator.rb
|
|
237
|
+
- lib/git_fit/export/csv.rb
|
|
207
238
|
- lib/git_fit/export/defaults.rb
|
|
208
239
|
- lib/git_fit/export/json.rb
|
|
240
|
+
- lib/git_fit/export/stats.rb
|
|
209
241
|
- lib/git_fit/fit.rb
|
|
210
242
|
- lib/git_fit/fit/decoder.js
|
|
211
243
|
- lib/git_fit/fit/decoder.rb
|
|
@@ -232,6 +264,7 @@ files:
|
|
|
232
264
|
- lib/git_fit/source/base.rb
|
|
233
265
|
- lib/git_fit/source/tally.rb
|
|
234
266
|
- lib/git_fit/sport_mapper.rb
|
|
267
|
+
- lib/git_fit/strava_web/file_check.rb
|
|
235
268
|
- lib/git_fit/sync/base.rb
|
|
236
269
|
- lib/git_fit/sync/garmin.rb
|
|
237
270
|
- lib/git_fit/sync/garmin_base.rb
|