teems 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/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +136 -0
- data/bin/teems +7 -0
- data/lib/teems/api/calendar.rb +94 -0
- data/lib/teems/api/channels.rb +26 -0
- data/lib/teems/api/chats.rb +29 -0
- data/lib/teems/api/client.rb +40 -0
- data/lib/teems/api/files.rb +12 -0
- data/lib/teems/api/messages.rb +58 -0
- data/lib/teems/api/users.rb +88 -0
- data/lib/teems/api/users_mailbox.rb +16 -0
- data/lib/teems/api/users_presence.rb +43 -0
- data/lib/teems/cli.rb +133 -0
- data/lib/teems/commands/activity.rb +222 -0
- data/lib/teems/commands/auth.rb +268 -0
- data/lib/teems/commands/base.rb +146 -0
- data/lib/teems/commands/cal.rb +891 -0
- data/lib/teems/commands/channels.rb +115 -0
- data/lib/teems/commands/chats.rb +159 -0
- data/lib/teems/commands/help.rb +107 -0
- data/lib/teems/commands/messages.rb +281 -0
- data/lib/teems/commands/ooo.rb +385 -0
- data/lib/teems/commands/org.rb +232 -0
- data/lib/teems/commands/status.rb +224 -0
- data/lib/teems/commands/sync.rb +390 -0
- data/lib/teems/commands/who.rb +377 -0
- data/lib/teems/formatters/calendar_formatter.rb +227 -0
- data/lib/teems/formatters/format_utils.rb +56 -0
- data/lib/teems/formatters/markdown_formatter.rb +113 -0
- data/lib/teems/formatters/message_formatter.rb +67 -0
- data/lib/teems/formatters/output.rb +105 -0
- data/lib/teems/models/account.rb +59 -0
- data/lib/teems/models/channel.rb +31 -0
- data/lib/teems/models/chat.rb +111 -0
- data/lib/teems/models/duration.rb +46 -0
- data/lib/teems/models/event.rb +124 -0
- data/lib/teems/models/message.rb +125 -0
- data/lib/teems/models/parsing.rb +56 -0
- data/lib/teems/models/user.rb +25 -0
- data/lib/teems/models/user_profile.rb +45 -0
- data/lib/teems/runner.rb +81 -0
- data/lib/teems/services/api_client.rb +217 -0
- data/lib/teems/services/cache_store.rb +32 -0
- data/lib/teems/services/configuration.rb +56 -0
- data/lib/teems/services/file_downloader.rb +39 -0
- data/lib/teems/services/headless_extract.rb +192 -0
- data/lib/teems/services/safari_oauth.rb +285 -0
- data/lib/teems/services/sync_dir_naming.rb +42 -0
- data/lib/teems/services/sync_engine.rb +194 -0
- data/lib/teems/services/sync_store.rb +193 -0
- data/lib/teems/services/teams_url_parser.rb +78 -0
- data/lib/teems/services/token_exchange_scripts.rb +56 -0
- data/lib/teems/services/token_extractor.rb +401 -0
- data/lib/teems/services/token_extractor_scripts.rb +116 -0
- data/lib/teems/services/token_refresher.rb +169 -0
- data/lib/teems/services/token_store.rb +116 -0
- data/lib/teems/support/error_logger.rb +35 -0
- data/lib/teems/support/help_formatter.rb +80 -0
- data/lib/teems/support/timezone.rb +44 -0
- data/lib/teems/support/xdg_paths.rb +62 -0
- data/lib/teems/version.rb +5 -0
- data/lib/teems.rb +117 -0
- data/support/token_helper.swift +485 -0
- metadata +110 -0
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Commands
|
|
5
|
+
CAL_HELP = <<~HELP
|
|
6
|
+
teems cal - List calendar events and view details
|
|
7
|
+
|
|
8
|
+
USAGE:
|
|
9
|
+
teems cal [options] List today's events (interactive when TTY)
|
|
10
|
+
teems cal today List today's events (alias)
|
|
11
|
+
teems cal tomorrow List tomorrow's events
|
|
12
|
+
teems cal show <N|hash> Show details for event by # or hash
|
|
13
|
+
teems cal accept <N|hash> Accept event by # or hash
|
|
14
|
+
teems cal decline <N|hash> Decline event by # or hash
|
|
15
|
+
teems cal tentative <N|hash> Tentatively accept event by # or hash
|
|
16
|
+
teems cal create "Title" [opts] Create a new event
|
|
17
|
+
teems cal delete <N|hash> Delete event by # or hash
|
|
18
|
+
|
|
19
|
+
OPTIONS:
|
|
20
|
+
--days N Show events for the next N days (default: 1)
|
|
21
|
+
--week Show events for the current week (Mon-Fri)
|
|
22
|
+
--date YYYY-MM-DD Show events for a specific date
|
|
23
|
+
--no-interactive Disable interactive mode (list and exit)
|
|
24
|
+
--comment TEXT Add a comment to RSVP response
|
|
25
|
+
--no-send Don't send response to organizer
|
|
26
|
+
-n, --limit N Maximum number of events to show
|
|
27
|
+
-v, --verbose Show attendee summaries
|
|
28
|
+
-q, --quiet Suppress output
|
|
29
|
+
--json Output as JSON
|
|
30
|
+
-h, --help Show this help
|
|
31
|
+
|
|
32
|
+
CREATE OPTIONS:
|
|
33
|
+
--start TIME Start time: "YYYY-MM-DD HH:MM", "today HH:MM",
|
|
34
|
+
"tomorrow HH:MM", or "HH:MM" (assumes today)
|
|
35
|
+
--end TIME End time (default: start + 30 minutes)
|
|
36
|
+
--duration MIN Duration in minutes (alternative to --end)
|
|
37
|
+
--all-day Create an all-day event (use with --date)
|
|
38
|
+
--location TEXT Event location
|
|
39
|
+
--body TEXT Event description (plain text)
|
|
40
|
+
--html TEXT Event description (HTML)
|
|
41
|
+
--attendees EMAILS Comma-separated required attendee emails
|
|
42
|
+
--optional EMAILS Comma-separated optional attendee emails
|
|
43
|
+
--room EMAILS Comma-separated room/resource emails
|
|
44
|
+
--teams Add a Teams online meeting link
|
|
45
|
+
--no-teams Explicitly disable Teams meeting
|
|
46
|
+
--show-as STATUS free, tentative, busy, oof, workingElsewhere
|
|
47
|
+
--importance LEVEL low, normal, high
|
|
48
|
+
--sensitivity LEVEL normal, personal, private, confidential
|
|
49
|
+
--reminder MIN Reminder minutes before start
|
|
50
|
+
--no-reminder Disable reminder
|
|
51
|
+
--no-rsvp Don't request responses from attendees
|
|
52
|
+
--no-time-proposals Don't allow new time proposals
|
|
53
|
+
--hide-attendees Hide attendee list from other attendees
|
|
54
|
+
|
|
55
|
+
EXAMPLES:
|
|
56
|
+
teems cal # Interactive agenda (TTY)
|
|
57
|
+
teems cal --no-interactive # List and exit
|
|
58
|
+
teems cal today # Same as above
|
|
59
|
+
teems cal tomorrow # Tomorrow's events
|
|
60
|
+
teems cal --days 3 # Next 3 days
|
|
61
|
+
teems cal --week # This work week
|
|
62
|
+
teems cal --date 2026-01-20 # Specific date
|
|
63
|
+
teems cal show 3 # Details for event #3
|
|
64
|
+
teems cal show a3f2b1 # Details by short hash
|
|
65
|
+
teems cal accept 3 # Accept event #3
|
|
66
|
+
teems cal accept a3f2 # Accept by hash prefix
|
|
67
|
+
teems cal decline 3 --comment "Out of office"
|
|
68
|
+
teems cal create "Standup" --start "tomorrow 09:00" --duration 15
|
|
69
|
+
teems cal create "Review" --start "2026-03-20 14:00" --teams \
|
|
70
|
+
--attendees alice@example.com,bob@example.com
|
|
71
|
+
teems cal delete 3 # Delete event #3
|
|
72
|
+
teems cal --json | jq ... # JSON output, no prompt
|
|
73
|
+
HELP
|
|
74
|
+
|
|
75
|
+
# Date range computation for calendar queries
|
|
76
|
+
module CalDateRange
|
|
77
|
+
# Map common timezone abbreviations to IANA names
|
|
78
|
+
TIMEZONE_MAP = {
|
|
79
|
+
'EST' => 'America/New_York', 'EDT' => 'America/New_York',
|
|
80
|
+
'CST' => 'America/Chicago', 'CDT' => 'America/Chicago',
|
|
81
|
+
'MST' => 'America/Denver', 'MDT' => 'America/Denver',
|
|
82
|
+
'PST' => 'America/Los_Angeles', 'PDT' => 'America/Los_Angeles',
|
|
83
|
+
'AKST' => 'America/Anchorage', 'AKDT' => 'America/Anchorage',
|
|
84
|
+
'HST' => 'Pacific/Honolulu', 'UTC' => 'UTC', 'GMT' => 'UTC'
|
|
85
|
+
}.freeze
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def detect_timezone
|
|
90
|
+
tz_from_env = resolve_tz_env
|
|
91
|
+
tz_from_env || timezone_from_system
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def resolve_tz_env
|
|
95
|
+
tz_env = ENV.fetch('TZ', '')
|
|
96
|
+
return if tz_env.empty?
|
|
97
|
+
|
|
98
|
+
TIMEZONE_MAP.fetch(tz_env) { tz_env }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def timezone_from_system
|
|
102
|
+
zone_abbrev = Time.now.strftime('%Z')
|
|
103
|
+
TIMEZONE_MAP[zone_abbrev] || 'UTC'
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def compute_date_range
|
|
107
|
+
start_dt, end_dt = date_range_boundaries
|
|
108
|
+
[format_datetime(start_dt), format_datetime(end_dt)]
|
|
109
|
+
rescue Date::Error
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def date_range_boundaries
|
|
114
|
+
if @options[:date]
|
|
115
|
+
date_range_for_date
|
|
116
|
+
elsif @options[:week]
|
|
117
|
+
date_range_for_week
|
|
118
|
+
else
|
|
119
|
+
date_range_for_days
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def date_range_for_date
|
|
124
|
+
date = Date.parse(@options[:date])
|
|
125
|
+
[day_start(date), day_end(date)]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def date_range_for_week
|
|
129
|
+
monday = week_monday
|
|
130
|
+
[day_start(monday), day_end(monday + 4)]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def week_monday
|
|
134
|
+
@week_monday ||= compute_week_monday(@options[:days] || 5)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def compute_week_monday(_week_length)
|
|
138
|
+
today = Date.today
|
|
139
|
+
offset = today.wday
|
|
140
|
+
today - (offset.zero? ? 6 : offset - 1)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def date_range_for_days
|
|
144
|
+
today = Date.today
|
|
145
|
+
end_date = today + (@options[:days] || 1) - 1
|
|
146
|
+
[day_start(today), day_end(end_date)]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def day_start(date)
|
|
150
|
+
Time.new(date.year, date.month, date.day, 0, 0, 0)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def day_end(date)
|
|
154
|
+
Time.new(date.year, date.month, date.day, 23, 59, 59)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def format_datetime(time) = time.strftime('%Y-%m-%dT%H:%M:%S%:z')
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Subcommand parsing for cal command
|
|
161
|
+
module CalSubcommandParser
|
|
162
|
+
RSVP_ACTIONS = %w[accept decline tentative].freeze
|
|
163
|
+
|
|
164
|
+
SUBCOMMAND_PARSERS = {
|
|
165
|
+
'show' => :parse_show_subcommand, 'today' => :parse_today_subcommand,
|
|
166
|
+
'tomorrow' => :parse_tomorrow_subcommand, 'create' => :parse_create_subcommand,
|
|
167
|
+
'delete' => :parse_delete_subcommand
|
|
168
|
+
}.freeze
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def parse_options(args)
|
|
173
|
+
remaining = super
|
|
174
|
+
parse_subcommand(remaining)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def parse_subcommand(remaining)
|
|
178
|
+
dispatch_subcommand_parse(remaining)
|
|
179
|
+
remaining
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def dispatch_subcommand_parse(remaining)
|
|
183
|
+
subcommand = remaining.first
|
|
184
|
+
parser = SUBCOMMAND_PARSERS[subcommand]
|
|
185
|
+
if parser then send(parser, remaining)
|
|
186
|
+
elsif RSVP_ACTIONS.include?(subcommand) then parse_rsvp_subcommand(remaining)
|
|
187
|
+
else @subcommand = 'list'
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def parse_show_subcommand(remaining)
|
|
192
|
+
@subcommand = 'show'
|
|
193
|
+
_subcommand, event_arg = remaining.shift(2)
|
|
194
|
+
@event_ref = event_arg
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def parse_today_subcommand(remaining)
|
|
198
|
+
@subcommand = 'list'
|
|
199
|
+
remaining.shift
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def parse_tomorrow_subcommand(remaining)
|
|
203
|
+
@subcommand = 'list'
|
|
204
|
+
remaining.shift
|
|
205
|
+
@options[:date] = (Date.today + 1).to_s
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def parse_rsvp_subcommand(remaining)
|
|
209
|
+
action, event_arg = remaining.shift(2)
|
|
210
|
+
@subcommand = action
|
|
211
|
+
@event_ref = event_arg
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def parse_create_subcommand(remaining)
|
|
215
|
+
@subcommand = 'create'
|
|
216
|
+
_subcommand, subject = remaining.shift(2)
|
|
217
|
+
@create_subject = subject
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def parse_delete_subcommand(remaining)
|
|
221
|
+
@subcommand = 'delete'
|
|
222
|
+
_subcommand, event_arg = remaining.shift(2)
|
|
223
|
+
@event_ref = event_arg
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Event resolution by number or short hash
|
|
228
|
+
module CalEventResolver
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
def resolve_event_id
|
|
232
|
+
@resolve_events = fetch_current_events
|
|
233
|
+
event = resolve_by_ref(@event_ref)
|
|
234
|
+
return event.id if event
|
|
235
|
+
|
|
236
|
+
error("Event '#{@event_ref}' not found")
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def resolve_by_ref(ref)
|
|
241
|
+
ref.match?(/\A\d+\z/) ? event_by_number(ref) : event_by_hash_prefix(ref)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def event_by_number(ref)
|
|
245
|
+
index = ref.to_i - 1
|
|
246
|
+
index >= 0 ? @resolve_events[index] : nil
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def event_by_hash_prefix(ref)
|
|
250
|
+
@hash_matches = @resolve_events.select { |evt| evt.short_hash.start_with?(ref.downcase) }
|
|
251
|
+
@hash_matches.first if @hash_matches.one?
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def fetch_current_events
|
|
255
|
+
range = compute_date_range
|
|
256
|
+
return [] unless range
|
|
257
|
+
|
|
258
|
+
fetch_events(range)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Event display, RSVP, and detail subcommands
|
|
263
|
+
module CalEventActions
|
|
264
|
+
RSVP_ACTION_LABELS = {
|
|
265
|
+
'accept' => 'accepted', 'decline' => 'declined', 'tentative' => 'tentatively accepted'
|
|
266
|
+
}.freeze
|
|
267
|
+
|
|
268
|
+
private
|
|
269
|
+
|
|
270
|
+
def show_event
|
|
271
|
+
event_id = validated_event_id
|
|
272
|
+
return event_id if event_id.is_a?(Integer)
|
|
273
|
+
|
|
274
|
+
render_single_event(event_id)
|
|
275
|
+
rescue ApiError => e
|
|
276
|
+
api_error_result('Failed to fetch event', e)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def validated_event_id
|
|
280
|
+
return missing_event_ref unless @event_ref && !@event_ref.empty?
|
|
281
|
+
|
|
282
|
+
resolve_event_id || 1
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def api_error_result(prefix, err)
|
|
286
|
+
error("#{prefix}: #{err.message}")
|
|
287
|
+
1
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def render_single_event(event_id)
|
|
291
|
+
event = fetch_event_for_display(event_id)
|
|
292
|
+
render_event_output(event)
|
|
293
|
+
0
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def render_event_output(event)
|
|
297
|
+
if @options[:json]
|
|
298
|
+
output_json(event_to_hash(event))
|
|
299
|
+
else
|
|
300
|
+
formatter = Formatters::CalendarFormatter.new(output: output)
|
|
301
|
+
puts formatter.format_event_detail(event)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def rsvp_event
|
|
306
|
+
event_id = validated_event_id
|
|
307
|
+
return event_id if event_id.is_a?(Integer)
|
|
308
|
+
|
|
309
|
+
send_rsvp(event_id)
|
|
310
|
+
rescue ApiError => e
|
|
311
|
+
api_error_result('Failed to respond to event', e)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def send_rsvp(event_id)
|
|
315
|
+
with_token_refresh do
|
|
316
|
+
runner.calendar_api.rsvp_event(
|
|
317
|
+
event_id: event_id, action: @subcommand,
|
|
318
|
+
comment: @options[:comment],
|
|
319
|
+
notify: @options[:no_send] ? :silent : :send
|
|
320
|
+
)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
success("Event #{@event_ref} #{RSVP_ACTION_LABELS[@subcommand]}")
|
|
324
|
+
0
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def missing_event_ref
|
|
328
|
+
action = @subcommand == 'show' ? 'show' : @subcommand
|
|
329
|
+
error("Event reference required. Usage: teems cal #{action} <N|hash>")
|
|
330
|
+
1
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def delete_event
|
|
334
|
+
event_id = validated_event_id
|
|
335
|
+
return event_id if event_id.is_a?(Integer)
|
|
336
|
+
|
|
337
|
+
send_delete(event_id)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def send_delete(event_id)
|
|
341
|
+
event = fetch_and_delete(event_id)
|
|
342
|
+
display_delete_result(event)
|
|
343
|
+
0
|
|
344
|
+
rescue ApiError => e
|
|
345
|
+
api_error_result('Failed to delete event', e)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def fetch_and_delete(event_id)
|
|
349
|
+
event = fetch_event_for_display(event_id)
|
|
350
|
+
with_token_refresh { runner.calendar_api.delete_event(event_id: event_id) }
|
|
351
|
+
event
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def fetch_event_for_display(event_id)
|
|
355
|
+
with_token_refresh { runner.calendar_api.get_event(event_id: event_id, timezone: detect_timezone) }
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def display_delete_result(event)
|
|
359
|
+
success("Deleted: \"#{event.subject}\"")
|
|
360
|
+
event.create_summary_lines.each { |line| puts " #{line}" }
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def event_to_hash(event)
|
|
364
|
+
event.to_h.merge(start_time: event.start_time&.iso8601, end_time: event.end_time&.iso8601)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Interactive event selection and action loop for TTY mode
|
|
369
|
+
module CalInteractiveMode
|
|
370
|
+
RSVP_KEYS = { 'a' => 'accept', 'd' => 'decline', 't' => 'tentative' }.freeze
|
|
371
|
+
|
|
372
|
+
private
|
|
373
|
+
|
|
374
|
+
def interactive?
|
|
375
|
+
!@options[:json] && !@options[:no_interactive] && !@options[:quiet] && output.tty?
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def interactive_event_loop(events)
|
|
379
|
+
setup_interactive_state(events)
|
|
380
|
+
run_selection_loop
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def setup_interactive_state(events)
|
|
384
|
+
@interactive_events = events
|
|
385
|
+
@resolve_events = events
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def run_selection_loop
|
|
389
|
+
loop do
|
|
390
|
+
input = read_selection_input
|
|
391
|
+
return unless input
|
|
392
|
+
|
|
393
|
+
handle_selection(input)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def read_selection_input
|
|
398
|
+
output.print "\nEnter # or hash for details (1-#{@interactive_events.length}) or q to quit: "
|
|
399
|
+
output.flush
|
|
400
|
+
@last_input = $stdin.gets&.strip.to_s
|
|
401
|
+
@last_input unless @last_input.empty? || @last_input == 'q'
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def handle_selection(input)
|
|
405
|
+
event = resolve_by_ref(input)
|
|
406
|
+
event ? show_detail_and_act(event) : output.puts("Invalid selection: #{input}")
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def show_detail_and_act(event)
|
|
410
|
+
render_event_detail_text(event)
|
|
411
|
+
action_loop(event)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def render_event_detail_text(event)
|
|
415
|
+
formatter = Formatters::CalendarFormatter.new(output: output)
|
|
416
|
+
output.puts ''
|
|
417
|
+
output.puts formatter.format_event_detail(event)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def action_loop(event)
|
|
421
|
+
loop do
|
|
422
|
+
input = read_action_input
|
|
423
|
+
result = dispatch_action(input, event)
|
|
424
|
+
return result unless result == :continue
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def read_action_input
|
|
429
|
+
output.print "\n[a]ccept [d]ecline [t]entative [D]elete [b]ack [q]uit: "
|
|
430
|
+
output.flush
|
|
431
|
+
$stdin.gets&.strip
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def dispatch_action(input, event)
|
|
435
|
+
return send_interactive_rsvp(RSVP_KEYS[input], event) if RSVP_KEYS.key?(input)
|
|
436
|
+
|
|
437
|
+
dispatch_non_rsvp_action(input, event)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def dispatch_non_rsvp_action(input, event)
|
|
441
|
+
case input.to_s
|
|
442
|
+
when 'D' then send_interactive_delete(event)
|
|
443
|
+
when 'b' then redisplay_list
|
|
444
|
+
when 'q', '' then throw(:interactive_quit)
|
|
445
|
+
else output.puts("Unknown action: #{input}") || :continue
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def send_interactive_rsvp(action, event)
|
|
450
|
+
with_token_refresh { runner.calendar_api.rsvp_event(event_id: event.id, action: action, notify: :send) }
|
|
451
|
+
display_rsvp_result(action, event)
|
|
452
|
+
rescue ApiError => e
|
|
453
|
+
error("Failed to respond: #{e.message}") && :continue
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def display_rsvp_result(action, event)
|
|
457
|
+
success("Event #{CalEventActions::RSVP_ACTION_LABELS[action]}: \"#{event.subject}\"")
|
|
458
|
+
:continue
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def send_interactive_delete(event)
|
|
462
|
+
perform_delete(event)
|
|
463
|
+
throw(:interactive_quit)
|
|
464
|
+
rescue ApiError => e
|
|
465
|
+
error("Failed to delete: #{e.message}") && :continue
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def perform_delete(event)
|
|
469
|
+
with_token_refresh { runner.calendar_api.delete_event(event_id: event.id) }
|
|
470
|
+
success("Deleted: \"#{event.subject}\"")
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def redisplay_list
|
|
474
|
+
output.puts ''
|
|
475
|
+
render_events_text(@interactive_events)
|
|
476
|
+
nil
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Time parsing for create subcommand
|
|
481
|
+
module CalTimeParsing
|
|
482
|
+
private
|
|
483
|
+
|
|
484
|
+
def parse_time_input(raw)
|
|
485
|
+
date, time_str = split_time_input(raw)
|
|
486
|
+
return unless time_str
|
|
487
|
+
|
|
488
|
+
date.is_a?(String) ? parse_absolute_time(date, time_str) : parse_relative_time(date, time_str)
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def split_time_input(raw)
|
|
492
|
+
base = Date.today
|
|
493
|
+
split_tomorrow_time(raw, base) || split_today_time(raw, base) || split_absolute_time(raw)
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def split_tomorrow_time(raw, base)
|
|
497
|
+
return unless raw.start_with?('tomorrow ')
|
|
498
|
+
|
|
499
|
+
[base + 1, raw.delete_prefix('tomorrow ')]
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def split_today_time(raw, base)
|
|
503
|
+
return unless raw.match?(/\A(?:today\s+)?\d{1,2}:\d{2}\z/)
|
|
504
|
+
|
|
505
|
+
[base, raw.delete_prefix('today ')]
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def split_absolute_time(raw)
|
|
509
|
+
return unless raw.match?(/\A\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\z/)
|
|
510
|
+
|
|
511
|
+
raw.split(/\s+/, 2)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def parse_relative_time(date, time_str)
|
|
515
|
+
hour, min = time_str.split(':').map(&:to_i)
|
|
516
|
+
debug("Parsing time #{hour}:#{min} for #{@options[:date]}") if @options[:verbose]
|
|
517
|
+
Time.new(date.year, date.month, date.day, hour, min, 0)
|
|
518
|
+
rescue ArgumentError
|
|
519
|
+
nil
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def parse_absolute_time(date_str, time_str)
|
|
523
|
+
date = Date.parse(date_str)
|
|
524
|
+
parse_relative_time(date, time_str)
|
|
525
|
+
rescue Date::Error
|
|
526
|
+
nil
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Builds the event body hash fields for create requests
|
|
531
|
+
module CalEventFields
|
|
532
|
+
private
|
|
533
|
+
|
|
534
|
+
def add_optional_event_fields(body)
|
|
535
|
+
add_location_field(body)
|
|
536
|
+
add_body_field(body)
|
|
537
|
+
add_all_attendees(body)
|
|
538
|
+
add_meeting_flags(body)
|
|
539
|
+
add_display_fields(body)
|
|
540
|
+
body
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def add_location_field(body)
|
|
544
|
+
location = @options[:location]
|
|
545
|
+
body[:location] = { displayName: location } if location
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def add_body_field(body)
|
|
549
|
+
html = @options[:html]
|
|
550
|
+
text = @options[:body]
|
|
551
|
+
body[:body] = { contentType: 'html', content: html } if html
|
|
552
|
+
body[:body] = { contentType: 'text', content: text } if text && !html
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def add_all_attendees(body)
|
|
556
|
+
list = collect_attendees
|
|
557
|
+
body[:attendees] = list unless list.empty?
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def collect_attendees
|
|
561
|
+
entries = []
|
|
562
|
+
entries.concat(typed_attendees(@options[:attendees], 'required'))
|
|
563
|
+
entries.concat(typed_attendees(@options[:optional], 'optional'))
|
|
564
|
+
entries.concat(typed_attendees(@options[:rooms], 'resource'))
|
|
565
|
+
entries
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def typed_attendees(emails, type)
|
|
569
|
+
return [] unless emails
|
|
570
|
+
|
|
571
|
+
emails.map { |email| { emailAddress: { address: email }, type: type } }
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def add_meeting_flags(body)
|
|
575
|
+
apply_teams_setting(body)
|
|
576
|
+
body[:responseRequested] = false if @options[:no_rsvp]
|
|
577
|
+
body[:allowNewTimeProposals] = false if @options[:no_time_proposals]
|
|
578
|
+
body[:hideAttendees] = true if @options[:hide_attendees]
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def apply_teams_setting(body)
|
|
582
|
+
teams = @options[:teams]
|
|
583
|
+
return unless teams || @options[:no_teams]
|
|
584
|
+
|
|
585
|
+
body[:isOnlineMeeting] = teams || false
|
|
586
|
+
body[:onlineMeetingProvider] = 'teamsForBusiness' if teams
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def add_display_fields(body)
|
|
590
|
+
{ showAs: @options[:show_as], importance: @options[:importance],
|
|
591
|
+
sensitivity: @options[:sensitivity] }.each { |key, val| body[key] = val if val }
|
|
592
|
+
apply_reminder(body)
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def apply_reminder(body)
|
|
596
|
+
reminder = @options[:reminder]
|
|
597
|
+
return body.merge!(isReminderOn: false) if @options[:no_reminder]
|
|
598
|
+
|
|
599
|
+
body.merge!(isReminderOn: true, reminderMinutesBeforeStart: reminder) if reminder
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
# Validates create option values before building the API request
|
|
604
|
+
module CalCreateValidation
|
|
605
|
+
private
|
|
606
|
+
|
|
607
|
+
def validate_create_options
|
|
608
|
+
validate_conflicting_flags ||
|
|
609
|
+
validate_enum(:show_as, CalOptionDefs::SHOW_AS_VALUES) ||
|
|
610
|
+
validate_enum(:importance, CalOptionDefs::IMPORTANCE_VALUES) ||
|
|
611
|
+
validate_enum(:sensitivity, CalOptionDefs::SENSITIVITY_VALUES) ||
|
|
612
|
+
validate_positive_reminder
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def validate_conflicting_flags
|
|
616
|
+
conflict_error('--teams', '--no-teams') if @options[:teams] && @options[:no_teams]
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def conflict_error(flag_a, flag_b)
|
|
620
|
+
error("Cannot use #{flag_a} and #{flag_b} together")
|
|
621
|
+
1
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def validate_enum(option, valid_values)
|
|
625
|
+
value = @options[option]
|
|
626
|
+
return unless value && !valid_values.include?(value)
|
|
627
|
+
|
|
628
|
+
error("Invalid --#{option.to_s.tr('_', '-')} value: #{value}. Valid: #{valid_values.join(', ')}")
|
|
629
|
+
1
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def validate_positive_reminder
|
|
633
|
+
reminder = @options[:reminder]
|
|
634
|
+
return unless reminder && !reminder.positive?
|
|
635
|
+
|
|
636
|
+
error('Reminder must be a positive number of minutes')
|
|
637
|
+
1
|
|
638
|
+
end
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# Event creation subcommand
|
|
642
|
+
module CalCreateActions
|
|
643
|
+
include CalTimeParsing
|
|
644
|
+
include CalEventFields
|
|
645
|
+
include CalCreateValidation
|
|
646
|
+
|
|
647
|
+
private
|
|
648
|
+
|
|
649
|
+
def create_event
|
|
650
|
+
return missing_subject_error unless @create_subject
|
|
651
|
+
|
|
652
|
+
validated_create_event
|
|
653
|
+
rescue ApiError => e
|
|
654
|
+
api_error_result('Failed to create event', e)
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def validated_create_event
|
|
658
|
+
validation = validate_create_options
|
|
659
|
+
return validation if validation
|
|
660
|
+
|
|
661
|
+
times = resolve_create_times
|
|
662
|
+
return times if times.is_a?(Integer)
|
|
663
|
+
|
|
664
|
+
send_create_request(*times)
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
def missing_subject_error
|
|
668
|
+
error('Event title required. Usage: teems cal create "Title" --start TIME')
|
|
669
|
+
1
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def resolve_create_times
|
|
673
|
+
@options[:all_day] ? resolve_all_day_times : resolve_timed_event_times
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def resolve_all_day_times
|
|
677
|
+
date = @options[:date] || Date.today.to_s
|
|
678
|
+
parsed = Date.parse(date)
|
|
679
|
+
[parsed.strftime('%Y-%m-%dT00:00:00'), (parsed + 1).strftime('%Y-%m-%dT00:00:00')]
|
|
680
|
+
rescue Date::Error
|
|
681
|
+
error("Invalid date: #{date}") || 1
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
def resolve_timed_event_times
|
|
685
|
+
start_time = validated_start_time
|
|
686
|
+
return start_time if start_time.is_a?(Integer)
|
|
687
|
+
|
|
688
|
+
end_time = compute_end_time(start_time)
|
|
689
|
+
return end_time if end_time.is_a?(Integer)
|
|
690
|
+
|
|
691
|
+
[format_time(start_time), format_time(end_time)]
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def validated_start_time
|
|
695
|
+
start_input = @options[:start]
|
|
696
|
+
return missing_start_time_error unless start_input
|
|
697
|
+
|
|
698
|
+
parse_time_input(start_input) || (error("Invalid start time: #{start_input}") || 1)
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def missing_start_time_error
|
|
702
|
+
error('Start time required. Use --start "YYYY-MM-DD HH:MM" or --start "today HH:MM"')
|
|
703
|
+
1
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def compute_end_time(start_time)
|
|
707
|
+
@options[:end] ? parse_explicit_end_time : compute_end_from_duration(start_time)
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def parse_explicit_end_time
|
|
711
|
+
end_input = @options[:end]
|
|
712
|
+
parse_time_input(end_input) || (error("Invalid end time: #{end_input}") || 1)
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def compute_end_from_duration(start_time)
|
|
716
|
+
duration = @options[:duration] || 30
|
|
717
|
+
return error('Duration must be a positive number of minutes') || 1 unless duration.positive?
|
|
718
|
+
|
|
719
|
+
start_time + (duration * 60)
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
def format_time(time) = time.strftime('%Y-%m-%dT%H:%M:%S')
|
|
723
|
+
|
|
724
|
+
def send_create_request(start_dt, end_dt)
|
|
725
|
+
event = with_token_refresh do
|
|
726
|
+
runner.calendar_api.create_event(build_create_event(start_dt, end_dt))
|
|
727
|
+
end
|
|
728
|
+
display_create_result(event)
|
|
729
|
+
0
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def build_create_event(start_dt, end_dt)
|
|
733
|
+
tz = detect_timezone
|
|
734
|
+
body = { subject: @create_subject,
|
|
735
|
+
start: { dateTime: start_dt, timeZone: tz },
|
|
736
|
+
end: { dateTime: end_dt, timeZone: tz },
|
|
737
|
+
isAllDay: @options[:all_day] || false,
|
|
738
|
+
transactionId: SecureRandom.uuid }
|
|
739
|
+
add_optional_event_fields(body)
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
def display_create_result(event)
|
|
743
|
+
success("Created: \"#{event.subject}\"")
|
|
744
|
+
warn_auto_teams(event)
|
|
745
|
+
event.create_summary_lines.each { |line| puts " #{line}" }
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def warn_auto_teams(event)
|
|
749
|
+
return unless @options[:no_teams] && event.online_meeting_url
|
|
750
|
+
|
|
751
|
+
output.warn('Note: Teams meeting was auto-added by your org settings.')
|
|
752
|
+
output.warn('Disable in Outlook: Settings > Calendar > Events > "Add online meeting to all meetings"')
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# Option definitions and validation for the cal command
|
|
757
|
+
module CalOptionDefs
|
|
758
|
+
SHOW_AS_VALUES = %w[free tentative busy oof workingElsewhere].freeze
|
|
759
|
+
IMPORTANCE_VALUES = %w[low normal high].freeze
|
|
760
|
+
SENSITIVITY_VALUES = %w[normal personal private confidential].freeze
|
|
761
|
+
SPLIT_EMAILS = ->(raw) { raw ? raw.split(',').map(&:strip).reject(&:empty?) : [] }
|
|
762
|
+
ALL = {
|
|
763
|
+
'--days' => ->(opts, args) { opts[:days] = args.shift.to_i },
|
|
764
|
+
'--week' => ->(opts, _args) { opts[:week] = true },
|
|
765
|
+
'--date' => ->(opts, args) { opts[:date] = args.shift },
|
|
766
|
+
'--no-interactive' => ->(opts, _args) { opts[:no_interactive] = true },
|
|
767
|
+
'--comment' => ->(opts, args) { opts[:comment] = args.shift },
|
|
768
|
+
'--no-send' => ->(opts, _args) { opts[:no_send] = true },
|
|
769
|
+
'--start' => ->(opts, args) { opts[:start] = args.shift },
|
|
770
|
+
'--end' => ->(opts, args) { opts[:end] = args.shift },
|
|
771
|
+
'--duration' => ->(opts, args) { opts[:duration] = args.shift.to_i },
|
|
772
|
+
'--all-day' => ->(opts, _args) { opts[:all_day] = true },
|
|
773
|
+
'--location' => ->(opts, args) { opts[:location] = args.shift },
|
|
774
|
+
'--body' => ->(opts, args) { opts[:body] = args.shift },
|
|
775
|
+
'--html' => ->(opts, args) { opts[:html] = args.shift },
|
|
776
|
+
'--attendees' => ->(opts, args) { opts[:attendees] = SPLIT_EMAILS.call(args.shift) },
|
|
777
|
+
'--optional' => ->(opts, args) { opts[:optional] = SPLIT_EMAILS.call(args.shift) },
|
|
778
|
+
'--room' => ->(opts, args) { opts[:rooms] = SPLIT_EMAILS.call(args.shift) },
|
|
779
|
+
'--teams' => ->(opts, _args) { opts[:teams] = true },
|
|
780
|
+
'--no-teams' => ->(opts, _args) { opts[:no_teams] = true },
|
|
781
|
+
'--show-as' => ->(opts, args) { opts[:show_as] = args.shift },
|
|
782
|
+
'--importance' => ->(opts, args) { opts[:importance] = args.shift },
|
|
783
|
+
'--sensitivity' => ->(opts, args) { opts[:sensitivity] = args.shift },
|
|
784
|
+
'--reminder' => ->(opts, args) { opts[:reminder] = args.shift.to_i },
|
|
785
|
+
'--no-reminder' => ->(opts, _args) { opts[:no_reminder] = true },
|
|
786
|
+
'--no-rsvp' => ->(opts, _args) { opts[:no_rsvp] = true },
|
|
787
|
+
'--no-time-proposals' => ->(opts, _args) { opts[:no_time_proposals] = true },
|
|
788
|
+
'--hide-attendees' => ->(opts, _args) { opts[:hide_attendees] = true }
|
|
789
|
+
}.freeze
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
# List calendar events and view event details
|
|
793
|
+
class Cal < Base
|
|
794
|
+
include CalDateRange
|
|
795
|
+
include CalSubcommandParser
|
|
796
|
+
include CalEventResolver
|
|
797
|
+
include CalEventActions
|
|
798
|
+
include CalCreateActions
|
|
799
|
+
include CalInteractiveMode
|
|
800
|
+
|
|
801
|
+
def initialize(args, runner:)
|
|
802
|
+
@options = {}
|
|
803
|
+
@subcommand = nil
|
|
804
|
+
@event_ref = nil
|
|
805
|
+
@create_subject = nil
|
|
806
|
+
super
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
def execute
|
|
810
|
+
result = validate_options
|
|
811
|
+
return result if result
|
|
812
|
+
|
|
813
|
+
auth_result = require_auth
|
|
814
|
+
return auth_result if auth_result
|
|
815
|
+
|
|
816
|
+
dispatch_subcommand
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
protected
|
|
820
|
+
|
|
821
|
+
CAL_OPTIONS = CalOptionDefs::ALL
|
|
822
|
+
|
|
823
|
+
def handle_option(arg, pending)
|
|
824
|
+
handler = CAL_OPTIONS[arg]
|
|
825
|
+
return super unless handler
|
|
826
|
+
|
|
827
|
+
handler.call(@options, pending)
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
def help_text = CAL_HELP
|
|
831
|
+
|
|
832
|
+
private
|
|
833
|
+
|
|
834
|
+
def dispatch_subcommand
|
|
835
|
+
case @subcommand
|
|
836
|
+
when 'show' then show_event
|
|
837
|
+
when 'create' then create_event
|
|
838
|
+
when 'delete' then delete_event
|
|
839
|
+
when 'accept', 'decline', 'tentative' then rsvp_event
|
|
840
|
+
else list_events
|
|
841
|
+
end
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
def list_events
|
|
845
|
+
range = compute_date_range
|
|
846
|
+
return error("Invalid date: #{@options[:date]}") && 1 unless range
|
|
847
|
+
|
|
848
|
+
fetch_and_display_events(range)
|
|
849
|
+
rescue ApiError => e
|
|
850
|
+
api_error_result('Failed to fetch calendar', e)
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
def fetch_and_display_events(range)
|
|
854
|
+
events = fetch_events(range)
|
|
855
|
+
return 0 if events.empty? && (puts('No events found') || true)
|
|
856
|
+
|
|
857
|
+
render_events(events)
|
|
858
|
+
run_interactive_loop(events) if interactive?
|
|
859
|
+
0
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
def run_interactive_loop(events)
|
|
863
|
+
catch(:interactive_quit) { interactive_event_loop(events) }
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
def fetch_events(range)
|
|
867
|
+
start_dt, end_dt = range
|
|
868
|
+
with_token_refresh do
|
|
869
|
+
runner.calendar_api.list_events(
|
|
870
|
+
time_range: { start_dt: start_dt, end_dt: end_dt, timezone: detect_timezone },
|
|
871
|
+
top: @options[:limit]
|
|
872
|
+
)
|
|
873
|
+
end
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
def render_events(events)
|
|
877
|
+
if @options[:json]
|
|
878
|
+
output_json(events.map { |event| event_to_hash(event) })
|
|
879
|
+
else
|
|
880
|
+
render_events_text(events)
|
|
881
|
+
end
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
def render_events_text(events)
|
|
885
|
+
formatter = Formatters::CalendarFormatter.new(output: output)
|
|
886
|
+
method = @options[:verbose] ? :format_event_list_verbose : :format_event_list_compact
|
|
887
|
+
puts formatter.public_send(method, events)
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
end
|
|
891
|
+
end
|