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.
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
@@ -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,11 @@
1
+ require "json"
2
+
3
+ module Rueda
4
+ module Output
5
+ module Ndjson
6
+ def self.activities(activities)
7
+ activities.map { JSON.generate(it.to_h) }.join("\n")
8
+ end
9
+ end
10
+ end
11
+ 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
@@ -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
@@ -0,0 +1,3 @@
1
+ module Rueda
2
+ VERSION = "0.1.0".freeze
3
+ 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"