zele 0.2.0 → 0.3.5
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/README.md +38 -1
- package/bin/zele +27 -0
- package/dist/api-utils.d.ts +51 -2
- package/dist/api-utils.js +89 -3
- package/dist/api-utils.js.map +1 -1
- package/dist/auth.d.ts +27 -6
- package/dist/auth.js +185 -129
- package/dist/auth.js.map +1 -1
- package/dist/calendar-client.d.ts +16 -9
- package/dist/calendar-client.js +163 -59
- package/dist/calendar-client.js.map +1 -1
- package/dist/cli.js +28 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/attachment.js +17 -15
- package/dist/commands/attachment.js.map +1 -1
- package/dist/commands/auth-cmd.js +20 -9
- package/dist/commands/auth-cmd.js.map +1 -1
- package/dist/commands/calendar.js +67 -78
- package/dist/commands/calendar.js.map +1 -1
- package/dist/commands/draft.js +25 -18
- package/dist/commands/draft.js.map +1 -1
- package/dist/commands/label.js +33 -45
- package/dist/commands/label.js.map +1 -1
- package/dist/commands/mail-actions.js +11 -13
- package/dist/commands/mail-actions.js.map +1 -1
- package/dist/commands/mail.js +114 -128
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/profile.js +18 -21
- package/dist/commands/profile.js.map +1 -1
- package/dist/commands/watch.d.ts +2 -0
- package/dist/commands/watch.js +73 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/db.js +12 -13
- package/dist/db.js.map +1 -1
- package/dist/generated/browser.d.ts +12 -27
- package/dist/generated/client.d.ts +13 -28
- package/dist/generated/client.js +1 -1
- package/dist/generated/commonInputTypes.d.ts +90 -26
- package/dist/generated/enums.d.ts +0 -4
- package/dist/generated/enums.js +0 -3
- package/dist/generated/enums.js.map +1 -1
- package/dist/generated/internal/class.d.ts +22 -55
- package/dist/generated/internal/class.js +12 -4
- package/dist/generated/internal/class.js.map +1 -1
- package/dist/generated/internal/prismaNamespace.d.ts +272 -511
- package/dist/generated/internal/prismaNamespace.js +54 -66
- package/dist/generated/internal/prismaNamespace.js.map +1 -1
- package/dist/generated/internal/prismaNamespaceBrowser.d.ts +60 -74
- package/dist/generated/internal/prismaNamespaceBrowser.js +50 -62
- package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
- package/dist/generated/models/Account.d.ts +1637 -0
- package/dist/generated/models/Account.js +2 -0
- package/dist/generated/models/Account.js.map +1 -0
- package/dist/generated/models/CalendarList.d.ts +1161 -0
- package/dist/generated/models/CalendarList.js +2 -0
- package/dist/generated/models/CalendarList.js.map +1 -0
- package/dist/generated/models/Label.d.ts +1161 -0
- package/dist/generated/models/Label.js +2 -0
- package/dist/generated/models/Label.js.map +1 -0
- package/dist/generated/models/Profile.d.ts +1269 -0
- package/dist/generated/models/Profile.js +2 -0
- package/dist/generated/models/Profile.js.map +1 -0
- package/dist/generated/models/SyncState.d.ts +1130 -0
- package/dist/generated/models/SyncState.js +2 -0
- package/dist/generated/models/SyncState.js.map +1 -0
- package/dist/generated/models/Thread.d.ts +1608 -0
- package/dist/generated/models/Thread.js +2 -0
- package/dist/generated/models/Thread.js.map +1 -0
- package/dist/generated/models.d.ts +6 -9
- package/dist/gmail-client.d.ts +119 -94
- package/dist/gmail-client.js +862 -315
- package/dist/gmail-client.js.map +1 -1
- package/dist/mail-tui.d.ts +1 -0
- package/dist/mail-tui.js +517 -0
- package/dist/mail-tui.js.map +1 -0
- package/dist/output.d.ts +6 -4
- package/dist/output.js +124 -17
- package/dist/output.js.map +1 -1
- package/package.json +39 -11
- package/schema.prisma +81 -113
- package/src/api-utils.ts +103 -5
- package/src/auth.ts +224 -143
- package/src/calendar-client.ts +196 -89
- package/src/cli.ts +32 -1
- package/src/commands/attachment.ts +18 -19
- package/src/commands/auth-cmd.ts +19 -9
- package/src/commands/calendar.ts +42 -85
- package/src/commands/draft.ts +19 -22
- package/src/commands/label.ts +21 -57
- package/src/commands/mail-actions.ts +11 -19
- package/src/commands/mail.ts +104 -149
- package/src/commands/profile.ts +12 -28
- package/src/commands/watch.ts +88 -0
- package/src/db.ts +13 -16
- package/src/generated/browser.ts +49 -0
- package/src/generated/client.ts +71 -0
- package/src/generated/commonInputTypes.ts +332 -0
- package/src/generated/enums.ts +17 -0
- package/src/generated/internal/class.ts +250 -0
- package/src/generated/internal/prismaNamespace.ts +1198 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +169 -0
- package/src/generated/models/Account.ts +1848 -0
- package/src/generated/models/CalendarList.ts +1331 -0
- package/src/generated/models/Label.ts +1331 -0
- package/src/generated/models/Profile.ts +1439 -0
- package/src/generated/models/SyncState.ts +1300 -0
- package/src/generated/models/Thread.ts +1787 -0
- package/src/generated/models.ts +17 -0
- package/src/gmail-client.test.ts +59 -0
- package/src/gmail-client.ts +1034 -422
- package/src/mail-tui.tsx +1061 -0
- package/src/output.test.ts +1093 -0
- package/src/output.ts +128 -20
- package/src/schema.sql +58 -68
- package/src/test-fixtures/email-html/safe-claude-event.html +28 -0
- package/src/test-fixtures/email-html/safe-product-announcement.html +25 -0
- package/src/test-fixtures/email-html/safe-tracked-links.html +27 -0
- package/src/test-fixtures/email-html-snapshots/safe-claude-event.html.md +9 -0
- package/src/test-fixtures/email-html-snapshots/safe-product-announcement.html.md +13 -0
- package/src/test-fixtures/email-html-snapshots/safe-tracked-links.html.md +7 -0
- package/AGENTS.md +0 -26
- package/CHANGELOG.md +0 -36
- package/dist/generated/models/accounts.d.ts +0 -2000
- package/dist/generated/models/accounts.js +0 -2
- package/dist/generated/models/accounts.js.map +0 -1
- package/dist/generated/models/calendar_events.d.ts +0 -1433
- package/dist/generated/models/calendar_events.js +0 -2
- package/dist/generated/models/calendar_events.js.map +0 -1
- package/dist/generated/models/calendar_lists.d.ts +0 -1131
- package/dist/generated/models/calendar_lists.js +0 -2
- package/dist/generated/models/calendar_lists.js.map +0 -1
- package/dist/generated/models/label_counts.d.ts +0 -1131
- package/dist/generated/models/label_counts.js +0 -2
- package/dist/generated/models/label_counts.js.map +0 -1
- package/dist/generated/models/labels.d.ts +0 -1131
- package/dist/generated/models/labels.js +0 -2
- package/dist/generated/models/labels.js.map +0 -1
- package/dist/generated/models/profiles.d.ts +0 -1131
- package/dist/generated/models/profiles.js +0 -2
- package/dist/generated/models/profiles.js.map +0 -1
- package/dist/generated/models/sync_states.d.ts +0 -1107
- package/dist/generated/models/sync_states.js +0 -2
- package/dist/generated/models/sync_states.js.map +0 -1
- package/dist/generated/models/thread_lists.d.ts +0 -1404
- package/dist/generated/models/thread_lists.js +0 -2
- package/dist/generated/models/thread_lists.js.map +0 -1
- package/dist/generated/models/threads.d.ts +0 -1247
- package/dist/generated/models/threads.js +0 -2
- package/dist/generated/models/threads.js.map +0 -1
- package/dist/gmail-cache.d.ts +0 -60
- package/dist/gmail-cache.js +0 -264
- package/dist/gmail-cache.js.map +0 -1
- package/docs/gogcli-gmail-implementation.md +0 -599
- package/scripts/test-device-code-clients.ts +0 -186
- package/scripts/test-micropython-scopes.ts +0 -72
- package/scripts/test-oauth-clients.ts +0 -257
- package/src/gmail-cache.ts +0 -339
- package/tsconfig.json +0 -16
package/src/calendar-client.ts
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// Uses tsdav for CalDAV protocol and ts-ics for typed iCalendar parse/generate.
|
|
3
3
|
// Auth: passes a Bearer token via headers (reuses existing google-auth-library OAuth2).
|
|
4
4
|
// Google CalDAV endpoint: https://apidata.googleusercontent.com/caldav/v2/
|
|
5
|
+
// Cache is built into the client for stable metadata lookups (calendar list/timezone).
|
|
6
|
+
// Event listing is always fresh so CLI output reflects newly created/updated events.
|
|
7
|
+
// Raw tsdav responses are stored in the cache so the cache is resilient to changes
|
|
8
|
+
// in our own parsed types. Parsing happens at read time.
|
|
5
9
|
|
|
6
10
|
import {
|
|
7
11
|
fetchCalendars,
|
|
@@ -21,6 +25,21 @@ import {
|
|
|
21
25
|
type IcsDateObject,
|
|
22
26
|
} from 'ts-ics'
|
|
23
27
|
import crypto from 'node:crypto'
|
|
28
|
+
import * as errore from 'errore'
|
|
29
|
+
import { getPrisma } from './db.js'
|
|
30
|
+
import { AuthError, isAuthLikeError, ApiError, NotFoundError, ParseError, MissingDataError } from './api-utils.js'
|
|
31
|
+
|
|
32
|
+
/** Boundary helper: wrap a tsdav/CalDAV call, converting auth-like errors to AuthError values.
|
|
33
|
+
* Non-auth errors are wrapped in ApiError so they remain error values (no throwing).
|
|
34
|
+
* Original error is preserved as `cause` for debugging. */
|
|
35
|
+
function caldavBoundary<T>(email: string, fn: () => Promise<T>) {
|
|
36
|
+
return errore.tryAsync({
|
|
37
|
+
try: fn,
|
|
38
|
+
catch: (err) => isAuthLikeError(err)
|
|
39
|
+
? new AuthError({ email, reason: String(err) })
|
|
40
|
+
: new ApiError({ reason: String(err), cause: err }),
|
|
41
|
+
})
|
|
42
|
+
}
|
|
24
43
|
|
|
25
44
|
// ---------------------------------------------------------------------------
|
|
26
45
|
// Types (kept identical to previous API so commands layer is unchanged)
|
|
@@ -212,15 +231,13 @@ function icsEventToCalendarEvent(event: IcsEvent, calObj?: DAVCalendarObject): C
|
|
|
212
231
|
}
|
|
213
232
|
}
|
|
214
233
|
|
|
215
|
-
/** Parse all events from a raw iCal data string using ts-ics
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
return []
|
|
223
|
-
}
|
|
234
|
+
/** Parse all events from a raw iCal data string using ts-ics.
|
|
235
|
+
* Boundary: convertIcsCalendar (ts-ics library) may throw on malformed iCal.
|
|
236
|
+
* Returns ParseError on failure so callers can decide how to handle it. */
|
|
237
|
+
function parseICalData(data: string, calObj?: DAVCalendarObject): ParseError | CalendarEvent[] {
|
|
238
|
+
const calendar = errore.tryFn(() => convertIcsCalendar(undefined, data))
|
|
239
|
+
if (calendar instanceof Error) return new ParseError({ what: 'iCal data', reason: calendar.message })
|
|
240
|
+
return (calendar.events ?? []).map((ev) => icsEventToCalendarEvent(ev, calObj))
|
|
224
241
|
}
|
|
225
242
|
|
|
226
243
|
/** Build an iCal string from event properties using ts-ics */
|
|
@@ -307,15 +324,25 @@ function extractTimezone(tz?: string): string {
|
|
|
307
324
|
|
|
308
325
|
const GOOGLE_CALDAV_URL = 'https://apidata.googleusercontent.com/caldav/v2/'
|
|
309
326
|
|
|
327
|
+
const TTL = {
|
|
328
|
+
CALENDAR_LIST: 30 * 60 * 1000, // 30 minutes
|
|
329
|
+
} as const
|
|
330
|
+
|
|
331
|
+
function isExpired(createdAt: Date, ttlMs: number): boolean {
|
|
332
|
+
return createdAt.getTime() + ttlMs < Date.now()
|
|
333
|
+
}
|
|
334
|
+
|
|
310
335
|
export class CalendarClient {
|
|
311
336
|
private headers: Record<string, string>
|
|
312
337
|
private email: string
|
|
338
|
+
private appId: string
|
|
313
339
|
private calendarCache: DAVCalendar[] | null = null
|
|
314
340
|
private timezoneCache: Record<string, string> = {}
|
|
315
341
|
|
|
316
|
-
constructor({ accessToken, email }: { accessToken: string; email: string }) {
|
|
342
|
+
constructor({ accessToken, email, appId }: { accessToken: string; email: string; appId: string }) {
|
|
317
343
|
this.headers = { Authorization: `Bearer ${accessToken}` }
|
|
318
344
|
this.email = email
|
|
345
|
+
this.appId = appId
|
|
319
346
|
}
|
|
320
347
|
|
|
321
348
|
/** Update the access token (e.g. after refresh) */
|
|
@@ -323,30 +350,63 @@ export class CalendarClient {
|
|
|
323
350
|
this.headers = { Authorization: `Bearer ${token}` }
|
|
324
351
|
}
|
|
325
352
|
|
|
353
|
+
// =========================================================================
|
|
354
|
+
// Cache helpers (private)
|
|
355
|
+
// =========================================================================
|
|
356
|
+
|
|
357
|
+
private get account() {
|
|
358
|
+
return { email: this.email, appId: this.appId }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private async getCachedCalendarList(): Promise<CalendarListItem[] | undefined> {
|
|
362
|
+
const prisma = await getPrisma()
|
|
363
|
+
const row = await prisma.calendarList.findUnique({ where: { email_appId: this.account } })
|
|
364
|
+
if (!row || isExpired(row.createdAt, row.ttlMs)) return undefined
|
|
365
|
+
return JSON.parse(row.rawData) as CalendarListItem[]
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private async cacheCalendarListData(data: CalendarListItem[]): Promise<void> {
|
|
369
|
+
const prisma = await getPrisma()
|
|
370
|
+
await prisma.calendarList.upsert({
|
|
371
|
+
where: { email_appId: this.account },
|
|
372
|
+
create: { ...this.account, rawData: JSON.stringify(data), ttlMs: TTL.CALENDAR_LIST, createdAt: new Date() },
|
|
373
|
+
update: { rawData: JSON.stringify(data), ttlMs: TTL.CALENDAR_LIST, createdAt: new Date() },
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private async invalidateCalendarLists(): Promise<void> {
|
|
378
|
+
const prisma = await getPrisma()
|
|
379
|
+
await prisma.calendarList.deleteMany({ where: this.account })
|
|
380
|
+
}
|
|
381
|
+
|
|
326
382
|
// =========================================================================
|
|
327
383
|
// Internal: fetch DAVCalendar list (cached per instance)
|
|
328
384
|
// =========================================================================
|
|
329
385
|
|
|
330
|
-
private async fetchDAVCalendars(): Promise<DAVCalendar[]> {
|
|
386
|
+
private async fetchDAVCalendars(): Promise<DAVCalendar[] | AuthError | ApiError> {
|
|
331
387
|
if (this.calendarCache) return this.calendarCache
|
|
332
388
|
|
|
333
|
-
const calendars = await
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
389
|
+
const calendars = await caldavBoundary(this.email, () =>
|
|
390
|
+
fetchCalendars({
|
|
391
|
+
account: {
|
|
392
|
+
serverUrl: GOOGLE_CALDAV_URL,
|
|
393
|
+
rootUrl: GOOGLE_CALDAV_URL,
|
|
394
|
+
accountType: 'caldav',
|
|
395
|
+
homeUrl: `${GOOGLE_CALDAV_URL}${this.email}/`,
|
|
396
|
+
},
|
|
397
|
+
headers: this.headers,
|
|
398
|
+
}),
|
|
399
|
+
)
|
|
400
|
+
if (calendars instanceof Error) return calendars
|
|
342
401
|
|
|
343
402
|
this.calendarCache = calendars
|
|
344
403
|
return calendars
|
|
345
404
|
}
|
|
346
405
|
|
|
347
406
|
/** Resolve a calendarId to a DAVCalendar. 'primary' maps to the user's email. */
|
|
348
|
-
private async resolveCalendar(calendarId: string): Promise<DAVCalendar> {
|
|
407
|
+
private async resolveCalendar(calendarId: string): Promise<DAVCalendar | AuthError | ApiError | NotFoundError> {
|
|
349
408
|
const calendars = await this.fetchDAVCalendars()
|
|
409
|
+
if (calendars instanceof Error) return calendars
|
|
350
410
|
|
|
351
411
|
// 'primary' = the user's own calendar (URL contains their email)
|
|
352
412
|
const targetId = calendarId === 'primary' ? this.email : calendarId
|
|
@@ -358,7 +418,7 @@ export class CalendarClient {
|
|
|
358
418
|
})
|
|
359
419
|
|
|
360
420
|
if (!match) {
|
|
361
|
-
|
|
421
|
+
return new NotFoundError({ resource: `calendar "${calendarId}". Available: ${calendars.map((c) => c.displayName || c.url).join(', ')}` })
|
|
362
422
|
}
|
|
363
423
|
|
|
364
424
|
return match
|
|
@@ -368,10 +428,15 @@ export class CalendarClient {
|
|
|
368
428
|
// Calendar list
|
|
369
429
|
// =========================================================================
|
|
370
430
|
|
|
371
|
-
async listCalendars(): Promise<CalendarListItem[]> {
|
|
431
|
+
async listCalendars(): Promise<CalendarListItem[] | AuthError | ApiError> {
|
|
432
|
+
// Check cache
|
|
433
|
+
const cached = await this.getCachedCalendarList()
|
|
434
|
+
if (cached) return cached
|
|
435
|
+
|
|
372
436
|
const calendars = await this.fetchDAVCalendars()
|
|
437
|
+
if (calendars instanceof Error) return calendars
|
|
373
438
|
|
|
374
|
-
|
|
439
|
+
const result = calendars.map((cal) => {
|
|
375
440
|
// Extract calendar ID from URL
|
|
376
441
|
// URL looks like: https://apidata.googleusercontent.com/caldav/v2/user%40gmail.com/events/
|
|
377
442
|
const urlParts = cal.url.replace(/\/$/, '').split('/')
|
|
@@ -392,29 +457,36 @@ export class CalendarClient {
|
|
|
392
457
|
backgroundColor: cal.calendarColor ?? '',
|
|
393
458
|
}
|
|
394
459
|
})
|
|
460
|
+
|
|
461
|
+
// Write cache
|
|
462
|
+
await this.cacheCalendarListData(result)
|
|
463
|
+
|
|
464
|
+
return result
|
|
395
465
|
}
|
|
396
466
|
|
|
397
467
|
// =========================================================================
|
|
398
468
|
// Timezone
|
|
399
469
|
// =========================================================================
|
|
400
470
|
|
|
401
|
-
async getTimezone(calendarId = 'primary'): Promise<string> {
|
|
471
|
+
async getTimezone(calendarId = 'primary'): Promise<string | AuthError | ApiError | NotFoundError> {
|
|
402
472
|
if (this.timezoneCache[calendarId]) return this.timezoneCache[calendarId]!
|
|
403
473
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
474
|
+
const cal = await this.resolveCalendar(calendarId)
|
|
475
|
+
if (cal instanceof Error) return cal
|
|
476
|
+
|
|
477
|
+
// extractTimezone is a pure function (regex on string) — no try/catch needed.
|
|
478
|
+
// Fallback to calendar list metadata if the DAVCalendar has no timezone data.
|
|
479
|
+
let tz = extractTimezone(cal.timezone)
|
|
480
|
+
if (tz === 'UTC' && cal.timezone) {
|
|
481
|
+
// extractTimezone returned UTC as default — check calendar list for a better match
|
|
411
482
|
const calendars = await this.listCalendars()
|
|
483
|
+
if (calendars instanceof Error) return calendars
|
|
412
484
|
const target = calendarId === 'primary' ? this.email : calendarId
|
|
413
485
|
const match = calendars.find((c) => c.id.toLowerCase() === target.toLowerCase())
|
|
414
|
-
|
|
415
|
-
this.timezoneCache[calendarId] = tz
|
|
416
|
-
return tz
|
|
486
|
+
if (match?.timezone && match.timezone !== 'UTC') tz = match.timezone
|
|
417
487
|
}
|
|
488
|
+
this.timezoneCache[calendarId] = tz
|
|
489
|
+
return tz
|
|
418
490
|
}
|
|
419
491
|
|
|
420
492
|
// =========================================================================
|
|
@@ -435,9 +507,13 @@ export class CalendarClient {
|
|
|
435
507
|
query?: string
|
|
436
508
|
maxResults?: number
|
|
437
509
|
pageToken?: string
|
|
438
|
-
} = {}): Promise<EventListResult> {
|
|
510
|
+
} = {}): Promise<EventListResult | AuthError | ApiError | NotFoundError> {
|
|
511
|
+
// Always fresh: event lists are user-facing live data.
|
|
512
|
+
|
|
439
513
|
const cal = await this.resolveCalendar(calendarId)
|
|
514
|
+
if (cal instanceof Error) return cal
|
|
440
515
|
const tz = await this.getTimezone(calendarId)
|
|
516
|
+
if (tz instanceof Error) return tz
|
|
441
517
|
|
|
442
518
|
const fetchOpts: Parameters<typeof fetchCalendarObjects>[0] = {
|
|
443
519
|
calendar: cal,
|
|
@@ -449,12 +525,15 @@ export class CalendarClient {
|
|
|
449
525
|
fetchOpts.timeRange = { start: timeMin, end: timeMax }
|
|
450
526
|
}
|
|
451
527
|
|
|
452
|
-
const calObjects = await fetchCalendarObjects(fetchOpts)
|
|
528
|
+
const calObjects = await caldavBoundary(this.email, () => fetchCalendarObjects(fetchOpts))
|
|
529
|
+
if (calObjects instanceof Error) return calObjects
|
|
453
530
|
|
|
454
531
|
let events: CalendarEvent[] = []
|
|
455
532
|
for (const obj of calObjects) {
|
|
456
533
|
if (!obj.data) continue
|
|
457
|
-
|
|
534
|
+
const parsed = parseICalData(obj.data, obj)
|
|
535
|
+
if (parsed instanceof ParseError) continue // skip unparseable calendar objects
|
|
536
|
+
events.push(...parsed)
|
|
458
537
|
}
|
|
459
538
|
|
|
460
539
|
if (query) {
|
|
@@ -468,11 +547,13 @@ export class CalendarClient {
|
|
|
468
547
|
|
|
469
548
|
events.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
|
|
470
549
|
|
|
471
|
-
|
|
550
|
+
const result: EventListResult = {
|
|
472
551
|
events: events.slice(0, maxResults),
|
|
473
552
|
nextPageToken: null,
|
|
474
553
|
timezone: tz,
|
|
475
554
|
}
|
|
555
|
+
|
|
556
|
+
return result
|
|
476
557
|
}
|
|
477
558
|
|
|
478
559
|
async getEvent({
|
|
@@ -481,23 +562,28 @@ export class CalendarClient {
|
|
|
481
562
|
}: {
|
|
482
563
|
calendarId?: string
|
|
483
564
|
eventId: string
|
|
484
|
-
}): Promise<CalendarEvent> {
|
|
565
|
+
}): Promise<CalendarEvent | AuthError | ApiError | NotFoundError> {
|
|
485
566
|
const cal = await this.resolveCalendar(calendarId)
|
|
567
|
+
if (cal instanceof Error) return cal
|
|
486
568
|
|
|
487
|
-
const calObjects = await
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
569
|
+
const calObjects = await caldavBoundary(this.email, () =>
|
|
570
|
+
fetchCalendarObjects({
|
|
571
|
+
calendar: cal,
|
|
572
|
+
headers: this.headers,
|
|
573
|
+
urlFilter: () => true,
|
|
574
|
+
}),
|
|
575
|
+
)
|
|
576
|
+
if (calObjects instanceof Error) return calObjects
|
|
492
577
|
|
|
493
578
|
for (const obj of calObjects) {
|
|
494
579
|
if (!obj.data) continue
|
|
495
580
|
const events = parseICalData(obj.data, obj)
|
|
581
|
+
if (events instanceof ParseError) continue // skip unparseable calendar objects
|
|
496
582
|
const match = events.find((e) => e.id === eventId || e.uid === eventId)
|
|
497
583
|
if (match) return match
|
|
498
584
|
}
|
|
499
585
|
|
|
500
|
-
|
|
586
|
+
return new NotFoundError({ resource: `event "${eventId}"` })
|
|
501
587
|
}
|
|
502
588
|
|
|
503
589
|
async createEvent({
|
|
@@ -530,8 +616,9 @@ export class CalendarClient {
|
|
|
530
616
|
colorId?: string
|
|
531
617
|
visibility?: string
|
|
532
618
|
transparency?: string
|
|
533
|
-
}): Promise<CalendarEvent> {
|
|
619
|
+
}): Promise<CalendarEvent | AuthError | ApiError | NotFoundError | ParseError> {
|
|
534
620
|
const cal = await this.resolveCalendar(calendarId)
|
|
621
|
+
if (cal instanceof Error) return cal
|
|
535
622
|
const uid = generateUID()
|
|
536
623
|
|
|
537
624
|
const iCalString = buildICalString({
|
|
@@ -550,16 +637,20 @@ export class CalendarClient {
|
|
|
550
637
|
|
|
551
638
|
const filename = `${uid.split('@')[0]}.ics`
|
|
552
639
|
|
|
553
|
-
await
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
640
|
+
const createResult = await caldavBoundary(this.email, () =>
|
|
641
|
+
createCalendarObject({
|
|
642
|
+
calendar: cal,
|
|
643
|
+
filename,
|
|
644
|
+
iCalString,
|
|
645
|
+
headers: this.headers,
|
|
646
|
+
}),
|
|
647
|
+
)
|
|
648
|
+
if (createResult instanceof Error) return createResult
|
|
559
649
|
|
|
560
650
|
const events = parseICalData(iCalString)
|
|
651
|
+
if (events instanceof ParseError) return events
|
|
561
652
|
const event = events[0]
|
|
562
|
-
if (!event)
|
|
653
|
+
if (!event) return new ParseError({ what: 'created event', reason: 'iCal round-trip produced no events' })
|
|
563
654
|
event.url = `${cal.url}${filename}`
|
|
564
655
|
|
|
565
656
|
return event
|
|
@@ -595,10 +686,11 @@ export class CalendarClient {
|
|
|
595
686
|
colorId?: string
|
|
596
687
|
visibility?: string
|
|
597
688
|
transparency?: string
|
|
598
|
-
}): Promise<CalendarEvent> {
|
|
689
|
+
}): Promise<CalendarEvent | AuthError | ApiError | NotFoundError | MissingDataError | ParseError> {
|
|
599
690
|
const existing = await this.getEvent({ calendarId, eventId })
|
|
691
|
+
if (existing instanceof Error) return existing
|
|
600
692
|
if (!existing.url || !existing.etag) {
|
|
601
|
-
|
|
693
|
+
return new MissingDataError({ what: 'CalDAV URL or etag', resource: `event ${eventId}` })
|
|
602
694
|
}
|
|
603
695
|
|
|
604
696
|
// Handle attendee add/remove
|
|
@@ -635,14 +727,18 @@ export class CalendarClient {
|
|
|
635
727
|
organizer: { email: this.email },
|
|
636
728
|
})
|
|
637
729
|
|
|
638
|
-
await
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
730
|
+
const updateResult = await caldavBoundary(this.email, () =>
|
|
731
|
+
updateCalendarObject({
|
|
732
|
+
calendarObject: { url: existing.url!, data: iCalString, etag: existing.etag },
|
|
733
|
+
headers: this.headers,
|
|
734
|
+
}),
|
|
735
|
+
)
|
|
736
|
+
if (updateResult instanceof Error) return updateResult
|
|
642
737
|
|
|
643
738
|
const events = parseICalData(iCalString)
|
|
739
|
+
if (events instanceof ParseError) return events
|
|
644
740
|
const event = events[0]
|
|
645
|
-
if (!event)
|
|
741
|
+
if (!event) return new ParseError({ what: 'updated event', reason: 'iCal round-trip produced no events' })
|
|
646
742
|
event.url = existing.url
|
|
647
743
|
event.etag = existing.etag
|
|
648
744
|
|
|
@@ -655,16 +751,21 @@ export class CalendarClient {
|
|
|
655
751
|
}: {
|
|
656
752
|
calendarId?: string
|
|
657
753
|
eventId: string
|
|
658
|
-
}): Promise<void> {
|
|
754
|
+
}): Promise<void | AuthError | ApiError | NotFoundError | MissingDataError> {
|
|
659
755
|
const existing = await this.getEvent({ calendarId, eventId })
|
|
756
|
+
if (existing instanceof Error) return existing
|
|
660
757
|
if (!existing.url) {
|
|
661
|
-
|
|
758
|
+
return new MissingDataError({ what: 'CalDAV URL', resource: `event ${eventId}` })
|
|
662
759
|
}
|
|
663
760
|
|
|
664
|
-
await
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
761
|
+
const deleteResult = await caldavBoundary(this.email, () =>
|
|
762
|
+
deleteCalendarObject({
|
|
763
|
+
calendarObject: { url: existing.url!, etag: existing.etag },
|
|
764
|
+
headers: this.headers,
|
|
765
|
+
}),
|
|
766
|
+
)
|
|
767
|
+
if (deleteResult instanceof Error) return deleteResult
|
|
768
|
+
|
|
668
769
|
}
|
|
669
770
|
|
|
670
771
|
async respondToEvent({
|
|
@@ -677,10 +778,11 @@ export class CalendarClient {
|
|
|
677
778
|
eventId: string
|
|
678
779
|
status: 'accepted' | 'declined' | 'tentative'
|
|
679
780
|
comment?: string
|
|
680
|
-
}): Promise<CalendarEvent> {
|
|
781
|
+
}): Promise<CalendarEvent | AuthError | ApiError | NotFoundError | MissingDataError | ParseError> {
|
|
681
782
|
const existing = await this.getEvent({ calendarId, eventId })
|
|
783
|
+
if (existing instanceof Error) return existing
|
|
682
784
|
if (!existing.url || !existing.etag) {
|
|
683
|
-
|
|
785
|
+
return new MissingDataError({ what: 'CalDAV URL or etag', resource: `event ${eventId}` })
|
|
684
786
|
}
|
|
685
787
|
|
|
686
788
|
// Update our PARTSTAT in attendees
|
|
@@ -708,14 +810,18 @@ export class CalendarClient {
|
|
|
708
810
|
: { email: this.email },
|
|
709
811
|
})
|
|
710
812
|
|
|
711
|
-
await
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
813
|
+
const respondResult = await caldavBoundary(this.email, () =>
|
|
814
|
+
updateCalendarObject({
|
|
815
|
+
calendarObject: { url: existing.url!, data: iCalString, etag: existing.etag },
|
|
816
|
+
headers: this.headers,
|
|
817
|
+
}),
|
|
818
|
+
)
|
|
819
|
+
if (respondResult instanceof Error) return respondResult
|
|
715
820
|
|
|
716
821
|
const events = parseICalData(iCalString)
|
|
822
|
+
if (events instanceof ParseError) return events
|
|
717
823
|
const event = events[0]
|
|
718
|
-
if (!event)
|
|
824
|
+
if (!event) return new ParseError({ what: 'response event', reason: 'iCal round-trip produced no events' })
|
|
719
825
|
event.url = existing.url
|
|
720
826
|
|
|
721
827
|
return event
|
|
@@ -733,23 +839,24 @@ export class CalendarClient {
|
|
|
733
839
|
const results: FreeBusyResult[] = []
|
|
734
840
|
|
|
735
841
|
for (const calId of calendarIds) {
|
|
736
|
-
try
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
.filter((e) => e.transparency !== 'transparent' && !e.allDay)
|
|
746
|
-
.map((e) => ({ start: e.start, end: e.end }))
|
|
747
|
-
|
|
748
|
-
results.push({ calendar: calId, busy })
|
|
749
|
-
} catch (err) {
|
|
750
|
-
console.error('[calendar] freebusy failed for', calId, '-', err instanceof Error ? err.stack ?? err.message : err)
|
|
842
|
+
// listEvents returns errors as values — no try/catch needed.
|
|
843
|
+
// Errors (auth, not found) yield an empty busy list for that calendar.
|
|
844
|
+
const eventsResult = await this.listEvents({
|
|
845
|
+
calendarId: calId,
|
|
846
|
+
timeMin,
|
|
847
|
+
timeMax,
|
|
848
|
+
maxResults: 200,
|
|
849
|
+
})
|
|
850
|
+
if (eventsResult instanceof Error) {
|
|
751
851
|
results.push({ calendar: calId, busy: [] })
|
|
852
|
+
continue
|
|
752
853
|
}
|
|
854
|
+
|
|
855
|
+
const busy: FreeBusyBlock[] = eventsResult.events
|
|
856
|
+
.filter((e: CalendarEvent) => e.transparency !== 'transparent' && !e.allDay)
|
|
857
|
+
.map((e: CalendarEvent) => ({ start: e.start, end: e.end }))
|
|
858
|
+
|
|
859
|
+
results.push({ calendar: calId, busy })
|
|
753
860
|
}
|
|
754
861
|
|
|
755
862
|
return results
|
package/src/cli.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { goke } from 'goke'
|
|
8
8
|
import { z } from 'zod'
|
|
9
|
+
import React from 'react'
|
|
9
10
|
import { registerAuthCommands } from './commands/auth-cmd.js'
|
|
10
11
|
import { registerMailCommands } from './commands/mail.js'
|
|
11
12
|
import { registerMailActionCommands } from './commands/mail-actions.js'
|
|
@@ -14,6 +15,7 @@ import { registerLabelCommands } from './commands/label.js'
|
|
|
14
15
|
import { registerAttachmentCommands } from './commands/attachment.js'
|
|
15
16
|
import { registerProfileCommands } from './commands/profile.js'
|
|
16
17
|
import { registerCalendarCommands } from './commands/calendar.js'
|
|
18
|
+
import { registerWatchCommands } from './commands/watch.js'
|
|
17
19
|
|
|
18
20
|
const cli = goke('zele')
|
|
19
21
|
|
|
@@ -26,6 +28,34 @@ cli.option(
|
|
|
26
28
|
z.array(z.string()).describe('Filter by email account (repeatable)'),
|
|
27
29
|
)
|
|
28
30
|
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Default command (TUI)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
cli
|
|
36
|
+
.command('', 'Browse emails in TUI')
|
|
37
|
+
.action(async () => {
|
|
38
|
+
if (typeof (globalThis as { Bun?: unknown }).Bun === 'undefined') {
|
|
39
|
+
const pc = await import('picocolors')
|
|
40
|
+
const isWindows = process.platform === 'win32'
|
|
41
|
+
const installCmd = isWindows
|
|
42
|
+
? 'powershell -c "irm bun.sh/install.ps1 | iex"'
|
|
43
|
+
: 'curl -fsSL https://bun.sh/install | bash'
|
|
44
|
+
console.error(
|
|
45
|
+
pc.default.red('Error: ') +
|
|
46
|
+
'The TUI requires Bun to run.\n\n' +
|
|
47
|
+
'Install Bun:\n' +
|
|
48
|
+
` ${pc.default.cyan(installCmd)}\n\n` +
|
|
49
|
+
'Then run:\n' +
|
|
50
|
+
` ${pc.default.cyan('zele')}`,
|
|
51
|
+
)
|
|
52
|
+
process.exit(1)
|
|
53
|
+
}
|
|
54
|
+
const { renderWithProviders } = await import('termcast')
|
|
55
|
+
const { default: Command } = await import('./mail-tui.js')
|
|
56
|
+
await renderWithProviders(React.createElement(Command))
|
|
57
|
+
})
|
|
58
|
+
|
|
29
59
|
// ---------------------------------------------------------------------------
|
|
30
60
|
// Register all command modules (auth first so login/logout/whoami appear at top of --help)
|
|
31
61
|
// ---------------------------------------------------------------------------
|
|
@@ -38,13 +68,14 @@ registerDraftCommands(cli)
|
|
|
38
68
|
registerLabelCommands(cli)
|
|
39
69
|
registerAttachmentCommands(cli)
|
|
40
70
|
registerCalendarCommands(cli)
|
|
71
|
+
registerWatchCommands(cli)
|
|
41
72
|
|
|
42
73
|
// ---------------------------------------------------------------------------
|
|
43
74
|
// Help & version
|
|
44
75
|
// ---------------------------------------------------------------------------
|
|
45
76
|
|
|
46
77
|
cli.help()
|
|
47
|
-
cli.version('0.
|
|
78
|
+
cli.version('0.3.5')
|
|
48
79
|
|
|
49
80
|
// ---------------------------------------------------------------------------
|
|
50
81
|
// Parse & run
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Attachment commands: list, get (download).
|
|
2
|
-
// Lists attachments for a
|
|
2
|
+
// Lists attachments for a thread and downloads them to disk.
|
|
3
3
|
// Skips re-download if file already exists with same size (like gogcli).
|
|
4
4
|
|
|
5
5
|
import type { Goke } from 'goke'
|
|
@@ -7,8 +7,8 @@ import { z } from 'zod'
|
|
|
7
7
|
import fs from 'node:fs'
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import { getClient } from '../auth.js'
|
|
10
|
-
import { GmailClient } from '../gmail-client.js'
|
|
11
10
|
import * as out from '../output.js'
|
|
11
|
+
import { handleCommandError } from '../output.js'
|
|
12
12
|
|
|
13
13
|
export function registerAttachmentCommands(cli: Goke) {
|
|
14
14
|
// =========================================================================
|
|
@@ -16,33 +16,31 @@ export function registerAttachmentCommands(cli: Goke) {
|
|
|
16
16
|
// =========================================================================
|
|
17
17
|
|
|
18
18
|
cli
|
|
19
|
-
.command('attachment list <
|
|
20
|
-
.action(async (
|
|
19
|
+
.command('attachment list <threadId>', 'List attachments for all messages in a thread')
|
|
20
|
+
.action(async (threadId, options) => {
|
|
21
21
|
const { client } = await getClient(options.account)
|
|
22
22
|
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
23
|
+
const { parsed: thread } = await client.getThread({ threadId })
|
|
24
|
+
const attachments = thread.messages.flatMap((msg) =>
|
|
25
|
+
msg.attachments.map((attachment) => ({
|
|
26
|
+
thread_id: thread.id,
|
|
27
|
+
message_id: msg.id,
|
|
28
|
+
attachment_id: attachment.attachmentId,
|
|
29
|
+
filename: attachment.filename,
|
|
30
|
+
type: attachment.mimeType,
|
|
31
|
+
size: formatSize(attachment.size),
|
|
32
|
+
})),
|
|
33
|
+
)
|
|
30
34
|
|
|
31
35
|
if (attachments.length === 0) {
|
|
32
36
|
out.hint('No attachments')
|
|
33
37
|
return
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
out.printList(
|
|
37
|
-
attachments.map((a) => ({
|
|
38
|
-
attachment_id: a.attachmentId,
|
|
39
|
-
filename: a.filename,
|
|
40
|
-
type: a.mimeType,
|
|
41
|
-
size: formatSize(a.size),
|
|
42
|
-
})),
|
|
43
|
-
)
|
|
40
|
+
out.printList(attachments)
|
|
44
41
|
|
|
45
42
|
out.hint(`${attachments.length} attachment(s)`)
|
|
43
|
+
out.hint('Use: zele attachment get <messageId> <attachmentId>')
|
|
46
44
|
})
|
|
47
45
|
|
|
48
46
|
// =========================================================================
|
|
@@ -58,6 +56,7 @@ export function registerAttachmentCommands(cli: Goke) {
|
|
|
58
56
|
|
|
59
57
|
// Get attachment metadata first
|
|
60
58
|
const msg = await client.getMessage({ messageId })
|
|
59
|
+
if (msg instanceof Error) return handleCommandError(msg)
|
|
61
60
|
if ('raw' in msg) {
|
|
62
61
|
out.error('Cannot get attachments for raw messages')
|
|
63
62
|
process.exit(1)
|