rueda 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +105 -0
- data/SPEC.md +228 -0
- data/exe/rueda +5 -0
- data/lib/rueda/activities.rb +33 -0
- data/lib/rueda/auth/api_key.rb +44 -0
- data/lib/rueda/auth/authenticator.rb +30 -0
- data/lib/rueda/auth/secret_file.rb +43 -0
- data/lib/rueda/auth/token.rb +35 -0
- data/lib/rueda/auth/token_cache.rb +41 -0
- data/lib/rueda/cli.rb +191 -0
- data/lib/rueda/errors.rb +50 -0
- data/lib/rueda/garmin/client.rb +107 -0
- data/lib/rueda/model/activity.rb +24 -0
- data/lib/rueda/normalize/activity.rb +139 -0
- data/lib/rueda/output/csv.rb +15 -0
- data/lib/rueda/output/json.rb +22 -0
- data/lib/rueda/output/ndjson.rb +11 -0
- data/lib/rueda/output/saved_file.rb +17 -0
- data/lib/rueda/output/table.rb +57 -0
- data/lib/rueda/output.rb +30 -0
- data/lib/rueda/version.rb +3 -0
- data/lib/rueda.rb +13 -0
- metadata +79 -0
data/lib/rueda/cli.rb
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
require "date"
|
|
2
|
+
require "json"
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Rueda
|
|
6
|
+
class CLI
|
|
7
|
+
FORMATS = %w[table json ndjson csv].freeze
|
|
8
|
+
DEFAULT_TIMEOUT = 30
|
|
9
|
+
DEFAULT_DAYS = 30
|
|
10
|
+
DATE = /\A\d{4}-\d{2}-\d{2}\z/
|
|
11
|
+
ID = /\A[1-9]\d*\z/
|
|
12
|
+
BANNER = <<~TEXT.freeze
|
|
13
|
+
Usage: rueda COMMAND [options]
|
|
14
|
+
|
|
15
|
+
Commands:
|
|
16
|
+
auth check Check the key and request a new token
|
|
17
|
+
activities List activities (--since, --until, --type, --limit)
|
|
18
|
+
activity ID Show one activity, or download its original file (--fit, -o, --force)
|
|
19
|
+
version Print the version
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
TEXT
|
|
23
|
+
|
|
24
|
+
def initialize(stdout: $stdout, stderr: $stderr, env: ENV)
|
|
25
|
+
@stdout = stdout
|
|
26
|
+
@stderr = stderr
|
|
27
|
+
@env = env
|
|
28
|
+
@options = {timeout: DEFAULT_TIMEOUT, quiet: false, types: []}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run(argv)
|
|
32
|
+
dispatch(command(argv))
|
|
33
|
+
rescue => e
|
|
34
|
+
error = as_rueda_error(e)
|
|
35
|
+
report(error)
|
|
36
|
+
exit(error.exit_status)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def command(argv)
|
|
42
|
+
args = parser.parse(argv)
|
|
43
|
+
@options[:command] || args
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parser
|
|
47
|
+
@parser ||= OptionParser.new(BANNER) do
|
|
48
|
+
it.require_exact = true
|
|
49
|
+
it.on("--format FORMAT", FORMATS) { @options[:format] = it }
|
|
50
|
+
it.on("--api-key-file PATH") { @options[:api_key_file] = it }
|
|
51
|
+
it.on("--timeout SECONDS", Float) { @options[:timeout] = it }
|
|
52
|
+
it.on("--raw") { @options[:raw] = true }
|
|
53
|
+
it.on("--quiet") { @options[:quiet] = true }
|
|
54
|
+
it.on("--since DATE") { @options[:since] = date(it) }
|
|
55
|
+
it.on("--until DATE") { @options[:until] = date(it) }
|
|
56
|
+
it.on("--type TYPE") { @options[:types] << it }
|
|
57
|
+
it.on("--limit N", Integer) { @options[:limit] = positive(it) }
|
|
58
|
+
it.on("--fit") { @options[:fit] = true }
|
|
59
|
+
it.on("-o", "--output FILE") { @options[:output] = it }
|
|
60
|
+
it.on("--force") { @options[:force] = true }
|
|
61
|
+
it.on("-h", "--help") { @options[:command] = ["help"] }
|
|
62
|
+
it.on("--version") { @options[:command] = ["version"] }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def dispatch(args)
|
|
67
|
+
case args
|
|
68
|
+
in ["auth", "check"] then auth_check
|
|
69
|
+
in ["activities"] then list_activities
|
|
70
|
+
in ["activity", ID => id] then @options[:fit] ? download_fit(Integer(id)) : show_activity(Integer(id))
|
|
71
|
+
in ["activity", *] then raise UsageError, "Usage: rueda activity ID, where ID is a Garmin activity ID."
|
|
72
|
+
in ["version"] then @stdout.puts(VERSION)
|
|
73
|
+
in ["help"] then @stdout.puts(parser.help)
|
|
74
|
+
else raise UsageError, "Unknown command. Run `rueda --help`."
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def auth_check
|
|
79
|
+
expires_at = authenticator.fresh_token.expires_at
|
|
80
|
+
if table?
|
|
81
|
+
@stdout.puts("Token OK, valid until #{expires_at.localtime.strftime("%Y-%m-%d %H:%M %Z")}.")
|
|
82
|
+
else
|
|
83
|
+
@stdout.puts(JSON.generate(token_expires_at: expires_at.iso8601))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def list_activities
|
|
88
|
+
to = @options[:until] || Date.today
|
|
89
|
+
from = @options[:since] || (to - DEFAULT_DAYS)
|
|
90
|
+
raise UsageError, "--since must not be after --until." if from > to
|
|
91
|
+
check_raw_format
|
|
92
|
+
|
|
93
|
+
activities = Activities.new(client:, authenticator:)
|
|
94
|
+
.list(from:, to:, types: @options[:types], limit: @options[:limit], raw: raw?)
|
|
95
|
+
query = {since: from.iso8601, until: to.iso8601, type: @options[:types]}
|
|
96
|
+
emit(Output.activities(activities, format:, query:, raw: raw?))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def show_activity(id)
|
|
100
|
+
check_raw_format
|
|
101
|
+
activity = Activities.new(client:, authenticator:).find(id, raw: raw?)
|
|
102
|
+
emit(Output.activity(activity, format:, raw: raw?))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def download_fit(id)
|
|
106
|
+
path = @options[:output] || "activity-#{id}.zip"
|
|
107
|
+
raise UsageError, "Refusing to write a ZIP to a terminal. Redirect stdout or pass -o FILE." if path == "-" && @stdout.tty?
|
|
108
|
+
|
|
109
|
+
zip = Activities.new(client:, authenticator:).fit_file(id)
|
|
110
|
+
if path == "-"
|
|
111
|
+
@stdout.binmode.write(zip)
|
|
112
|
+
else
|
|
113
|
+
Output::SavedFile.write(path, zip, force: @options.fetch(:force, false))
|
|
114
|
+
info("saved #{path}")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def check_raw_format
|
|
119
|
+
raise UsageError, "--raw works only with --format json or ndjson." if raw? && !Output::RAW_FORMATS.include?(format)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def authenticator
|
|
123
|
+
@authenticator ||= Auth::Authenticator.new(
|
|
124
|
+
api_key:,
|
|
125
|
+
client:,
|
|
126
|
+
cache: Auth::TokenCache.new(path: config_path("token.json"), warn: method(:warn))
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def client
|
|
131
|
+
@client ||= Garmin::Client.new(timeout: @options[:timeout])
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def api_key
|
|
135
|
+
Auth::ApiKey.new(file: @options[:api_key_file], env: @env, default_path: config_path("api_key"),
|
|
136
|
+
warn: method(:warn)).value
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def config_path(name)
|
|
140
|
+
File.join(@env["HOME"] || Dir.home, ".config", "rueda", name)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def date(text)
|
|
144
|
+
raise UsageError, "Dates must be YYYY-MM-DD." unless text.match?(DATE)
|
|
145
|
+
|
|
146
|
+
Date.iso8601(text)
|
|
147
|
+
rescue Date::Error
|
|
148
|
+
raise UsageError, "Dates must be YYYY-MM-DD."
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def positive(number)
|
|
152
|
+
raise UsageError, "--limit must be a positive integer." unless number.positive?
|
|
153
|
+
|
|
154
|
+
number
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def format
|
|
158
|
+
@options[:format] || ((@stdout.tty? && !raw?) ? "table" : "json")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def table? = format == "table"
|
|
162
|
+
|
|
163
|
+
def raw? = @options.fetch(:raw, false)
|
|
164
|
+
|
|
165
|
+
def emit(text)
|
|
166
|
+
@stdout.puts(text) unless text.empty?
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def warn(message) = info("warning: #{message}")
|
|
170
|
+
|
|
171
|
+
def info(message)
|
|
172
|
+
@stderr.puts("rueda: #{message}") unless @options[:quiet]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def as_rueda_error(error)
|
|
176
|
+
case error
|
|
177
|
+
when Error then error
|
|
178
|
+
when OptionParser::ParseError then UsageError.new("#{error.reason}: #{error.args.first.to_s.split("=", 2).first}")
|
|
179
|
+
else Error.new("Unexpected error (#{error.class}).")
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def report(error)
|
|
184
|
+
if table?
|
|
185
|
+
@stderr.puts("rueda: #{error.message}")
|
|
186
|
+
else
|
|
187
|
+
@stderr.puts(JSON.generate(error: {code: error.code, message: error.message}))
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
data/lib/rueda/errors.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Rueda
|
|
2
|
+
class Error < StandardError
|
|
3
|
+
CODE = "unexpected"
|
|
4
|
+
EXIT_STATUS = 1
|
|
5
|
+
|
|
6
|
+
def code = self.class::CODE
|
|
7
|
+
|
|
8
|
+
def exit_status = self.class::EXIT_STATUS
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class UsageError < Error
|
|
12
|
+
CODE = "usage"
|
|
13
|
+
EXIT_STATUS = 2
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class NoApiKey < Error
|
|
17
|
+
CODE = "no_api_key"
|
|
18
|
+
EXIT_STATUS = 3
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class AuthFailed < Error
|
|
22
|
+
CODE = "auth_failed"
|
|
23
|
+
EXIT_STATUS = 4
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class NotFound < Error
|
|
27
|
+
CODE = "not_found"
|
|
28
|
+
EXIT_STATUS = 5
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class RateLimited < Error
|
|
32
|
+
CODE = "rate_limited"
|
|
33
|
+
EXIT_STATUS = 6
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class NetworkError < Error
|
|
37
|
+
CODE = "network"
|
|
38
|
+
EXIT_STATUS = 7
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class BadResponse < Error
|
|
42
|
+
CODE = "bad_response"
|
|
43
|
+
EXIT_STATUS = 8
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class GarminError < Error
|
|
47
|
+
CODE = "garmin_error"
|
|
48
|
+
EXIT_STATUS = 9
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "../auth/token"
|
|
7
|
+
|
|
8
|
+
module Rueda
|
|
9
|
+
module Garmin
|
|
10
|
+
class Client
|
|
11
|
+
HOST = URI("https://personal.connectapi.garmin.com")
|
|
12
|
+
TOKEN_PATH = "/personalapitoken-service/personal-api/personal-api-key/token".freeze
|
|
13
|
+
ACTIVITIES_PATH = "/activitylist-service/activities/search/activities".freeze
|
|
14
|
+
DETAIL_PATH = "/activity-service/activity/%d".freeze
|
|
15
|
+
FILE_PATH = "/download-service/files/activity/%d".freeze
|
|
16
|
+
PAGE_SIZE = 99
|
|
17
|
+
STATUS_ERRORS = {"401" => AuthFailed, "403" => AuthFailed, "404" => NotFound, "429" => RateLimited}.freeze
|
|
18
|
+
NETWORK_ERRORS = [Timeout::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError, IOError,
|
|
19
|
+
Net::ProtocolError].freeze
|
|
20
|
+
|
|
21
|
+
def initialize(timeout:)
|
|
22
|
+
@timeout = timeout
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def token(api_key)
|
|
26
|
+
Auth::Token.new(post(TOKEN_PATH, authorization: "ApiKey #{api_key}").body.to_s.strip)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def each_activity(token, from:, to:, &)
|
|
30
|
+
return enum_for(__method__, token, from:, to:) unless block_given?
|
|
31
|
+
|
|
32
|
+
start = 0
|
|
33
|
+
loop do
|
|
34
|
+
page = activities_page(token, start:, from:, to:)
|
|
35
|
+
break if page.empty?
|
|
36
|
+
|
|
37
|
+
page.each(&)
|
|
38
|
+
start += page.size
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def activity(token, id)
|
|
43
|
+
detail = parse(get(format(DETAIL_PATH, id), authorization: bearer(token)))
|
|
44
|
+
raise BadResponse, "The activity is not an object." unless detail.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
detail
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def activity_file(token, id)
|
|
50
|
+
zip = get(format(FILE_PATH, id), authorization: bearer(token)).body.to_s
|
|
51
|
+
raise BadResponse, "The activity file is not a ZIP." unless zip.start_with?("PK")
|
|
52
|
+
|
|
53
|
+
zip
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def bearer(token) = "Bearer #{token}"
|
|
59
|
+
|
|
60
|
+
def activities_page(token, start:, from:, to:)
|
|
61
|
+
query = URI.encode_www_form(start:, limit: PAGE_SIZE, startDate: from.iso8601, endDate: to.iso8601)
|
|
62
|
+
page = parse(get("#{ACTIVITIES_PATH}?#{query}", authorization: bearer(token)))
|
|
63
|
+
raise BadResponse, "The activity list is not a list." unless page.is_a?(Array)
|
|
64
|
+
|
|
65
|
+
page
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def post(path, authorization:) = send_request(Net::HTTP::Post, path, authorization:)
|
|
69
|
+
|
|
70
|
+
def get(path, authorization:) = send_request(Net::HTTP::Get, path, authorization:)
|
|
71
|
+
|
|
72
|
+
def send_request(method, path, authorization:)
|
|
73
|
+
request = method.new(path)
|
|
74
|
+
request["Authorization"] = authorization
|
|
75
|
+
check(perform(request))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def perform(request)
|
|
79
|
+
http.request(request)
|
|
80
|
+
rescue Net::HTTPBadResponse
|
|
81
|
+
raise BadResponse, "Garmin sent a malformed HTTP response."
|
|
82
|
+
rescue *NETWORK_ERRORS => e
|
|
83
|
+
raise NetworkError, "Could not reach Garmin (#{e.class})."
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def http
|
|
87
|
+
Net::HTTP.new(HOST.host, HOST.port).tap do
|
|
88
|
+
it.use_ssl = true
|
|
89
|
+
it.open_timeout = @timeout
|
|
90
|
+
it.read_timeout = @timeout
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def check(response)
|
|
95
|
+
return response if response.is_a?(Net::HTTPSuccess)
|
|
96
|
+
|
|
97
|
+
raise STATUS_ERRORS.fetch(response.code, GarminError), "Garmin returned HTTP #{response.code}."
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def parse(response)
|
|
101
|
+
JSON.parse(response.body.to_s)
|
|
102
|
+
rescue JSON::ParserError
|
|
103
|
+
raise BadResponse, "Garmin's response is not JSON."
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Rueda
|
|
2
|
+
module Model
|
|
3
|
+
Activity = Data.define(
|
|
4
|
+
:garmin_id,
|
|
5
|
+
:name,
|
|
6
|
+
:type,
|
|
7
|
+
:started_at,
|
|
8
|
+
:local_date,
|
|
9
|
+
:distance_km,
|
|
10
|
+
:moving_time_s,
|
|
11
|
+
:elapsed_time_s,
|
|
12
|
+
:avg_speed_kmh,
|
|
13
|
+
:max_speed_kmh,
|
|
14
|
+
:elevation_gain_m,
|
|
15
|
+
:elevation_loss_m,
|
|
16
|
+
:avg_hr_bpm,
|
|
17
|
+
:max_hr_bpm,
|
|
18
|
+
:avg_power_w,
|
|
19
|
+
:calories,
|
|
20
|
+
:avg_cadence_rpm,
|
|
21
|
+
:max_cadence_rpm
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
require "date"
|
|
2
|
+
require "time"
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../model/activity"
|
|
5
|
+
|
|
6
|
+
module Rueda
|
|
7
|
+
module Normalize
|
|
8
|
+
class Activity
|
|
9
|
+
TYPE_KEY = %w[activityType typeKey].freeze
|
|
10
|
+
START_TIME = /\A(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/
|
|
11
|
+
MPS_TO_KMH = 3.6
|
|
12
|
+
FIELDS = {
|
|
13
|
+
distance: "distance",
|
|
14
|
+
moving: "movingDuration",
|
|
15
|
+
elapsed: "elapsedDuration",
|
|
16
|
+
max_speed: "maxSpeed",
|
|
17
|
+
elevation_gain: "elevationGain",
|
|
18
|
+
elevation_loss: "elevationLoss",
|
|
19
|
+
avg_hr: "averageHR",
|
|
20
|
+
max_hr: "maxHR",
|
|
21
|
+
avg_power: "avgPower",
|
|
22
|
+
calories: "calories",
|
|
23
|
+
avg_cadence: "averageBikingCadenceInRevPerMinute",
|
|
24
|
+
max_cadence: "maxBikingCadenceInRevPerMinute"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def self.call(raw) = new(raw).call
|
|
28
|
+
|
|
29
|
+
def self.type(raw) = new(raw).type
|
|
30
|
+
|
|
31
|
+
def initialize(raw)
|
|
32
|
+
raise BadResponse, "An activity is not an object." unless raw.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
@raw = raw
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def call
|
|
38
|
+
Model::Activity.new(
|
|
39
|
+
garmin_id:, name:, type:, started_at:, local_date:, distance_km:, moving_time_s:, elapsed_time_s:,
|
|
40
|
+
avg_speed_kmh:, max_speed_kmh:, elevation_gain_m:, elevation_loss_m:, avg_hr_bpm:, max_hr_bpm:,
|
|
41
|
+
avg_power_w:, calories:, avg_cadence_rpm:, max_cadence_rpm:
|
|
42
|
+
)
|
|
43
|
+
rescue TypeError, ArgumentError
|
|
44
|
+
raise BadResponse, "An activity has fields of an unexpected type."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def type
|
|
48
|
+
required(@raw, *self.class::TYPE_KEY)
|
|
49
|
+
rescue TypeError
|
|
50
|
+
raise BadResponse, "An activity has an activity type of an unexpected type."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def summary = @raw
|
|
56
|
+
|
|
57
|
+
def garmin_id = Integer(required(@raw, "activityId"))
|
|
58
|
+
|
|
59
|
+
def name = @raw["activityName"]
|
|
60
|
+
|
|
61
|
+
def started_at
|
|
62
|
+
date, time = start("startTimeGMT")
|
|
63
|
+
Time.strptime("#{date} #{time} +00:00", "%Y-%m-%d %H:%M:%S %z").utc.iso8601
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def local_date
|
|
67
|
+
date, = start("startTimeLocal")
|
|
68
|
+
Date.iso8601(date).iso8601
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def distance_km = decimal(measure(:distance)&./(1000))
|
|
72
|
+
|
|
73
|
+
def moving_time_s = whole(measure(:moving))
|
|
74
|
+
|
|
75
|
+
def elapsed_time_s = whole(measure(:elapsed))
|
|
76
|
+
|
|
77
|
+
def avg_speed_kmh
|
|
78
|
+
meters = measure(:distance)
|
|
79
|
+
seconds = measure(:moving) || measure(:elapsed)
|
|
80
|
+
return if meters.nil? || seconds.nil? || seconds.zero?
|
|
81
|
+
|
|
82
|
+
decimal(meters / seconds * MPS_TO_KMH)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def max_speed_kmh = decimal(measure(:max_speed)&.*(MPS_TO_KMH))
|
|
86
|
+
|
|
87
|
+
def elevation_gain_m = whole(measure(:elevation_gain))
|
|
88
|
+
|
|
89
|
+
def elevation_loss_m = whole(measure(:elevation_loss))
|
|
90
|
+
|
|
91
|
+
def avg_hr_bpm = whole(measure(:avg_hr))
|
|
92
|
+
|
|
93
|
+
def max_hr_bpm = whole(measure(:max_hr))
|
|
94
|
+
|
|
95
|
+
def avg_power_w = whole(measure(:avg_power))
|
|
96
|
+
|
|
97
|
+
def calories = whole(measure(:calories))
|
|
98
|
+
|
|
99
|
+
def avg_cadence_rpm = whole(measure(:avg_cadence))
|
|
100
|
+
|
|
101
|
+
def max_cadence_rpm = whole(measure(:max_cadence))
|
|
102
|
+
|
|
103
|
+
def start(field)
|
|
104
|
+
match = START_TIME.match(required(summary, field))
|
|
105
|
+
raise BadResponse, "An activity has a malformed #{field}." unless match
|
|
106
|
+
|
|
107
|
+
match.captures
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def measure(name) = summary[self.class::FIELDS.fetch(name)]
|
|
111
|
+
|
|
112
|
+
def required(source, *keys)
|
|
113
|
+
source.dig(*keys) || raise(BadResponse, "An activity has no #{keys.join(".")}.")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def whole(number) = number&.round
|
|
117
|
+
|
|
118
|
+
def decimal(number) = number&.round(1)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
class ActivityDetail < Activity
|
|
122
|
+
TYPE_KEY = %w[activityTypeDTO typeKey].freeze
|
|
123
|
+
FIELDS = Activity::FIELDS.merge(
|
|
124
|
+
avg_power: "averagePower",
|
|
125
|
+
avg_cadence: "averageBikeCadence",
|
|
126
|
+
max_cadence: "maxBikeCadence"
|
|
127
|
+
).freeze
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def summary
|
|
132
|
+
detail_summary = @raw["summaryDTO"]
|
|
133
|
+
raise BadResponse, "An activity has no summaryDTO." unless detail_summary.is_a?(Hash)
|
|
134
|
+
|
|
135
|
+
detail_summary
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require "csv"
|
|
2
|
+
require_relative "../model/activity"
|
|
3
|
+
|
|
4
|
+
module Rueda
|
|
5
|
+
module Output
|
|
6
|
+
module Csv
|
|
7
|
+
def self.activities(activities)
|
|
8
|
+
CSV.generate do |csv|
|
|
9
|
+
csv << Model::Activity.members
|
|
10
|
+
activities.each { csv << it.deconstruct }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Rueda
|
|
4
|
+
module Output
|
|
5
|
+
module Json
|
|
6
|
+
SCHEMA_VERSION = 1
|
|
7
|
+
|
|
8
|
+
def self.activities(activities, query:)
|
|
9
|
+
JSON.generate(
|
|
10
|
+
schema_version: SCHEMA_VERSION,
|
|
11
|
+
query:,
|
|
12
|
+
count: activities.size,
|
|
13
|
+
activities: activities.map(&:to_h)
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.activity(activity)
|
|
18
|
+
JSON.generate(schema_version: SCHEMA_VERSION, activity: activity.to_h)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require_relative "../errors"
|
|
2
|
+
|
|
3
|
+
module Rueda
|
|
4
|
+
module Output
|
|
5
|
+
module SavedFile
|
|
6
|
+
NEW_FILE = File::WRONLY | File::CREAT | File::EXCL | File::BINARY
|
|
7
|
+
|
|
8
|
+
def self.write(path, bytes, force:)
|
|
9
|
+
File.open(path, force ? "wb" : NEW_FILE) { it.write(bytes) }
|
|
10
|
+
rescue Errno::EEXIST
|
|
11
|
+
raise UsageError, "#{path} already exists. Use --force to overwrite it."
|
|
12
|
+
rescue SystemCallError => e
|
|
13
|
+
raise Error, "Could not write #{path} (#{e.class})."
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Rueda
|
|
2
|
+
module Output
|
|
3
|
+
class Table
|
|
4
|
+
MISSING = "—".freeze
|
|
5
|
+
COLUMNS = {
|
|
6
|
+
"DATE" => :local_date,
|
|
7
|
+
"TYPE" => :type,
|
|
8
|
+
"NAME" => :name,
|
|
9
|
+
"KM" => :distance_km,
|
|
10
|
+
"MOVING" => :moving_time_s,
|
|
11
|
+
"KM/H" => :avg_speed_kmh,
|
|
12
|
+
"ELEV" => :elevation_gain_m,
|
|
13
|
+
"HR" => :avg_hr_bpm,
|
|
14
|
+
"CAD" => :avg_cadence_rpm
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def self.activities(activities) = new(activities).to_s
|
|
18
|
+
|
|
19
|
+
def initialize(activities)
|
|
20
|
+
@rows = [COLUMNS.keys] + activities.map { row(it) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_s
|
|
24
|
+
@rows.map { line(it) }.join("\n")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def row(activity)
|
|
30
|
+
COLUMNS.values.map { cell(activity, it) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def cell(activity, field)
|
|
34
|
+
value = activity.public_send(field)
|
|
35
|
+
return MISSING if value.nil?
|
|
36
|
+
|
|
37
|
+
(field == :moving_time_s) ? duration(value) : printable(value.to_s)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def printable(text)
|
|
41
|
+
text.gsub(/[[:cntrl:]]/, "")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def duration(seconds)
|
|
45
|
+
format("%d:%02d:%02d", seconds / 3600, seconds % 3600 / 60, seconds % 60)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def line(cells)
|
|
49
|
+
cells.zip(widths).map { |cell, width| cell.ljust(width) }.join(" ").rstrip
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def widths
|
|
53
|
+
@widths ||= @rows.transpose.map { |column| column.map(&:length).max }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/rueda/output.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "output/csv"
|
|
3
|
+
require_relative "output/json"
|
|
4
|
+
require_relative "output/ndjson"
|
|
5
|
+
require_relative "output/saved_file"
|
|
6
|
+
require_relative "output/table"
|
|
7
|
+
|
|
8
|
+
module Rueda
|
|
9
|
+
module Output
|
|
10
|
+
RAW_FORMATS = %w[json ndjson].freeze
|
|
11
|
+
|
|
12
|
+
def self.activities(activities, format:, query:, raw:)
|
|
13
|
+
case [format, raw]
|
|
14
|
+
in ["json", false] then Json.activities(activities, query:)
|
|
15
|
+
in ["json", true] then JSON.generate(activities)
|
|
16
|
+
in ["ndjson", _] then Ndjson.activities(activities)
|
|
17
|
+
in ["csv", false] then Csv.activities(activities)
|
|
18
|
+
in ["table", false] then Table.activities(activities)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.activity(activity, format:, raw:)
|
|
23
|
+
case [format, raw]
|
|
24
|
+
in ["json", false] then Json.activity(activity)
|
|
25
|
+
in ["json" | "ndjson", true] then JSON.generate(activity)
|
|
26
|
+
else activities([activity], format:, query: nil, raw:)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/rueda.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
require_relative "rueda/version"
|
|
2
|
+
require_relative "rueda/errors"
|
|
3
|
+
require_relative "rueda/auth/secret_file"
|
|
4
|
+
require_relative "rueda/auth/token"
|
|
5
|
+
require_relative "rueda/auth/api_key"
|
|
6
|
+
require_relative "rueda/auth/token_cache"
|
|
7
|
+
require_relative "rueda/auth/authenticator"
|
|
8
|
+
require_relative "rueda/garmin/client"
|
|
9
|
+
require_relative "rueda/model/activity"
|
|
10
|
+
require_relative "rueda/normalize/activity"
|
|
11
|
+
require_relative "rueda/output"
|
|
12
|
+
require_relative "rueda/activities"
|
|
13
|
+
require_relative "rueda/cli"
|