ruby_garmin_connect 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '08c6d390104bc4f19e46f95d0726171890e2c0f3c41e165b1afca94031c0f243'
4
+ data.tar.gz: 8225469c95d81af76180c1e6e1c4229f3caaa27cef9eb675e1c514ec208b1145
5
+ SHA512:
6
+ metadata.gz: e3f740b9dda27508940450bacfe50f7ad74e060f2c9f97100500fcb1b200424cfb425efd0a187d61f5d55e03af04e3ada0b2029284a6542adbefb70f971c0ede
7
+ data.tar.gz: 2609e8c7e033db637fa8358c409cda7d5d31c8018b6d43a7506a309140febaa90c7ce271a79600cdeea40b0a5f278304f7511ccb045cfe6b7b2ac706ada4e6e1
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-02-11)
4
+
5
+ - Initial release
6
+ - Full Garmin Connect OAuth authentication (SSO + OAuth1 + OAuth2)
7
+ - MFA support
8
+ - Token persistence (file and string-based, garth-compatible)
9
+ - Automatic token refresh
10
+ - 108 API methods across 9 categories:
11
+ - User & Profile
12
+ - Daily Health (steps, HR, HRV, sleep, stress, body battery, SpO2, respiration)
13
+ - Activities (list, details, CRUD, download in 5 formats, upload)
14
+ - Body Composition & Weight (weigh-ins, hydration, blood pressure)
15
+ - Advanced Metrics (VO2 max, training readiness, endurance/hill score, race predictions, lactate threshold)
16
+ - Devices & Gear (device settings, gear management, gear-activity linking)
17
+ - Badges, Challenges & Goals (earned/available badges, challenges, personal records, goals)
18
+ - Workouts & Training Plans
19
+ - Wellness (menstrual cycle, pregnancy, lifestyle logging, GraphQL)
20
+ - Retry with exponential backoff on server errors
21
+ - Typed error hierarchy
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DRBragg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,295 @@
1
+ # GarminConnect
2
+
3
+ A Ruby client for the Garmin Connect API. Provides access to health, fitness, activity, and device data from your Garmin account.
4
+
5
+ Inspired by [python-garminconnect](https://github.com/cyberjunky/python-garminconnect), rebuilt idiomatically for Ruby.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "garmin_connect"
13
+ ```
14
+
15
+ Or install directly:
16
+
17
+ ```
18
+ gem install garmin_connect
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ```ruby
24
+ require "garmin_connect"
25
+
26
+ # Login with credentials (tokens are saved to ~/.garminconnect automatically)
27
+ client = GarminConnect.login(email: "you@example.com", password: "your-password")
28
+
29
+ # Subsequent sessions resume from saved tokens (no re-login for ~1 year)
30
+ client = GarminConnect.login
31
+
32
+ # Get today's stats
33
+ puts client.daily_summary
34
+ puts client.heart_rates
35
+ puts client.sleep_data
36
+ puts client.stress
37
+ ```
38
+
39
+ ## Authentication
40
+
41
+ The gem uses the same OAuth flow as the Garmin Connect mobile app:
42
+
43
+ 1. SSO login with your email/password
44
+ 2. Exchange for an OAuth1 token (~1 year lifetime)
45
+ 3. Exchange OAuth1 for an OAuth2 Bearer token (~20 hours)
46
+ 4. Auto-refresh when the OAuth2 token expires
47
+
48
+ ### MFA Support
49
+
50
+ If your account has MFA enabled, the gem prompts via `$stdin` by default. You can provide a custom handler:
51
+
52
+ ```ruby
53
+ client = GarminConnect::Client.new(
54
+ email: "you@example.com",
55
+ password: "your-password",
56
+ mfa_handler: -> { print "MFA code: "; gets.chomp }
57
+ )
58
+ client.login
59
+ ```
60
+
61
+ ### Token Storage
62
+
63
+ Tokens are saved to `~/.garminconnect` by default. You can customize this:
64
+
65
+ ```ruby
66
+ # Custom directory
67
+ client = GarminConnect::Client.new(token_dir: "/path/to/tokens")
68
+
69
+ # Base64-encoded string (useful for environment variables)
70
+ encoded = client.dump_tokens
71
+ client = GarminConnect::Client.new(token_string: encoded, token_dir: nil)
72
+
73
+ # Disable persistence entirely
74
+ client = GarminConnect::Client.new(token_dir: nil)
75
+ ```
76
+
77
+ ### Token Interoperability
78
+
79
+ Token files are compatible with [garth](https://github.com/matin/garth) (the Python auth library). If you have existing tokens from the Python library, point `token_dir` at the same directory.
80
+
81
+ ## API Reference
82
+
83
+ ### User & Profile
84
+
85
+ ```ruby
86
+ client.user_settings # Measurement system, sleep settings, etc.
87
+ client.user_profile # Profile configuration
88
+ client.personal_information # Age, gender, email, biometric profile
89
+ client.display_name # "YourDisplayName"
90
+ client.full_name # "Your Full Name"
91
+ client.unit_system # "statute_us" or "metric"
92
+ ```
93
+
94
+ ### Daily Health
95
+
96
+ ```ruby
97
+ client.daily_summary(date) # Steps, calories, distance, active minutes
98
+ client.heart_rates(date) # Heart rate data with resting HR
99
+ client.resting_heart_rate(start_date, end_date)
100
+ client.hrv(date) # Heart rate variability
101
+ client.sleep_data(date) # Sleep stages, duration, scores
102
+ client.stress(date) # All-day stress levels
103
+ client.body_battery(start, end) # Body battery reports
104
+ client.body_battery_events(date)
105
+ client.steps_data(date) # Steps chart data
106
+ client.floors(date) # Floors climbed
107
+ client.respiration(date) # Respiration rate
108
+ client.spo2(date) # Blood oxygen
109
+ client.intensity_minutes(date) # Intensity minutes
110
+ client.daily_events(date) # Auto-detected activities
111
+ client.request_reload(date) # Request data reload from device
112
+ ```
113
+
114
+ ### Activities
115
+
116
+ ```ruby
117
+ # Listing
118
+ client.activities(start: 0, limit: 20)
119
+ client.activities_by_date("2026-01-01", "2026-01-31")
120
+ client.activity_count
121
+ client.last_activity
122
+
123
+ # Details
124
+ client.activity(activity_id)
125
+ client.activity_details(activity_id)
126
+ client.activity_splits(activity_id)
127
+ client.activity_typed_splits(activity_id)
128
+ client.activity_split_summaries(activity_id)
129
+ client.activity_weather(activity_id)
130
+ client.activity_hr_zones(activity_id)
131
+ client.activity_power_zones(activity_id)
132
+ client.activity_exercise_sets(activity_id)
133
+ client.activity_types
134
+
135
+ # CRUD
136
+ client.create_activity(payload_hash)
137
+ client.rename_activity(activity_id, "Morning Run")
138
+ client.update_activity_type(activity_id, type_dto)
139
+ client.delete_activity(activity_id)
140
+
141
+ # Download & Upload
142
+ client.download_activity(activity_id, format: :original) # :tcx, :gpx, :kml, :csv
143
+ client.upload_activity("/path/to/file.fit")
144
+
145
+ # Progress
146
+ client.progress_summary("2026-01-01", "2026-12-31", metric: "distance")
147
+ ```
148
+
149
+ ### Body Composition & Weight
150
+
151
+ ```ruby
152
+ client.body_composition(start_date, end_date)
153
+ client.weigh_ins(start_date, end_date)
154
+ client.daily_weigh_ins(date)
155
+ client.add_weigh_in(84.5, date: "2026-02-11", unit_key: "kg")
156
+ client.delete_weigh_in(date, weight_pk)
157
+ ```
158
+
159
+ ### Hydration
160
+
161
+ ```ruby
162
+ client.hydration(date)
163
+ client.log_hydration(250, date: "2026-02-11") # 250 ml
164
+ client.log_hydration(-250, date: "2026-02-11") # Subtract
165
+ ```
166
+
167
+ ### Blood Pressure
168
+
169
+ ```ruby
170
+ client.blood_pressure(start_date, end_date)
171
+ client.log_blood_pressure(systolic: 120, diastolic: 80, pulse: 65)
172
+ client.delete_blood_pressure(date, version)
173
+ ```
174
+
175
+ ### Advanced Metrics
176
+
177
+ ```ruby
178
+ client.max_metrics(date) # VO2 Max
179
+ client.training_readiness(date) # Training readiness score
180
+ client.training_status(date) # Aggregated training status
181
+ client.endurance_score(date) # Single day
182
+ client.endurance_score(start_date: s, end_date: e) # Date range
183
+ client.hill_score(date)
184
+ client.race_predictions # 5k, 10k, half, full marathon
185
+ client.fitness_age(date)
186
+ client.lactate_threshold # Latest
187
+ client.lactate_threshold_history(start_date, end_date)
188
+ client.cycling_ftp # Functional Threshold Power
189
+ ```
190
+
191
+ ### Historical Data
192
+
193
+ ```ruby
194
+ client.daily_steps(start_date, end_date) # Auto-chunked at 28 days
195
+ client.weekly_steps(end_date, weeks: 52)
196
+ client.weekly_stress(end_date, weeks: 52)
197
+ client.weekly_intensity_minutes(start_date, end_date)
198
+ ```
199
+
200
+ ### Devices & Gear
201
+
202
+ ```ruby
203
+ # Devices
204
+ client.devices
205
+ client.device_settings(device_id)
206
+ client.last_used_device
207
+ client.primary_training_device
208
+ client.device_solar_data(device_id, start_date, end_date)
209
+ client.device_alarms # Alarms across all devices
210
+
211
+ # Gear
212
+ client.gear
213
+ client.activity_gear(activity_id)
214
+ client.gear_stats(gear_uuid)
215
+ client.gear_defaults
216
+ client.set_gear_default(gear_uuid, activity_type)
217
+ client.link_gear(gear_uuid, activity_id)
218
+ client.unlink_gear(gear_uuid, activity_id)
219
+ client.gear_activities(gear_uuid)
220
+ ```
221
+
222
+ ### Badges, Challenges & Goals
223
+
224
+ ```ruby
225
+ client.earned_badges
226
+ client.available_badges
227
+ client.in_progress_badges
228
+ client.adhoc_challenges
229
+ client.badge_challenges
230
+ client.available_badge_challenges
231
+ client.non_completed_badge_challenges
232
+ client.virtual_challenges
233
+ client.personal_records
234
+ client.goals(status: "active")
235
+ ```
236
+
237
+ ### Workouts & Training Plans
238
+
239
+ ```ruby
240
+ client.workouts(start: 0, limit: 20)
241
+ client.workout(workout_id)
242
+ client.download_workout(workout_id)
243
+ client.create_workout(payload_hash)
244
+ client.scheduled_workout(id)
245
+ client.training_plans
246
+ client.training_plan(plan_id)
247
+ client.adaptive_training_plan(plan_id)
248
+ ```
249
+
250
+ ### Wellness & Misc
251
+
252
+ ```ruby
253
+ client.menstrual_data(date)
254
+ client.menstrual_calendar(start_date, end_date)
255
+ client.pregnancy_summary
256
+ client.lifestyle_logging(date)
257
+ client.graphql(query_string, variables: {})
258
+ ```
259
+
260
+ ## Error Handling
261
+
262
+ ```ruby
263
+ begin
264
+ client.daily_summary
265
+ rescue GarminConnect::UnauthorizedError
266
+ # Token expired or invalid
267
+ rescue GarminConnect::TooManyRequestsError
268
+ # Rate limited, back off
269
+ rescue GarminConnect::NotFoundError
270
+ # Resource doesn't exist
271
+ rescue GarminConnect::ServerError
272
+ # Garmin's servers are having issues
273
+ rescue GarminConnect::HTTPError => e
274
+ # Any other HTTP error
275
+ puts e.status
276
+ puts e.body
277
+ rescue GarminConnect::AuthenticationError
278
+ # Login/token issues
279
+ rescue GarminConnect::Error
280
+ # Catch-all for gem errors
281
+ end
282
+ ```
283
+
284
+ ## Development
285
+
286
+ ```
287
+ git clone https://github.com/drbragg/garmin_connect.git
288
+ cd garmin_connect
289
+ bundle install
290
+ bundle exec rspec
291
+ ```
292
+
293
+ ## License
294
+
295
+ MIT License. See [LICENSE](LICENSE).
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GarminConnect
4
+ module API
5
+ # Activity listing, details, CRUD, and download/upload endpoints.
6
+ module Activities
7
+ DOWNLOAD_FORMATS = {
8
+ original: "/download-service/files/activity/%s",
9
+ tcx: "/download-service/export/tcx/activity/%s",
10
+ gpx: "/download-service/export/gpx/activity/%s",
11
+ kml: "/download-service/export/kml/activity/%s",
12
+ csv: "/download-service/export/csv/activity/%s"
13
+ }.freeze
14
+
15
+ # List activities with optional filters.
16
+ # @param start [Integer] pagination offset
17
+ # @param limit [Integer] max results
18
+ # @param activity_type [String, nil] filter by activity type
19
+ # @param sort_order [String] "asc" or "desc"
20
+ def activities(start: 0, limit: 20, activity_type: nil, sort_order: "desc")
21
+ params = { "start" => start, "limit" => limit }
22
+ params["activityType"] = activity_type if activity_type
23
+ params["sortOrder"] = sort_order
24
+
25
+ connection.get("/activitylist-service/activities/search/activities", params: params)
26
+ end
27
+
28
+ # Get activities within a date range.
29
+ # @param start_date [Date, String]
30
+ # @param end_date [Date, String]
31
+ # @param activity_type [String, nil]
32
+ def activities_by_date(start_date, end_date, activity_type: nil)
33
+ params = {
34
+ "startDate" => format_date(start_date),
35
+ "endDate" => format_date(end_date),
36
+ "start" => 0,
37
+ "limit" => 100
38
+ }
39
+ params["activityType"] = activity_type if activity_type
40
+
41
+ connection.get("/activitylist-service/activities/search/activities", params: params)
42
+ end
43
+
44
+ # Get the total activity count.
45
+ def activity_count
46
+ connection.get("/activitylist-service/activities/count")
47
+ end
48
+
49
+ # Get the most recent activity.
50
+ def last_activity
51
+ results = activities(start: 0, limit: 1)
52
+ results&.first
53
+ end
54
+
55
+ # Get a single activity's summary.
56
+ # @param activity_id [String, Integer]
57
+ def activity(activity_id)
58
+ connection.get("/activity-service/activity/#{activity_id}")
59
+ end
60
+
61
+ # Get detailed activity data (charts, polylines).
62
+ # @param activity_id [String, Integer]
63
+ # @param max_chart_size [Integer]
64
+ # @param max_polyline_size [Integer]
65
+ def activity_details(activity_id, max_chart_size: 2000, max_polyline_size: 4000)
66
+ connection.get(
67
+ "/activity-service/activity/#{activity_id}/details",
68
+ params: { "maxChartSize" => max_chart_size, "maxPolylineSize" => max_polyline_size }
69
+ )
70
+ end
71
+
72
+ # Get activity splits.
73
+ def activity_splits(activity_id)
74
+ connection.get("/activity-service/activity/#{activity_id}/splits")
75
+ end
76
+
77
+ # Get typed activity splits.
78
+ def activity_typed_splits(activity_id)
79
+ connection.get("/activity-service/activity/#{activity_id}/typedsplits")
80
+ end
81
+
82
+ # Get activity split summaries.
83
+ def activity_split_summaries(activity_id)
84
+ connection.get("/activity-service/activity/#{activity_id}/split_summaries")
85
+ end
86
+
87
+ # Get weather data for an activity.
88
+ def activity_weather(activity_id)
89
+ connection.get("/activity-service/activity/#{activity_id}/weather")
90
+ end
91
+
92
+ # Get heart rate time-in-zones for an activity.
93
+ def activity_hr_zones(activity_id)
94
+ connection.get("/activity-service/activity/#{activity_id}/hrTimeInZones")
95
+ end
96
+
97
+ # Get power time-in-zones for an activity.
98
+ def activity_power_zones(activity_id)
99
+ connection.get("/activity-service/activity/#{activity_id}/powerTimeInZones")
100
+ end
101
+
102
+ # Get exercise sets for an activity.
103
+ def activity_exercise_sets(activity_id)
104
+ connection.get("/activity-service/activity/#{activity_id}/exerciseSets")
105
+ end
106
+
107
+ # Get all available activity types.
108
+ def activity_types
109
+ connection.get("/activity-service/activity/activityTypes")
110
+ end
111
+
112
+ # Get heart rate activities for a specific date.
113
+ def heart_rate_activities(date = today)
114
+ connection.get("/mobile-gateway/heartRate/forDate/#{format_date(date)}")
115
+ end
116
+
117
+ # --- CRUD ---
118
+
119
+ # Create a manual activity from a hash/JSON payload.
120
+ # @param payload [Hash] the activity data
121
+ def create_activity(payload)
122
+ connection.post("/activity-service/activity", body: payload)
123
+ end
124
+
125
+ # Rename an activity.
126
+ # @param activity_id [String, Integer]
127
+ # @param name [String]
128
+ def rename_activity(activity_id, name)
129
+ connection.put(
130
+ "/activity-service/activity/#{activity_id}",
131
+ body: { "activityId" => activity_id, "activityName" => name }
132
+ )
133
+ end
134
+
135
+ # Change an activity's type.
136
+ # @param activity_id [String, Integer]
137
+ # @param activity_type [Hash] the activityTypeDTO
138
+ def update_activity_type(activity_id, activity_type)
139
+ connection.put(
140
+ "/activity-service/activity/#{activity_id}",
141
+ body: { "activityId" => activity_id, "activityTypeDTO" => activity_type }
142
+ )
143
+ end
144
+
145
+ # Delete an activity.
146
+ def delete_activity(activity_id)
147
+ connection.delete("/activity-service/activity/#{activity_id}")
148
+ end
149
+
150
+ # --- Download / Upload ---
151
+
152
+ # Download an activity file.
153
+ # @param activity_id [String, Integer]
154
+ # @param format [Symbol] :original, :tcx, :gpx, :kml, or :csv
155
+ # @return [String] raw file bytes
156
+ def download_activity(activity_id, format: :original)
157
+ path = DOWNLOAD_FORMATS.fetch(format) do
158
+ raise ArgumentError, "Unknown format: #{format}. Use: #{DOWNLOAD_FORMATS.keys.join(", ")}"
159
+ end
160
+
161
+ connection.download(path % activity_id)
162
+ end
163
+
164
+ # Upload an activity file (FIT, GPX, or TCX).
165
+ # @param file_path [String] path to the file
166
+ def upload_activity(file_path)
167
+ connection.upload("/upload-service/upload", file_path: file_path)
168
+ end
169
+
170
+ # --- Progress ---
171
+
172
+ # Get progress summary between dates.
173
+ # @param start_date [Date, String]
174
+ # @param end_date [Date, String]
175
+ # @param metric [String] e.g., "distance", "duration", "elevationGain"
176
+ def progress_summary(start_date, end_date, metric: "distance")
177
+ connection.get(
178
+ "/fitnessstats-service/activity",
179
+ params: {
180
+ "startDate" => format_date(start_date),
181
+ "endDate" => format_date(end_date),
182
+ "aggregation" => "lifetime",
183
+ "groupByParentActivityType" => true,
184
+ "metric" => metric
185
+ }
186
+ )
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GarminConnect
4
+ module API
5
+ # Badges, challenges, personal records, and goals endpoints.
6
+ module Badges
7
+ # Get all earned badges.
8
+ def earned_badges
9
+ connection.get("/badge-service/badge/earned")
10
+ end
11
+
12
+ # Get all available badges.
13
+ def available_badges
14
+ connection.get(
15
+ "/badge-service/badge/available",
16
+ params: { "showExclusiveBadge" => true }
17
+ )
18
+ end
19
+
20
+ # Get badges currently in progress.
21
+ def in_progress_badges
22
+ earned = earned_badges
23
+ available = available_badges
24
+ return [] unless earned.is_a?(Array) && available.is_a?(Array)
25
+
26
+ earned_ids = earned.map { |b| b["badgeId"] }.compact.to_set
27
+ available.reject { |b| earned_ids.include?(b["badgeId"]) }
28
+ end
29
+
30
+ # Get historical ad-hoc challenges.
31
+ # @param start [Integer] pagination offset
32
+ # @param limit [Integer]
33
+ def adhoc_challenges(start: 0, limit: 20)
34
+ connection.get(
35
+ "/adhocchallenge-service/adHocChallenge/historical",
36
+ params: { "start" => start, "limit" => limit }
37
+ )
38
+ end
39
+
40
+ # Get completed badge challenges.
41
+ def badge_challenges(start: 0, limit: 20)
42
+ connection.get(
43
+ "/badgechallenge-service/badgeChallenge/completed",
44
+ params: { "start" => start, "limit" => limit }
45
+ )
46
+ end
47
+
48
+ # Get available badge challenges.
49
+ def available_badge_challenges(start: 0, limit: 20)
50
+ connection.get(
51
+ "/badgechallenge-service/badgeChallenge/available",
52
+ params: { "start" => start, "limit" => limit }
53
+ )
54
+ end
55
+
56
+ # Get non-completed badge challenges.
57
+ def non_completed_badge_challenges(start: 0, limit: 20)
58
+ connection.get(
59
+ "/badgechallenge-service/badgeChallenge/non-completed",
60
+ params: { "start" => start, "limit" => limit }
61
+ )
62
+ end
63
+
64
+ # Get in-progress virtual challenges.
65
+ def virtual_challenges(start: 0, limit: 20)
66
+ connection.get(
67
+ "/badgechallenge-service/virtualChallenge/inProgress",
68
+ params: { "start" => start, "limit" => limit }
69
+ )
70
+ end
71
+
72
+ # --- Personal Records ---
73
+
74
+ # Get personal records.
75
+ # @param display_name [String] defaults to the logged-in user
76
+ def personal_records(display_name = self.display_name)
77
+ connection.get("/personalrecord-service/personalrecord/prs/#{display_name}")
78
+ end
79
+
80
+ # --- Goals ---
81
+
82
+ # Get goals by status.
83
+ # @param status [String] "active", "future", or "past"
84
+ # @param start [Integer] pagination offset
85
+ # @param limit [Integer]
86
+ def goals(status: "active", start: 0, limit: 20)
87
+ connection.get(
88
+ "/goal-service/goal/goals",
89
+ params: { "status" => status, "start" => start, "limit" => limit, "sortOrder" => "asc" }
90
+ )
91
+ end
92
+ end
93
+ end
94
+ end