zele 0.1.2 → 0.2.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.
- package/AGENTS.md +4 -0
- package/CHANGELOG.md +36 -0
- package/README.md +112 -0
- package/dist/api-utils.d.ts +6 -0
- package/dist/api-utils.js +52 -0
- package/dist/api-utils.js.map +1 -0
- package/dist/auth.d.ts +16 -0
- package/dist/auth.js +74 -5
- package/dist/auth.js.map +1 -1
- package/dist/calendar-client.d.ts +135 -0
- package/dist/calendar-client.js +498 -0
- package/dist/calendar-client.js.map +1 -0
- package/dist/calendar-time.d.ts +24 -0
- package/dist/calendar-time.js +245 -0
- package/dist/calendar-time.js.map +1 -0
- package/dist/cli.js +5 -3
- package/dist/cli.js.map +1 -1
- package/dist/commands/auth-cmd.js +5 -5
- package/dist/commands/auth-cmd.js.map +1 -1
- package/dist/commands/calendar.d.ts +2 -0
- package/dist/commands/calendar.js +563 -0
- package/dist/commands/calendar.js.map +1 -0
- package/dist/generated/browser.d.ts +10 -0
- package/dist/generated/client.d.ts +10 -0
- package/dist/generated/internal/class.d.ts +22 -0
- package/dist/generated/internal/class.js +2 -2
- package/dist/generated/internal/class.js.map +1 -1
- package/dist/generated/internal/prismaNamespace.d.ts +174 -1
- package/dist/generated/internal/prismaNamespace.js +21 -0
- package/dist/generated/internal/prismaNamespace.js.map +1 -1
- package/dist/generated/internal/prismaNamespaceBrowser.d.ts +23 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +21 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
- package/dist/generated/models/accounts.d.ts +281 -0
- package/dist/generated/models/calendar_events.d.ts +1433 -0
- package/dist/generated/models/calendar_events.js +2 -0
- package/dist/generated/models/calendar_events.js.map +1 -0
- package/dist/generated/models/calendar_lists.d.ts +1131 -0
- package/dist/generated/models/calendar_lists.js +2 -0
- package/dist/generated/models/calendar_lists.js.map +1 -0
- package/dist/generated/models.d.ts +2 -0
- package/dist/gmail-cache.d.ts +22 -0
- package/dist/gmail-cache.js +76 -0
- package/dist/gmail-cache.js.map +1 -1
- package/dist/gmail-client.js +1 -48
- package/dist/gmail-client.js.map +1 -1
- package/dist/output.d.ts +11 -0
- package/dist/output.js +42 -0
- package/dist/output.js.map +1 -1
- package/package.json +4 -2
- package/schema.prisma +39 -6
- package/scripts/test-device-code-clients.ts +186 -0
- package/scripts/test-micropython-scopes.ts +72 -0
- package/scripts/test-oauth-clients.ts +257 -0
- package/src/api-utils.ts +60 -0
- package/src/auth.ts +92 -5
- package/src/calendar-client.ts +758 -0
- package/src/calendar-time.ts +299 -0
- package/src/cli.ts +5 -3
- package/src/commands/auth-cmd.ts +5 -5
- package/src/commands/calendar.ts +634 -0
- package/src/gmail-cache.ts +96 -0
- package/src/gmail-client.ts +1 -57
- package/src/output.ts +51 -0
- package/src/schema.sql +22 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
// Calendar commands: list, events, get, create, update, delete, respond, freebusy.
|
|
2
|
+
// Manages Google Calendar with YAML output and cache integration.
|
|
3
|
+
// Multi-account: list/events fetch all accounts concurrently and merge by start time.
|
|
4
|
+
// Improved UX over gogcli: multi-account by default, +duration syntax, cleaner output.
|
|
5
|
+
|
|
6
|
+
import type { Goke } from 'goke'
|
|
7
|
+
import { z } from 'zod'
|
|
8
|
+
import readline from 'node:readline'
|
|
9
|
+
import { getCalendarClients, getCalendarClient } from '../auth.js'
|
|
10
|
+
import type { CalendarClient, CalendarEvent, CalendarListItem, EventListResult } from '../calendar-client.js'
|
|
11
|
+
import * as cache from '../gmail-cache.js'
|
|
12
|
+
import * as out from '../output.js'
|
|
13
|
+
import { resolveTimeRange, parseTimeExpression, parseDuration, isDateOnly } from '../calendar-time.js'
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Register commands
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export function registerCalendarCommands(cli: Goke) {
|
|
20
|
+
// =========================================================================
|
|
21
|
+
// cal list
|
|
22
|
+
// =========================================================================
|
|
23
|
+
|
|
24
|
+
cli
|
|
25
|
+
.command('cal list', 'List calendars')
|
|
26
|
+
.option('--no-cache', 'Skip cache')
|
|
27
|
+
.action(async (options) => {
|
|
28
|
+
const clients = await getCalendarClients(options.account)
|
|
29
|
+
|
|
30
|
+
const settled = await Promise.allSettled(
|
|
31
|
+
clients.map(async ({ email, client }) => {
|
|
32
|
+
if (!options.noCache) {
|
|
33
|
+
const cached = await cache.getCachedCalendarList<CalendarListItem[]>(email)
|
|
34
|
+
if (cached) return { email, calendars: cached }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const calendars = await client.listCalendars()
|
|
38
|
+
|
|
39
|
+
if (!options.noCache) {
|
|
40
|
+
await cache.cacheCalendarList(email, calendars)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { email, calendars }
|
|
44
|
+
}),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
const allResults = settled
|
|
48
|
+
.filter((r): r is PromiseFulfilledResult<{ email: string; calendars: CalendarListItem[] }> => {
|
|
49
|
+
if (r.status === 'rejected') {
|
|
50
|
+
const msg = String(r.reason)
|
|
51
|
+
if (msg.includes('401') || msg.includes('403') || msg.includes('Unauthorized')) {
|
|
52
|
+
out.error('CalDAV authentication failed. Try: zele login')
|
|
53
|
+
} else {
|
|
54
|
+
out.error(`Failed to fetch calendars: ${msg}`)
|
|
55
|
+
}
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
return true
|
|
59
|
+
})
|
|
60
|
+
.map((r) => r.value)
|
|
61
|
+
|
|
62
|
+
const showAccount = clients.length > 1
|
|
63
|
+
const merged = allResults.flatMap(({ email, calendars }) =>
|
|
64
|
+
calendars
|
|
65
|
+
.sort((a, b) => (b.primary ? 1 : 0) - (a.primary ? 1 : 0))
|
|
66
|
+
.map((cal) => ({
|
|
67
|
+
...(showAccount ? { account: email } : {}),
|
|
68
|
+
id: cal.id,
|
|
69
|
+
name: cal.summary,
|
|
70
|
+
role: cal.role,
|
|
71
|
+
...(cal.primary ? { primary: true } : {}),
|
|
72
|
+
})),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if (merged.length === 0) {
|
|
76
|
+
out.hint('No calendars found')
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
out.printList(merged)
|
|
81
|
+
out.hint(`${merged.length} calendars`)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// =========================================================================
|
|
85
|
+
// cal events
|
|
86
|
+
// =========================================================================
|
|
87
|
+
|
|
88
|
+
cli
|
|
89
|
+
.command('cal events', 'List calendar events')
|
|
90
|
+
.option('--calendar <calendar>', 'Calendar ID (default: primary)')
|
|
91
|
+
.option('--from <from>', 'Start time (ISO date, "today", "tomorrow", weekday name)')
|
|
92
|
+
.option('--to <to>', 'End time (same formats, or +1h/+30m/+2d relative to --from)')
|
|
93
|
+
.option('--today', 'Show today only')
|
|
94
|
+
.option('--tomorrow', 'Show tomorrow only')
|
|
95
|
+
.option('--week', 'Show this week')
|
|
96
|
+
.option('--days <days>', z.number().describe('Show next N days'))
|
|
97
|
+
.option('--all', 'Fetch from all calendars')
|
|
98
|
+
.option('--query <query>', 'Free text search')
|
|
99
|
+
.option('--max [max]', 'Max results (default: 20)')
|
|
100
|
+
.option('--page <page>', 'Pagination token')
|
|
101
|
+
.option('--no-cache', 'Skip cache')
|
|
102
|
+
.action(async (options) => {
|
|
103
|
+
const max = options.max ? Number(options.max) : 20
|
|
104
|
+
const calendarId = options.calendar ?? 'primary'
|
|
105
|
+
|
|
106
|
+
if (options.all && options.calendar) {
|
|
107
|
+
out.error('--all and --calendar cannot be used together')
|
|
108
|
+
process.exit(1)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const clients = await getCalendarClients(options.account)
|
|
112
|
+
|
|
113
|
+
if (options.page && clients.length > 1) {
|
|
114
|
+
out.error('--page cannot be used with multiple accounts (page tokens are per-account)')
|
|
115
|
+
process.exit(1)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const settled = await Promise.allSettled(
|
|
119
|
+
clients.map(async ({ email, client }) => {
|
|
120
|
+
const tz = await client.getTimezone(calendarId)
|
|
121
|
+
const { timeMin, timeMax } = resolveTimeRange({
|
|
122
|
+
from: options.from,
|
|
123
|
+
to: options.to,
|
|
124
|
+
today: options.today,
|
|
125
|
+
tomorrow: options.tomorrow,
|
|
126
|
+
week: options.week,
|
|
127
|
+
days: options.days,
|
|
128
|
+
}, tz)
|
|
129
|
+
|
|
130
|
+
const cacheParams = {
|
|
131
|
+
calendarId: options.all ? '__all__' : calendarId,
|
|
132
|
+
timeMin,
|
|
133
|
+
timeMax,
|
|
134
|
+
query: options.query,
|
|
135
|
+
maxResults: max,
|
|
136
|
+
pageToken: options.page,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!options.noCache) {
|
|
140
|
+
const cached = await cache.getCachedCalendarEvents<EventListResult>(email, cacheParams)
|
|
141
|
+
if (cached) return { email, result: cached, tz }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let result: EventListResult
|
|
145
|
+
|
|
146
|
+
if (options.all) {
|
|
147
|
+
// Fetch from all calendars
|
|
148
|
+
const calendars = await client.listCalendars()
|
|
149
|
+
const allEvents: CalendarEvent[] = []
|
|
150
|
+
|
|
151
|
+
const perCalResults = await Promise.allSettled(
|
|
152
|
+
calendars.map(async (cal) => {
|
|
153
|
+
const r = await client.listEvents({
|
|
154
|
+
calendarId: cal.id,
|
|
155
|
+
timeMin,
|
|
156
|
+
timeMax,
|
|
157
|
+
query: options.query,
|
|
158
|
+
maxResults: max,
|
|
159
|
+
})
|
|
160
|
+
return r.events.map((e) => ({ ...e, calendarId: cal.id }))
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
for (const r of perCalResults) {
|
|
165
|
+
if (r.status === 'fulfilled') {
|
|
166
|
+
allEvents.push(...r.value)
|
|
167
|
+
} else {
|
|
168
|
+
out.error(`Calendar fetch failed: ${r.reason}`)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
result = {
|
|
173
|
+
events: allEvents,
|
|
174
|
+
nextPageToken: null,
|
|
175
|
+
timezone: tz,
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
result = await client.listEvents({
|
|
179
|
+
calendarId,
|
|
180
|
+
timeMin,
|
|
181
|
+
timeMax,
|
|
182
|
+
query: options.query,
|
|
183
|
+
maxResults: max,
|
|
184
|
+
pageToken: options.page,
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!options.noCache) {
|
|
189
|
+
await cache.cacheCalendarEvents(email, cacheParams, result)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { email, result, tz }
|
|
193
|
+
}),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
const allResults = settled
|
|
197
|
+
.filter((r): r is PromiseFulfilledResult<{ email: string; result: EventListResult; tz: string }> => {
|
|
198
|
+
if (r.status === 'rejected') {
|
|
199
|
+
const msg = String(r.reason)
|
|
200
|
+
if (msg.includes('401') || msg.includes('403') || msg.includes('Unauthorized')) {
|
|
201
|
+
out.error('CalDAV authentication failed. Try: zele login')
|
|
202
|
+
} else {
|
|
203
|
+
out.error(`Failed to fetch events: ${msg}`)
|
|
204
|
+
}
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
return true
|
|
208
|
+
})
|
|
209
|
+
.map((r) => r.value)
|
|
210
|
+
|
|
211
|
+
const showAccount = clients.length > 1
|
|
212
|
+
|
|
213
|
+
// Merge events from all accounts, sort by start time
|
|
214
|
+
const merged = allResults
|
|
215
|
+
.flatMap(({ email, result }) =>
|
|
216
|
+
result.events.map((e) => ({ ...e, account: email })),
|
|
217
|
+
)
|
|
218
|
+
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
|
|
219
|
+
.slice(0, max)
|
|
220
|
+
|
|
221
|
+
if (merged.length === 0) {
|
|
222
|
+
out.hint('No events found')
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
out.printList(
|
|
227
|
+
merged.map((e) => {
|
|
228
|
+
const time = out.formatEventTime(e.start, e.end, e.allDay)
|
|
229
|
+
return {
|
|
230
|
+
...(showAccount ? { account: e.account } : {}),
|
|
231
|
+
id: e.id,
|
|
232
|
+
summary: e.summary,
|
|
233
|
+
start: time.start,
|
|
234
|
+
end: time.end,
|
|
235
|
+
...(e.location ? { location: e.location } : {}),
|
|
236
|
+
...(e.calendarId && e.calendarId !== calendarId ? { calendar: e.calendarId } : {}),
|
|
237
|
+
}
|
|
238
|
+
}),
|
|
239
|
+
{ nextPage: allResults[0]?.result.nextPageToken },
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
out.hint(`${merged.length} events`)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
// =========================================================================
|
|
246
|
+
// cal get
|
|
247
|
+
// =========================================================================
|
|
248
|
+
|
|
249
|
+
cli
|
|
250
|
+
.command('cal get <eventId>', 'Get event details')
|
|
251
|
+
.option('--calendar <calendar>', 'Calendar ID (default: primary)')
|
|
252
|
+
.action(async (eventId, options) => {
|
|
253
|
+
const calendarId = options.calendar ?? 'primary'
|
|
254
|
+
const { client } = await getCalendarClient(options.account)
|
|
255
|
+
|
|
256
|
+
const event = await client.getEvent({ calendarId, eventId })
|
|
257
|
+
const time = out.formatEventTime(event.start, event.end, event.allDay)
|
|
258
|
+
|
|
259
|
+
const doc: Record<string, unknown> = {
|
|
260
|
+
id: event.id,
|
|
261
|
+
summary: event.summary,
|
|
262
|
+
start: time.start,
|
|
263
|
+
end: time.end,
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (event.allDay) doc.all_day = true
|
|
267
|
+
if (event.location) doc.location = event.location
|
|
268
|
+
if (event.description) doc.description = event.description
|
|
269
|
+
if (event.meetLink) doc.meet = event.meetLink
|
|
270
|
+
if (event.attendees.length > 0) {
|
|
271
|
+
doc.attendees = event.attendees.map((a) => ({
|
|
272
|
+
email: a.email,
|
|
273
|
+
...(a.name ? { name: a.name } : {}),
|
|
274
|
+
status: a.status,
|
|
275
|
+
...(a.organizer ? { organizer: true } : {}),
|
|
276
|
+
}))
|
|
277
|
+
}
|
|
278
|
+
if (event.recurrence.length > 0) doc.recurrence = event.recurrence
|
|
279
|
+
if (event.visibility !== 'default') doc.visibility = event.visibility
|
|
280
|
+
if (event.transparency === 'transparent') doc.show_as = 'free'
|
|
281
|
+
if (event.colorId) doc.color = event.colorId
|
|
282
|
+
if (event.htmlLink) doc.link = event.htmlLink
|
|
283
|
+
|
|
284
|
+
out.printYaml(doc)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
// =========================================================================
|
|
288
|
+
// cal create
|
|
289
|
+
// =========================================================================
|
|
290
|
+
|
|
291
|
+
cli
|
|
292
|
+
.command('cal create', 'Create a calendar event')
|
|
293
|
+
.option('--calendar <calendar>', 'Calendar ID (default: primary)')
|
|
294
|
+
.option('--summary <summary>', z.string().describe('Event title'))
|
|
295
|
+
.option('--from <from>', z.string().describe('Start time'))
|
|
296
|
+
.option('--to <to>', z.string().describe('End time (or +1h, +30m, +2d relative to --from)'))
|
|
297
|
+
.option('--description <description>', 'Event description')
|
|
298
|
+
.option('--location <location>', 'Event location')
|
|
299
|
+
.option('--attendees <attendees>', 'Comma-separated attendee emails')
|
|
300
|
+
.option('--meet', 'Create Google Meet link')
|
|
301
|
+
.option('--all-day', 'All-day event')
|
|
302
|
+
.option('--recurrence <recurrence>', 'Recurrence rule (e.g. RRULE:FREQ=WEEKLY;BYDAY=MO)')
|
|
303
|
+
.option('--reminder <reminder>', 'Reminder as method:duration (e.g. popup:15m)')
|
|
304
|
+
.option('--color <color>', 'Event color ID (1-11)')
|
|
305
|
+
.option('--visibility <visibility>', 'Event visibility (default, public, private)')
|
|
306
|
+
.action(async (options) => {
|
|
307
|
+
if (!options.summary) {
|
|
308
|
+
out.error('--summary is required')
|
|
309
|
+
process.exit(1)
|
|
310
|
+
}
|
|
311
|
+
if (!options.from) {
|
|
312
|
+
out.error('--from is required')
|
|
313
|
+
process.exit(1)
|
|
314
|
+
}
|
|
315
|
+
if (!options.to) {
|
|
316
|
+
out.error('--to is required')
|
|
317
|
+
process.exit(1)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const calendarId = options.calendar ?? 'primary'
|
|
321
|
+
const { email, client } = await getCalendarClient(options.account)
|
|
322
|
+
const tz = await client.getTimezone(calendarId)
|
|
323
|
+
|
|
324
|
+
const allDay = options.allDay || (isDateOnly(options.from) && isDateOnly(options.to))
|
|
325
|
+
const start = allDay ? options.from : parseTimeExpression(options.from, tz)
|
|
326
|
+
|
|
327
|
+
let end: string
|
|
328
|
+
if (allDay) {
|
|
329
|
+
// Calendar API uses exclusive end date: add 1 day so user can pass same date for single-day
|
|
330
|
+
const endDate = new Date(options.to + 'T00:00:00')
|
|
331
|
+
endDate.setDate(endDate.getDate() + 1)
|
|
332
|
+
end = endDate.toISOString().split('T')[0]!
|
|
333
|
+
} else {
|
|
334
|
+
// Support +duration syntax (e.g. +1h, +30m)
|
|
335
|
+
const durationMs = parseDuration(options.to)
|
|
336
|
+
if (durationMs !== null) {
|
|
337
|
+
end = new Date(new Date(start).getTime() + durationMs).toISOString()
|
|
338
|
+
} else {
|
|
339
|
+
end = parseTimeExpression(options.to, tz)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const attendees = options.attendees
|
|
344
|
+
? options.attendees.split(',').map((e: string) => e.trim()).filter(Boolean)
|
|
345
|
+
: undefined
|
|
346
|
+
|
|
347
|
+
const reminders = options.reminder ? [parseReminder(options.reminder)] : undefined
|
|
348
|
+
|
|
349
|
+
const event = await client.createEvent({
|
|
350
|
+
calendarId,
|
|
351
|
+
summary: options.summary,
|
|
352
|
+
start,
|
|
353
|
+
end,
|
|
354
|
+
allDay,
|
|
355
|
+
description: options.description,
|
|
356
|
+
location: options.location,
|
|
357
|
+
attendees,
|
|
358
|
+
withMeet: options.meet ?? false,
|
|
359
|
+
recurrence: options.recurrence ? [options.recurrence] : undefined,
|
|
360
|
+
reminders,
|
|
361
|
+
colorId: options.color,
|
|
362
|
+
visibility: options.visibility,
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
await cache.invalidateCalendarEvents(email)
|
|
366
|
+
|
|
367
|
+
printEventDetail(event)
|
|
368
|
+
out.success('Event created')
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
// =========================================================================
|
|
372
|
+
// cal update
|
|
373
|
+
// =========================================================================
|
|
374
|
+
|
|
375
|
+
cli
|
|
376
|
+
.command('cal update <eventId>', 'Update a calendar event')
|
|
377
|
+
.option('--calendar <calendar>', 'Calendar ID (default: primary)')
|
|
378
|
+
.option('--summary <summary>', 'Event title')
|
|
379
|
+
.option('--from <from>', 'Start time')
|
|
380
|
+
.option('--to <to>', 'End time')
|
|
381
|
+
.option('--description <description>', 'Event description')
|
|
382
|
+
.option('--location <location>', 'Event location')
|
|
383
|
+
.option('--add-attendees <addAttendees>', 'Comma-separated emails to add')
|
|
384
|
+
.option('--remove-attendees <removeAttendees>', 'Comma-separated emails to remove')
|
|
385
|
+
.option('--meet', 'Add Google Meet link')
|
|
386
|
+
.option('--color <color>', 'Event color ID (1-11, empty to clear)')
|
|
387
|
+
.option('--visibility <visibility>', 'Event visibility')
|
|
388
|
+
.action(async (eventId, options) => {
|
|
389
|
+
const calendarId = options.calendar ?? 'primary'
|
|
390
|
+
const { email, client } = await getCalendarClient(options.account)
|
|
391
|
+
|
|
392
|
+
const addAttendees = options.addAttendees
|
|
393
|
+
? options.addAttendees.split(',').map((e: string) => e.trim()).filter(Boolean)
|
|
394
|
+
: undefined
|
|
395
|
+
|
|
396
|
+
const removeAttendees = options.removeAttendees
|
|
397
|
+
? options.removeAttendees.split(',').map((e: string) => e.trim()).filter(Boolean)
|
|
398
|
+
: undefined
|
|
399
|
+
|
|
400
|
+
let start: string | undefined
|
|
401
|
+
let end: string | undefined
|
|
402
|
+
let allDay: boolean | undefined
|
|
403
|
+
|
|
404
|
+
if (options.from || options.to) {
|
|
405
|
+
// Detect all-day: both from and to are date-only
|
|
406
|
+
allDay = options.from && options.to && isDateOnly(options.from) && isDateOnly(options.to)
|
|
407
|
+
? true
|
|
408
|
+
: undefined
|
|
409
|
+
|
|
410
|
+
if (allDay) {
|
|
411
|
+
start = options.from
|
|
412
|
+
// Add 1 day for exclusive end date
|
|
413
|
+
if (options.to) {
|
|
414
|
+
const endDate = new Date(options.to + 'T00:00:00')
|
|
415
|
+
endDate.setDate(endDate.getDate() + 1)
|
|
416
|
+
end = endDate.toISOString().split('T')[0]!
|
|
417
|
+
}
|
|
418
|
+
} else {
|
|
419
|
+
const tz = await client.getTimezone(calendarId)
|
|
420
|
+
if (options.from) start = parseTimeExpression(options.from, tz)
|
|
421
|
+
if (options.to) {
|
|
422
|
+
const durationMs = parseDuration(options.to)
|
|
423
|
+
if (durationMs !== null && start) {
|
|
424
|
+
end = new Date(new Date(start).getTime() + durationMs).toISOString()
|
|
425
|
+
} else {
|
|
426
|
+
end = parseTimeExpression(options.to, tz)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const event = await client.updateEvent({
|
|
433
|
+
calendarId,
|
|
434
|
+
eventId,
|
|
435
|
+
summary: options.summary,
|
|
436
|
+
start,
|
|
437
|
+
end,
|
|
438
|
+
allDay,
|
|
439
|
+
description: options.description,
|
|
440
|
+
location: options.location,
|
|
441
|
+
addAttendees,
|
|
442
|
+
removeAttendees,
|
|
443
|
+
withMeet: options.meet,
|
|
444
|
+
colorId: options.color,
|
|
445
|
+
visibility: options.visibility,
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
await cache.invalidateCalendarEvents(email)
|
|
449
|
+
|
|
450
|
+
printEventDetail(event)
|
|
451
|
+
out.success('Event updated')
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
// =========================================================================
|
|
455
|
+
// cal delete
|
|
456
|
+
// =========================================================================
|
|
457
|
+
|
|
458
|
+
cli
|
|
459
|
+
.command('cal delete <eventId>', 'Delete a calendar event')
|
|
460
|
+
.option('--calendar <calendar>', 'Calendar ID (default: primary)')
|
|
461
|
+
.option('--force', 'Skip confirmation')
|
|
462
|
+
.action(async (eventId, options) => {
|
|
463
|
+
const calendarId = options.calendar ?? 'primary'
|
|
464
|
+
const { email, client } = await getCalendarClient(options.account)
|
|
465
|
+
|
|
466
|
+
if (!options.force && process.stdin.isTTY) {
|
|
467
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
|
|
468
|
+
const answer = await new Promise<string>((resolve) => {
|
|
469
|
+
rl.question(`Delete event ${eventId}? [y/N] `, resolve)
|
|
470
|
+
})
|
|
471
|
+
rl.close()
|
|
472
|
+
|
|
473
|
+
if (answer.toLowerCase() !== 'y') {
|
|
474
|
+
out.hint('Cancelled')
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
await client.deleteEvent({ calendarId, eventId })
|
|
480
|
+
await cache.invalidateCalendarEvents(email)
|
|
481
|
+
|
|
482
|
+
out.printYaml({ deleted: true, id: eventId })
|
|
483
|
+
out.success('Event deleted')
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
// =========================================================================
|
|
487
|
+
// cal respond
|
|
488
|
+
// =========================================================================
|
|
489
|
+
|
|
490
|
+
cli
|
|
491
|
+
.command('cal respond <eventId>', 'Respond to event invitation')
|
|
492
|
+
.option('--calendar <calendar>', 'Calendar ID (default: primary)')
|
|
493
|
+
.option('--status <status>', z.string().describe('Response: accepted, declined, tentative'))
|
|
494
|
+
.option('--comment <comment>', 'Optional comment')
|
|
495
|
+
.action(async (eventId, options) => {
|
|
496
|
+
if (!options.status) {
|
|
497
|
+
out.error('--status is required (accepted, declined, tentative)')
|
|
498
|
+
process.exit(1)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const validStatuses = ['accepted', 'declined', 'tentative']
|
|
502
|
+
if (!validStatuses.includes(options.status)) {
|
|
503
|
+
out.error(`Invalid status. Must be one of: ${validStatuses.join(', ')}`)
|
|
504
|
+
process.exit(1)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const calendarId = options.calendar ?? 'primary'
|
|
508
|
+
const { email, client } = await getCalendarClient(options.account)
|
|
509
|
+
|
|
510
|
+
const event = await client.respondToEvent({
|
|
511
|
+
calendarId,
|
|
512
|
+
eventId,
|
|
513
|
+
status: options.status as 'accepted' | 'declined' | 'tentative',
|
|
514
|
+
comment: options.comment,
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
await cache.invalidateCalendarEvents(email)
|
|
518
|
+
|
|
519
|
+
out.printYaml({
|
|
520
|
+
id: event.id,
|
|
521
|
+
summary: event.summary,
|
|
522
|
+
status: options.status,
|
|
523
|
+
...(options.comment ? { comment: options.comment } : {}),
|
|
524
|
+
})
|
|
525
|
+
out.success(`Responded: ${options.status}`)
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
// =========================================================================
|
|
529
|
+
// cal freebusy
|
|
530
|
+
// =========================================================================
|
|
531
|
+
|
|
532
|
+
cli
|
|
533
|
+
.command('cal freebusy [...calendarIds]', 'Get free/busy information')
|
|
534
|
+
.option('--from <from>', z.string().describe('Start time'))
|
|
535
|
+
.option('--to <to>', z.string().describe('End time'))
|
|
536
|
+
.action(async (calendarIds, options) => {
|
|
537
|
+
if (!calendarIds || calendarIds.length === 0) {
|
|
538
|
+
out.error('At least one calendar ID (email) is required')
|
|
539
|
+
process.exit(1)
|
|
540
|
+
}
|
|
541
|
+
if (!options.from || !options.to) {
|
|
542
|
+
out.error('--from and --to are required')
|
|
543
|
+
process.exit(1)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const { client } = await getCalendarClient(options.account)
|
|
547
|
+
const tz = await client.getTimezone()
|
|
548
|
+
|
|
549
|
+
const timeMin = parseTimeExpression(options.from, tz)
|
|
550
|
+
const timeMax = parseTimeExpression(options.to, tz)
|
|
551
|
+
|
|
552
|
+
const results = await client.getFreeBusy({
|
|
553
|
+
calendarIds,
|
|
554
|
+
timeMin,
|
|
555
|
+
timeMax,
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
const items = results.map((r) => ({
|
|
559
|
+
calendar: r.calendar,
|
|
560
|
+
busy: r.busy.length > 0
|
|
561
|
+
? r.busy.map((b) => {
|
|
562
|
+
const st = new Date(b.start).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
|
|
563
|
+
const en = new Date(b.end).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
|
|
564
|
+
return `${st} - ${en}`
|
|
565
|
+
})
|
|
566
|
+
: ['(free)'],
|
|
567
|
+
}))
|
|
568
|
+
|
|
569
|
+
out.printList(items)
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ---------------------------------------------------------------------------
|
|
575
|
+
// Helpers
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
function printEventDetail(event: CalendarEvent) {
|
|
579
|
+
const time = out.formatEventTime(event.start, event.end, event.allDay)
|
|
580
|
+
|
|
581
|
+
const doc: Record<string, unknown> = {
|
|
582
|
+
id: event.id,
|
|
583
|
+
summary: event.summary,
|
|
584
|
+
start: time.start,
|
|
585
|
+
end: time.end,
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (event.allDay) doc.all_day = true
|
|
589
|
+
if (event.location) doc.location = event.location
|
|
590
|
+
if (event.description) doc.description = event.description
|
|
591
|
+
if (event.meetLink) doc.meet = event.meetLink
|
|
592
|
+
if (event.attendees.length > 0) {
|
|
593
|
+
doc.attendees = event.attendees.map((a) => ({
|
|
594
|
+
email: a.email,
|
|
595
|
+
...(a.name ? { name: a.name } : {}),
|
|
596
|
+
status: a.status,
|
|
597
|
+
}))
|
|
598
|
+
}
|
|
599
|
+
if (event.recurrence.length > 0) doc.recurrence = event.recurrence
|
|
600
|
+
if (event.htmlLink) doc.link = event.htmlLink
|
|
601
|
+
|
|
602
|
+
out.printYaml(doc)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function parseReminder(input: string): { method: string; minutes: number } {
|
|
606
|
+
const [method, duration] = input.split(':')
|
|
607
|
+
if (!method || !duration) {
|
|
608
|
+
throw new Error(`Invalid reminder format: "${input}". Use method:duration (e.g. popup:15m)`)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const validMethods = ['popup', 'email']
|
|
612
|
+
if (!validMethods.includes(method)) {
|
|
613
|
+
throw new Error(`Invalid reminder method: "${method}". Must be popup or email`)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const match = duration.match(/^(\d+)(m|h|d|w)?$/)
|
|
617
|
+
if (!match) {
|
|
618
|
+
throw new Error(`Invalid reminder duration: "${duration}". Use 30, 30m, 1h, 3d, or 1w`)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const value = Number(match[1])
|
|
622
|
+
const unit = match[2] ?? 'm'
|
|
623
|
+
|
|
624
|
+
let minutes: number
|
|
625
|
+
switch (unit) {
|
|
626
|
+
case 'm': minutes = value; break
|
|
627
|
+
case 'h': minutes = value * 60; break
|
|
628
|
+
case 'd': minutes = value * 1440; break
|
|
629
|
+
case 'w': minutes = value * 10080; break
|
|
630
|
+
default: minutes = value
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return { method, minutes }
|
|
634
|
+
}
|