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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a908280d55da8ff10de61a53bfefa318d7838917604d73f30a906dc62702650
4
- data.tar.gz: 8ed803dda5b4c5459235302c8fe8e22618575925aa3feab783781324ce3264d2
3
+ metadata.gz: 0d17d1744736bfa39aa88b5e914e7c312ec88fb9f10e33f8341082286473ffc9
4
+ data.tar.gz: ea386f48c48f9ebed0830509ab813c45b7359dd0063f3b7389b6e7e99575120e
5
5
  SHA512:
6
- metadata.gz: 6887ca31b7ba22ba23608a2beda2898d4c80c64d726dfd2234c2f73d054d4e8c30cac4b529c187985b88c994a03bd5dc47bfc389856897965175e6f47ada22f7
7
- data.tar.gz: '052090c32f37ea6f2a4219f187a514beb62a3ac61979796feda7e96c26aed18a7eceabe08c767ac34dc79e7916abb1e4d5df51e86d7c4c23ff3e8267d809ff33'
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'
@@ -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
@@ -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: "Geo cache directory (empty = no cache)", default: ""
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
 
@@ -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
- env_name = "#{reg[:prefix]}_#{key.to_s.upcase}"
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] + [key.to_s], ENV[env_name])
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
@@ -7,3 +7,6 @@ end
7
7
 
8
8
  require_relative 'export/defaults'
9
9
  require_relative 'export/json'
10
+ require_relative 'export/csv'
11
+ require_relative 'export/calculator'
12
+ require_relative 'export/stats'
@@ -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
@@ -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 != '' &&
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GitFit
4
- VERSION = '0.9.9'
4
+ VERSION = '0.10.0'
5
5
  end
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.9.9
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