@bobfrankston/gcal 0.1.61 → 0.1.63

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/gcal.ts CHANGED
@@ -12,13 +12,13 @@ import fs from 'fs';
12
12
  import path from 'path';
13
13
  import { execSync } from 'child_process';
14
14
  import { createInterface } from 'readline/promises';
15
- import type { GoogleEvent, EventsListResponse, CalendarListEntry, CalendarListResponse } from './glib/types.ts';
15
+ import type { GoogleEvent, EventDateTime, EventsListResponse, CalendarListEntry, CalendarListResponse } from './glib/types.ts';
16
16
  import {
17
17
  loadConfig, saveConfig,
18
18
  formatDateTime, formatDuration, parseDuration, parseDateTime,
19
19
  hasTimeComponent, parseAllDay, formatYMD, normalizeUser
20
20
  } from './glib/gutils.js';
21
- import { setupAbortHandler, getAccessToken, apiFetch } from './glib/goauth.js';
21
+ import { setupAbortHandler, teardownAbortHandler, getAccessToken, apiFetch } from './glib/goauth.js';
22
22
  import { extractEventsFromText, readClipboard } from './glib/aihelper.js';
23
23
 
24
24
  import pkg from './package.json' with { type: 'json' };
@@ -59,6 +59,38 @@ async function listEvents(
59
59
  return data.items || [];
60
60
  }
61
61
 
62
+ async function listRecurringEvents(
63
+ accessToken: string,
64
+ calendarId = 'primary',
65
+ maxResults = 250
66
+ ): Promise<GoogleEvent[]> {
67
+ // singleEvents=false returns the master records; orderBy=startTime is incompatible
68
+ // so we omit it and sort client-side. Pages until done or maxResults reached.
69
+ const out: GoogleEvent[] = [];
70
+ let pageToken: string | undefined;
71
+ do {
72
+ const params = new URLSearchParams({
73
+ maxResults: '250',
74
+ singleEvents: 'false'
75
+ });
76
+ if (pageToken) params.set('pageToken', pageToken);
77
+ const url = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events?${params}`;
78
+ const res = await apiFetch(url, accessToken);
79
+ if (!res.ok) {
80
+ throw new Error(`Failed to list events: ${res.status} ${res.statusText}`);
81
+ }
82
+ const data = await res.json() as EventsListResponse & { nextPageToken?: string };
83
+ for (const ev of data.items || []) {
84
+ if (ev.recurrence && ev.status !== 'cancelled') {
85
+ out.push(ev);
86
+ if (out.length >= maxResults) return out;
87
+ }
88
+ }
89
+ pageToken = data.nextPageToken;
90
+ } while (pageToken);
91
+ return out;
92
+ }
93
+
62
94
  async function createEvent(
63
95
  accessToken: string,
64
96
  event: GoogleEvent,
@@ -258,12 +290,14 @@ Commands:
258
290
  show Show full details for an event (-json for JSON)
259
291
  open Open event in browser
260
292
  add Add event (explicit, AI, or interactive)
293
+ update | edit | set Change an event's time/title/location/busy/...
261
294
  del | delete Delete event(s) by ID
262
295
  remind Add reminder(s) to existing event
263
296
  resched Reschedule event
264
297
  snooze Snooze event (default: +1d)
265
298
  import Import events from ICS file
266
299
  calendars | listc | list-calendars List available calendars
300
+ listr | list-recurring List recurring event masters (RRULE)
267
301
  assoc Set up .ics file association (Windows)
268
302
  help [command] Show help
269
303
 
@@ -305,20 +339,55 @@ const USAGE: Record<string, string> = {
305
339
  Examples:
306
340
  gcal open abc12345
307
341
  `,
308
- add: `gcal add <title> <when> [duration] Explicit
342
+ add: `gcal add <title> <when> [duration] Explicit (timed)
343
+ gcal add <title> <date> [days] -allday Explicit (all-day)
309
344
  gcal add "<free text>" AI-parsed single arg
310
345
  gcal add -clip AI-parsed from clipboard
311
346
  gcal add Interactive (type description)
312
347
  Add a calendar event. Default duration 1h. Use -r <dur> to add reminder(s).
313
348
 
349
+ -allday Create an all-day event. The third arg is a day count
350
+ (default 1); the event spans that many days.
351
+ -free Mark the event as Free (does not block time / not busy).
352
+ -busy Mark the event as Busy (the default).
353
+ -open Open the event in the browser after creating it.
354
+
314
355
  Examples:
315
356
  gcal add "Dentist" "Friday 3pm" "1h"
316
357
  gcal add "Lunch" "1/14/2026 12:00" "1h"
317
358
  gcal add "Meeting" "tomorrow 10:00"
318
359
  gcal add "Appointment" "jan 15 2pm"
360
+ gcal add "Vacation" "jul 1" -allday (1 day, all-day)
361
+ gcal add "Conference" "jul 1" 3 -allday (3-day all-day event)
362
+ gcal add "Out of office" "jul 1" -allday -free (all-day, not busy)
319
363
  gcal add "Dentist appointment Friday 3pm for 1 hour"
320
364
  gcal add -clip
321
365
  gcal add "Dentist" "Friday 3pm" -r 30m
366
+ `,
367
+ update: `gcal update <id> [options]
368
+ gcal edit <id> ... (aliases: edit, set)
369
+ Change settings on an existing event. Only the options you give are
370
+ changed. Searches up to 30 days back; widen with -since.
371
+
372
+ -when <when> New start time/date (accepts +Nd / +Nh advances too).
373
+ -dur <dur> New duration ("1h30m"); for all-day events, a day count.
374
+ -title <text> New event title.
375
+ -loc <text> New location ("" to clear).
376
+ -note <text> New description ("" to clear).
377
+ -free Mark as Free (not busy).
378
+ -busy Mark as Busy.
379
+ -r <dur> Add a popup reminder (repeatable).
380
+ -rx <dur> Remove a reminder matching that duration (repeatable).
381
+ -nr Remove all reminders.
382
+ -open Open the event in the browser afterward.
383
+
384
+ Examples:
385
+ gcal update abc12345 -free
386
+ gcal update abc12345 -busy -title "Team sync"
387
+ gcal update abc12345 -when "wed 2pm" -dur 90m
388
+ gcal update abc12345 -r 30m -r 1h (add two reminders)
389
+ gcal update abc12345 -rx 30m (remove the 30m reminder)
390
+ gcal update abc12345 -nr (clear all reminders)
322
391
  `,
323
392
  del: `gcal del <id> [id2...] [-all] [-b]
324
393
  gcal delete <id> [id2...]
@@ -360,6 +429,12 @@ const USAGE: Record<string, string> = {
360
429
  `,
361
430
  calendars: `gcal calendars (aliases: listc, list-calendars)
362
431
  List available calendars (id, name, access role).
432
+ `,
433
+ listr: `gcal listr (alias: list-recurring)
434
+ List recurring event masters in the current calendar, showing the
435
+ RRULE for each. Use this to confirm whether something like a daily
436
+ "Statin" reminder actually exists as a recurring event in Google
437
+ Calendar (vs. being defined only in another tool's local store).
363
438
  `,
364
439
  assoc: `gcal assoc
365
440
  Set up Windows .ics file association so double-clicking imports to gcal.
@@ -371,7 +446,10 @@ const USAGE: Record<string, string> = {
371
446
 
372
447
  const HELP_ALIASES: Record<string, string> = {
373
448
  'listc': 'calendars',
374
- 'list-calendars': 'calendars'
449
+ 'list-calendars': 'calendars',
450
+ 'list-recurring': 'listr',
451
+ 'set': 'update',
452
+ 'edit': 'update'
375
453
  };
376
454
 
377
455
  function showUsage(cmd?: string): void {
@@ -399,7 +477,18 @@ interface ParsedArgs {
399
477
  clip: boolean;
400
478
  all: boolean;
401
479
  json: boolean;
480
+ allDay: boolean;
481
+ transparency: string; /** 'opaque' (busy) | 'transparent' (free); '' = unset */
482
+ setTitle?: string; /** update: new summary (undefined = leave alone) */
483
+ setLoc?: string; /** update: new location */
484
+ setNote?: string; /** update: new description */
485
+ setWhen?: string; /** update: new start (date/time or +Nd advance) */
486
+ setDur?: string; /** update: new duration (timed) or day count (all-day) */
487
+ removeReminders: number[]; /** update: reminder minutes to drop */
488
+ clearReminders: boolean; /** update: drop all reminders */
489
+ open: boolean; /** open the event in the browser after add/update */
402
490
  reminders: number[];
491
+ rrule: string; /** RRULE body, e.g. "FREQ=DAILY". RRULE: prefix added automatically. */
403
492
  since?: Date;
404
493
  till?: Date;
405
494
  helpCmd: string; /** `gcal help <cmd>` */
@@ -419,7 +508,13 @@ function parseArgs(argv: string[]): ParsedArgs {
419
508
  clip: false,
420
509
  all: false,
421
510
  json: false,
511
+ allDay: false,
512
+ transparency: '',
513
+ removeReminders: [],
514
+ clearReminders: false,
515
+ open: false,
422
516
  reminders: [],
517
+ rrule: '',
423
518
  helpCmd: ''
424
519
  };
425
520
 
@@ -464,6 +559,57 @@ function parseArgs(argv: string[]): ParsedArgs {
464
559
  case '--json':
465
560
  result.json = true;
466
561
  break;
562
+ case '-allday':
563
+ case '-all-day':
564
+ case '--allday':
565
+ result.allDay = true;
566
+ break;
567
+ case '-free':
568
+ case '--free':
569
+ result.transparency = 'transparent';
570
+ break;
571
+ case '-busy':
572
+ case '--busy':
573
+ result.transparency = 'opaque';
574
+ break;
575
+ case '-title':
576
+ case '--title':
577
+ result.setTitle = argv[++i] || '';
578
+ break;
579
+ case '-loc':
580
+ case '-location':
581
+ case '--location':
582
+ result.setLoc = argv[++i] || '';
583
+ break;
584
+ case '-note':
585
+ case '-desc':
586
+ case '-description':
587
+ case '--note':
588
+ result.setNote = argv[++i] || '';
589
+ break;
590
+ case '-when':
591
+ case '--when':
592
+ result.setWhen = argv[++i] || '';
593
+ break;
594
+ case '-dur':
595
+ case '-duration':
596
+ case '--duration':
597
+ result.setDur = argv[++i] || '';
598
+ break;
599
+ case '-rx':
600
+ case '-rmr':
601
+ case '--remove-reminder':
602
+ result.removeReminders.push(parseDuration(argv[++i] || ''));
603
+ break;
604
+ case '-nr':
605
+ case '-noreminders':
606
+ case '--no-reminders':
607
+ result.clearReminders = true;
608
+ break;
609
+ case '-open':
610
+ case '--open':
611
+ result.open = true;
612
+ break;
467
613
  case '-r':
468
614
  case '-reminder':
469
615
  case '--reminder': {
@@ -472,6 +618,14 @@ function parseArgs(argv: string[]): ParsedArgs {
472
618
  result.reminders.push(mins);
473
619
  break;
474
620
  }
621
+ case '-rule':
622
+ case '-rrule':
623
+ case '--rrule': {
624
+ let v = argv[++i] || '';
625
+ if (v.toUpperCase().startsWith('RRULE:')) v = v.slice(6);
626
+ result.rrule = v;
627
+ break;
628
+ }
475
629
  case '-since':
476
630
  case '--since': {
477
631
  const val = argv[++i] || '';
@@ -542,6 +696,102 @@ function buildReminders(minutes: number[]): GoogleEvent['reminders'] | undefined
542
696
  };
543
697
  }
544
698
 
699
+ /** Compute a start/end patch for moving and/or resizing an event.
700
+ * whenArg undefined => keep original start; durationArg undefined => keep original length.
701
+ * For all-day events durationArg is a day count; for timed events a duration string. */
702
+ function reschedulePatch(
703
+ event: GoogleEvent,
704
+ whenArg: string | undefined,
705
+ durationArg: string | undefined
706
+ ): { patch: Partial<GoogleEvent>; startDisplay: EventDateTime; endDisplay: EventDateTime } {
707
+ const origIsAllDay = !!event.start?.date;
708
+
709
+ if (origIsAllDay) {
710
+ const origStart = parseAllDay(event.start!.date!);
711
+ const origEnd = parseAllDay(event.end!.date!);
712
+ const origDurDays = Math.max(1, Math.round((origEnd.getTime() - origStart.getTime()) / 86400_000));
713
+
714
+ let newStart: Date;
715
+ if (whenArg) {
716
+ const adv = whenArg.match(/^\+(\d+)([dw])$/i);
717
+ if (adv) {
718
+ const [, n, unit] = adv;
719
+ const amt = parseInt(n);
720
+ newStart = new Date(origStart);
721
+ newStart.setDate(newStart.getDate() + (unit.toLowerCase() === 'w' ? amt * 7 : amt));
722
+ } else {
723
+ newStart = parseDateTime(whenArg);
724
+ newStart.setHours(0, 0, 0, 0);
725
+ }
726
+ } else {
727
+ newStart = new Date(origStart);
728
+ }
729
+ const durDays = durationArg ? (parseInt(durationArg, 10) || origDurDays) : origDurDays;
730
+ const newEnd = new Date(newStart);
731
+ newEnd.setDate(newEnd.getDate() + durDays);
732
+
733
+ const patch = { start: { date: formatYMD(newStart) }, end: { date: formatYMD(newEnd) } };
734
+ return { patch, startDisplay: patch.start, endDisplay: patch.end };
735
+ }
736
+
737
+ const origStart = new Date(event.start!.dateTime!);
738
+ const origEnd = new Date(event.end!.dateTime!);
739
+ const origDurMs = origEnd.getTime() - origStart.getTime();
740
+
741
+ let newStart: Date;
742
+ if (whenArg) {
743
+ const adv = whenArg.match(/^\+(\d+)([dwhm])$/i);
744
+ if (adv) {
745
+ const [, n, unit] = adv;
746
+ const amt = parseInt(n);
747
+ newStart = new Date(origStart);
748
+ switch (unit.toLowerCase()) {
749
+ case 'd': newStart.setDate(newStart.getDate() + amt); break;
750
+ case 'w': newStart.setDate(newStart.getDate() + amt * 7); break;
751
+ case 'h': newStart.setHours(newStart.getHours() + amt); break;
752
+ case 'm': newStart.setMinutes(newStart.getMinutes() + amt); break;
753
+ }
754
+ } else {
755
+ newStart = parseDateTime(whenArg);
756
+ if (!hasTimeComponent(whenArg)) {
757
+ newStart.setHours(origStart.getHours(), origStart.getMinutes(), 0, 0);
758
+ }
759
+ }
760
+ } else {
761
+ newStart = new Date(origStart);
762
+ }
763
+ const durMs = durationArg ? parseDuration(durationArg) * 60_000 : origDurMs;
764
+ const newEnd = new Date(newStart.getTime() + durMs);
765
+
766
+ const tz = event.start!.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
767
+ const patch = {
768
+ start: { dateTime: newStart.toISOString(), timeZone: tz },
769
+ end: { dateTime: newEnd.toISOString(), timeZone: tz }
770
+ };
771
+ return { patch, startDisplay: patch.start, endDisplay: patch.end };
772
+ }
773
+
774
+ /** Format a reminder override list for display, e.g. "10m, 1h" or "(none)". */
775
+ function formatReminders(overrides?: { minutes?: number }[]): string {
776
+ if (!overrides || overrides.length === 0) return '(none)';
777
+ return overrides
778
+ .map(r => r.minutes ?? 0)
779
+ .sort((a, b) => a - b)
780
+ .map(m => (m >= 60 && m % 60 === 0 ? `${m / 60}h` : `${m}m`))
781
+ .join(', ');
782
+ }
783
+
784
+ /** Open a URL in the platform's default browser. */
785
+ function openUrl(url: string): void {
786
+ if (process.platform === 'win32') {
787
+ execSync(`start "" "${url}"`, { stdio: 'ignore', shell: 'cmd.exe' });
788
+ } else if (process.platform === 'darwin') {
789
+ execSync(`open "${url}"`, { stdio: 'ignore' });
790
+ } else {
791
+ execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
792
+ }
793
+ }
794
+
545
795
  /** Match events by ID prefix and dedup recurring instances to the earliest.
546
796
  * `events` must be ordered by startTime (as returned by listEvents). */
547
797
  function findByPrefix(events: GoogleEvent[], prefix: string, includeBirthdays: boolean): GoogleEvent[] {
@@ -677,6 +927,30 @@ async function main(): Promise<void> {
677
927
  process.exit(1);
678
928
  }
679
929
 
930
+ // Resolve partial calendar names against the user's calendar list.
931
+ // 'primary' and full IDs (containing '@') pass through unchanged.
932
+ if (parsed.calendar !== 'primary' && !parsed.calendar.includes('@')) {
933
+ const token = await getAccessToken(user, false);
934
+ const cals = await listCalendars(token);
935
+ const q = parsed.calendar.toLowerCase();
936
+ const exact = cals.filter(c => (c.summary || '').toLowerCase() === q);
937
+ const matches = exact.length > 0 ? exact : cals.filter(c =>
938
+ (c.summary || '').toLowerCase().includes(q) ||
939
+ (c.id || '').toLowerCase().includes(q)
940
+ );
941
+ if (matches.length === 0) {
942
+ console.error(`No calendar matches "${parsed.calendar}". Available:`);
943
+ for (const c of cals) console.error(` ${c.summary} -- ${c.id}`);
944
+ process.exit(1);
945
+ }
946
+ if (matches.length > 1) {
947
+ console.error(`Ambiguous calendar "${parsed.calendar}":`);
948
+ for (const c of matches) console.error(` ${c.summary} -- ${c.id}`);
949
+ process.exit(1);
950
+ }
951
+ parsed.calendar = matches[0].id!;
952
+ }
953
+
680
954
  switch (parsed.command) {
681
955
  case 'import': {
682
956
  const filePath = parsed.icsFile || parsed.args[0];
@@ -717,7 +991,21 @@ async function main(): Promise<void> {
717
991
  const token = await getAccessToken(user, false);
718
992
  const timeMin = parsed.since ? parsed.since.toISOString() : undefined;
719
993
  const timeMax = parsed.till ? parsed.till.toISOString() : undefined;
720
- let events = await listEvents(token, parsed.calendar, count, timeMin, timeMax);
994
+ // Oversample when collapsing recurring series so we still get
995
+ // <count> distinct items after dedup.
996
+ const fetchCount = parsed.all ? count : Math.max(count * 5, 50);
997
+ let events = await listEvents(token, parsed.calendar, fetchCount, timeMin, timeMax);
998
+ if (!parsed.all) {
999
+ const seen = new Set<string>();
1000
+ events = events.filter(e => {
1001
+ const seriesKey = e.recurringEventId;
1002
+ if (!seriesKey) return true;
1003
+ if (seen.has(seriesKey)) return false;
1004
+ seen.add(seriesKey);
1005
+ return true;
1006
+ });
1007
+ events = events.slice(0, count);
1008
+ }
721
1009
  const birthdayCount = events.filter(e => e.eventType === 'birthday').length;
722
1010
  if (!parsed.birthdays) {
723
1011
  events = events.filter(e => e.eventType !== 'birthday');
@@ -737,7 +1025,9 @@ async function main(): Promise<void> {
737
1025
  const shortId = (event.id || '').slice(0, 8);
738
1026
  const start = event.start ? formatDateTime(event.start) : '?';
739
1027
  const duration = (event.start && event.end) ? formatDuration(event.start, event.end) : '';
740
- const summary = (event.summary || '(no title)') + (event.eventType === 'birthday' ? ' [from contact]' : '');
1028
+ const summary = (event.summary || '(no title)')
1029
+ + (event.eventType === 'birthday' ? ' [from contact]' : '')
1030
+ + (event.recurringEventId ? ' [recurring]' : '');
741
1031
  const loc = event.location || '';
742
1032
  if (parsed.verbose) {
743
1033
  rows.push([shortId, start, duration, summary, loc, event.htmlLink || '']);
@@ -773,32 +1063,49 @@ async function main(): Promise<void> {
773
1063
  case 'add': {
774
1064
  // Explicit mode: gcal add "title" "when" [duration]
775
1065
  if (parsed.args.length >= 2 && !parsed.clip) {
776
- const [title, when, duration = '1h'] = parsed.args;
1066
+ const [title, when, third] = parsed.args;
777
1067
  const startTime = parseDateTime(when);
778
- const durationMins = parseDuration(duration);
779
- const endTime = new Date(startTime.getTime() + durationMins * 60 * 1000);
780
1068
 
781
1069
  const event: GoogleEvent = {
782
1070
  summary: title,
783
- start: {
784
- dateTime: startTime.toISOString(),
785
- timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
786
- },
787
- end: {
788
- dateTime: endTime.toISOString(),
789
- timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
790
- },
1071
+ start: {},
1072
+ end: {},
791
1073
  reminders: buildReminders(parsed.reminders)
792
1074
  };
793
1075
 
1076
+ if (parsed.allDay) {
1077
+ // All-day: [duration] is a day count (default 1). Google's
1078
+ // end.date is exclusive, so a 1-day event ends the next day.
1079
+ const days = third ? (parseInt(third, 10) || 1) : 1;
1080
+ const startD = new Date(startTime);
1081
+ startD.setHours(0, 0, 0, 0);
1082
+ const endD = new Date(startD);
1083
+ endD.setDate(endD.getDate() + days);
1084
+ event.start = { date: formatYMD(startD) };
1085
+ event.end = { date: formatYMD(endD) };
1086
+ } else {
1087
+ const durationMins = parseDuration(third || '1h');
1088
+ const endTime = new Date(startTime.getTime() + durationMins * 60 * 1000);
1089
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
1090
+ event.start = { dateTime: startTime.toISOString(), timeZone: tz };
1091
+ event.end = { dateTime: endTime.toISOString(), timeZone: tz };
1092
+ }
1093
+ if (parsed.transparency) event.transparency = parsed.transparency;
1094
+ if (parsed.rrule) event.recurrence = [`RRULE:${parsed.rrule}`];
1095
+
794
1096
  const token = await getAccessToken(user, true);
795
- await checkProximity(token, parsed.calendar, startTime, endTime);
1097
+ if (!parsed.allDay) {
1098
+ await checkProximity(token, parsed.calendar,
1099
+ new Date(event.start.dateTime!), new Date(event.end.dateTime!));
1100
+ }
796
1101
  const created = await createEvent(token, event, parsed.calendar);
797
1102
  console.log(`\nEvent created: ${created.summary}`);
798
1103
  console.log(` When: ${formatDateTime(created.start)} - ${formatDateTime(created.end)}`);
1104
+ if (created.transparency === 'transparent') console.log(` Free (not busy)`);
799
1105
  if (created.htmlLink) {
800
1106
  console.log(` Link: ${created.htmlLink}`);
801
1107
  }
1108
+ if (parsed.open && created.htmlLink) openUrl(created.htmlLink);
802
1109
  break;
803
1110
  }
804
1111
 
@@ -858,6 +1165,7 @@ async function main(): Promise<void> {
858
1165
  description: extracted.description,
859
1166
  reminders: buildReminders(parsed.reminders)
860
1167
  };
1168
+ if (parsed.transparency) event.transparency = parsed.transparency;
861
1169
  events.push(event);
862
1170
 
863
1171
  console.log(`\n Event: ${extracted.summary}`);
@@ -877,13 +1185,17 @@ async function main(): Promise<void> {
877
1185
  ? '\nCreate this event? [Y/n] (auto-yes in 60s) '
878
1186
  : `\nCreate ${events.length} events? [Y/n] (auto-yes in 60s) `;
879
1187
  const rl2 = createInterface({ input: process.stdin, output: process.stdout });
1188
+ let timeoutId: NodeJS.Timeout | undefined;
880
1189
  const confirm = await Promise.race([
881
1190
  rl2.question(prompt).then(s => s.trim().toLowerCase()),
882
- new Promise<string>(resolve => setTimeout(() => {
883
- console.log('\nNo response creating event(s).');
884
- resolve('');
885
- }, 60_000))
1191
+ new Promise<string>(resolve => {
1192
+ timeoutId = setTimeout(() => {
1193
+ console.log('\nNo response — creating event(s).');
1194
+ resolve('');
1195
+ }, 60_000);
1196
+ })
886
1197
  ]);
1198
+ if (timeoutId) clearTimeout(timeoutId);
887
1199
  rl2.close();
888
1200
  if (confirm && confirm !== 'y' && confirm !== 'yes') {
889
1201
  console.log('Cancelled.');
@@ -897,6 +1209,7 @@ async function main(): Promise<void> {
897
1209
  if (created.htmlLink) {
898
1210
  console.log(` Link: ${created.htmlLink}`);
899
1211
  }
1212
+ if (parsed.open && created.htmlLink) openUrl(created.htmlLink);
900
1213
  }
901
1214
  break;
902
1215
  }
@@ -957,6 +1270,38 @@ async function main(): Promise<void> {
957
1270
  break;
958
1271
  }
959
1272
 
1273
+ case 'listr':
1274
+ case 'list-recurring': {
1275
+ const token = await getAccessToken(user, false);
1276
+ const events = await listRecurringEvents(token, parsed.calendar, parsed.count || 250);
1277
+
1278
+ if (events.length === 0) {
1279
+ console.log('No recurring events found.');
1280
+ break;
1281
+ }
1282
+ console.log(`\nRecurring events (${events.length}):\n`);
1283
+ const rows: string[][] = [];
1284
+ for (const ev of events) {
1285
+ const shortId = (ev.id || '').slice(0, 8);
1286
+ const start = ev.start ? formatDateTime(ev.start) : '';
1287
+ const summary = ev.summary || '(no title)';
1288
+ const rule = (ev.recurrence || []).join('; ');
1289
+ rows.push([shortId, start, summary, rule]);
1290
+ }
1291
+ const headers = ['ID', 'Starts', 'Event', 'Recurrence'];
1292
+ const colWidths = headers.map((h, i) =>
1293
+ Math.max(h.length, ...rows.map(r => (r[i] || '').length))
1294
+ );
1295
+ const lastIdx = headers.length - 1;
1296
+ const padCell = (s: string, i: number) => i === lastIdx ? s : s.padEnd(colWidths[i]);
1297
+ console.log(headers.map(padCell).join(' '));
1298
+ console.log(colWidths.map(w => '-'.repeat(w)).join(' '));
1299
+ for (const row of rows) {
1300
+ console.log(row.map((cell, i) => padCell(cell || '', i)).join(' '));
1301
+ }
1302
+ break;
1303
+ }
1304
+
960
1305
  case 'remind': {
961
1306
  if (parsed.args.length < 2) {
962
1307
  console.error('Usage: gcal remind <id> <duration> [duration2...]');
@@ -1001,6 +1346,111 @@ async function main(): Promise<void> {
1001
1346
  break;
1002
1347
  }
1003
1348
 
1349
+ case 'update':
1350
+ case 'edit':
1351
+ case 'set': {
1352
+ if (parsed.args.length < 1) {
1353
+ console.error('Usage: gcal update <id> [-when <when>] [-dur <dur>] [-title <text>]');
1354
+ console.error(' [-loc <text>] [-note <text>] [-free|-busy]');
1355
+ console.error(' [-r <dur>] [-rx <dur>] [-nr] [-open]');
1356
+ console.error('Use "gcal list" to see event IDs');
1357
+ process.exit(1);
1358
+ }
1359
+
1360
+ const patch: Partial<GoogleEvent> = {};
1361
+ if (parsed.setTitle !== undefined) patch.summary = parsed.setTitle;
1362
+ if (parsed.setLoc !== undefined) patch.location = parsed.setLoc;
1363
+ if (parsed.setNote !== undefined) patch.description = parsed.setNote;
1364
+ if (parsed.transparency) patch.transparency = parsed.transparency;
1365
+
1366
+ const changingTime = parsed.setWhen !== undefined || parsed.setDur !== undefined;
1367
+ const changingReminders = parsed.clearReminders
1368
+ || parsed.reminders.length > 0 || parsed.removeReminders.length > 0;
1369
+
1370
+ if (Object.keys(patch).length === 0 && !changingTime && !changingReminders) {
1371
+ console.error('Nothing to change. Specify -when, -dur, -title, -loc, -note,');
1372
+ console.error(' -free, -busy, -r (add reminder), -rx (remove reminder), or -nr.');
1373
+ process.exit(1);
1374
+ }
1375
+
1376
+ const idPrefix = parsed.args[0];
1377
+ const lookback = parsed.since
1378
+ ? parsed.since.toISOString()
1379
+ : new Date(Date.now() - 30 * 86400_000).toISOString();
1380
+ const timeMax = parsed.till ? parsed.till.toISOString() : undefined;
1381
+
1382
+ const token = await getAccessToken(user, true);
1383
+ const events = await listEvents(token, parsed.calendar, 250, lookback, timeMax);
1384
+ const unique = findByPrefix(events, idPrefix, parsed.birthdays);
1385
+
1386
+ if (unique.length === 0) {
1387
+ console.error(`${idPrefix}: not found (searched from ${lookback.slice(0, 10)})`);
1388
+ process.exit(1);
1389
+ }
1390
+ if (unique.length > 1) {
1391
+ console.error(`${idPrefix}: ambiguous (${unique.length} matches)`);
1392
+ for (const e of unique) {
1393
+ console.error(` ${e.id?.slice(0, 8)} - ${e.summary}`);
1394
+ }
1395
+ process.exit(1);
1396
+ }
1397
+
1398
+ const event = unique[0];
1399
+
1400
+ // Time / duration change (reuses the resched logic)
1401
+ let timeFrom = '';
1402
+ let timeTo = '';
1403
+ if (changingTime) {
1404
+ const r = reschedulePatch(event, parsed.setWhen, parsed.setDur);
1405
+ patch.start = r.patch.start;
1406
+ patch.end = r.patch.end;
1407
+ timeFrom = `${formatDateTime(event.start!)} - ${formatDateTime(event.end!)}`;
1408
+ timeTo = `${formatDateTime(r.startDisplay)} - ${formatDateTime(r.endDisplay)}`;
1409
+ }
1410
+
1411
+ // Reminders: -nr clears all; otherwise merge existing with -r adds and -rx removes
1412
+ if (parsed.clearReminders) {
1413
+ patch.reminders = { useDefault: false, overrides: [] };
1414
+ } else if (changingReminders) {
1415
+ const mins = new Set<number>((event.reminders?.overrides ?? []).map(r => r.minutes ?? 0));
1416
+ for (const m of parsed.removeReminders) mins.delete(m);
1417
+ for (const m of parsed.reminders) mins.add(m);
1418
+ patch.reminders = {
1419
+ useDefault: false,
1420
+ overrides: [...mins].sort((a, b) => a - b).map(m => ({ method: 'popup' as const, minutes: m }))
1421
+ };
1422
+ }
1423
+
1424
+ // Proximity check when the timed slot moved
1425
+ if (patch.start?.dateTime && patch.end?.dateTime) {
1426
+ await checkProximity(
1427
+ token,
1428
+ parsed.calendar,
1429
+ new Date(patch.start.dateTime),
1430
+ new Date(patch.end.dateTime),
1431
+ (event.id || '').split('_')[0]
1432
+ );
1433
+ }
1434
+
1435
+ const updated = await patchEvent(token, event.id!, patch, parsed.calendar);
1436
+ console.log(`Updated: ${updated.summary}`);
1437
+ if (patch.summary !== undefined) console.log(` Title: ${updated.summary}`);
1438
+ if (changingTime) {
1439
+ console.log(` When: ${timeFrom}`);
1440
+ console.log(` -> ${timeTo}`);
1441
+ }
1442
+ if (patch.location !== undefined) console.log(` Where: ${updated.location || '(cleared)'}`);
1443
+ if (patch.description !== undefined) console.log(` Note: ${updated.description || '(cleared)'}`);
1444
+ if (patch.transparency !== undefined) {
1445
+ console.log(` Shows as: ${updated.transparency === 'transparent' ? 'free' : 'busy'}`);
1446
+ }
1447
+ if (patch.reminders !== undefined) {
1448
+ console.log(` Reminders: ${formatReminders(updated.reminders?.overrides)}`);
1449
+ }
1450
+ if (parsed.open && updated.htmlLink) openUrl(updated.htmlLink);
1451
+ break;
1452
+ }
1453
+
1004
1454
  case 'resched':
1005
1455
  case 'reschedule':
1006
1456
  case 'snooze': {
@@ -1050,73 +1500,10 @@ async function main(): Promise<void> {
1050
1500
  }
1051
1501
  }
1052
1502
 
1053
- const origIsAllDay = !!event.start?.date;
1054
- let patch: Partial<GoogleEvent>;
1055
- let newStartDisplay: { date?: string; dateTime?: string; timeZone?: string };
1056
- let newEndDisplay: { date?: string; dateTime?: string; timeZone?: string };
1057
-
1058
- if (origIsAllDay) {
1059
- const origStart = parseAllDay(event.start!.date!);
1060
- const origEnd = parseAllDay(event.end!.date!);
1061
- const origDurDays = Math.max(1, Math.round((origEnd.getTime() - origStart.getTime()) / 86400_000));
1062
-
1063
- let newStart: Date;
1064
- const adv = whenArg.match(/^\+(\d+)([dw])$/i);
1065
- if (adv) {
1066
- const [, n, unit] = adv;
1067
- const amt = parseInt(n);
1068
- newStart = new Date(origStart);
1069
- newStart.setDate(newStart.getDate() + (unit.toLowerCase() === 'w' ? amt * 7 : amt));
1070
- } else {
1071
- newStart = parseDateTime(whenArg);
1072
- newStart.setHours(0, 0, 0, 0);
1073
- }
1074
- const newEnd = new Date(newStart);
1075
- newEnd.setDate(newEnd.getDate() + origDurDays);
1076
-
1077
- patch = {
1078
- start: { date: formatYMD(newStart) },
1079
- end: { date: formatYMD(newEnd) }
1080
- };
1081
- newStartDisplay = { date: formatYMD(newStart) };
1082
- newEndDisplay = { date: formatYMD(newEnd) };
1083
- } else {
1084
- const origStart = new Date(event.start!.dateTime!);
1085
- const origEnd = new Date(event.end!.dateTime!);
1086
- const origDurMs = origEnd.getTime() - origStart.getTime();
1087
-
1088
- let newStart: Date;
1089
- const adv = whenArg.match(/^\+(\d+)([dwhm])$/i);
1090
- if (adv) {
1091
- const [, n, unit] = adv;
1092
- const amt = parseInt(n);
1093
- newStart = new Date(origStart);
1094
- switch (unit.toLowerCase()) {
1095
- case 'd': newStart.setDate(newStart.getDate() + amt); break;
1096
- case 'w': newStart.setDate(newStart.getDate() + amt * 7); break;
1097
- case 'h': newStart.setHours(newStart.getHours() + amt); break;
1098
- case 'm': newStart.setMinutes(newStart.getMinutes() + amt); break;
1099
- }
1100
- } else {
1101
- newStart = parseDateTime(whenArg);
1102
- if (!hasTimeComponent(whenArg)) {
1103
- newStart.setHours(origStart.getHours(), origStart.getMinutes(), 0, 0);
1104
- }
1105
- }
1106
- const durMs = durationArg ? parseDuration(durationArg) * 60_000 : origDurMs;
1107
- const newEnd = new Date(newStart.getTime() + durMs);
1108
-
1109
- const tz = event.start!.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
1110
- patch = {
1111
- start: { dateTime: newStart.toISOString(), timeZone: tz },
1112
- end: { dateTime: newEnd.toISOString(), timeZone: tz }
1113
- };
1114
- newStartDisplay = patch.start!;
1115
- newEndDisplay = patch.end!;
1116
- }
1503
+ const { patch, startDisplay, endDisplay } = reschedulePatch(event, whenArg, durationArg);
1117
1504
 
1118
1505
  // Proximity check for timed events (skip all-day)
1119
- if (!origIsAllDay && patch.start?.dateTime && patch.end?.dateTime) {
1506
+ if (patch.start?.dateTime && patch.end?.dateTime) {
1120
1507
  await checkProximity(
1121
1508
  token,
1122
1509
  parsed.calendar,
@@ -1129,7 +1516,8 @@ async function main(): Promise<void> {
1129
1516
  const updated = await patchEvent(token, event.id!, patch, parsed.calendar);
1130
1517
  console.log(`Rescheduled: ${updated.summary}`);
1131
1518
  console.log(` From: ${formatDateTime(event.start!)} - ${formatDateTime(event.end!)}`);
1132
- console.log(` To: ${formatDateTime(newStartDisplay)} - ${formatDateTime(newEndDisplay)}`);
1519
+ console.log(` To: ${formatDateTime(startDisplay)} - ${formatDateTime(endDisplay)}`);
1520
+ if (parsed.open && updated.htmlLink) openUrl(updated.htmlLink);
1133
1521
  break;
1134
1522
  }
1135
1523
 
@@ -1217,6 +1605,7 @@ async function main(): Promise<void> {
1217
1605
  if (event.hangoutLink) console.log(` Meet: ${event.hangoutLink}`);
1218
1606
  if (event.htmlLink) console.log(` Link: ${event.htmlLink}`);
1219
1607
  console.log(` Status: ${event.status || 'confirmed'}`);
1608
+ console.log(` Shows as: ${event.transparency === 'transparent' ? 'free' : 'busy'}`);
1220
1609
  if (event.created) console.log(` Created: ${formatDateTime({ dateTime: event.created })}`);
1221
1610
  if (event.updated) console.log(` Updated: ${formatDateTime({ dateTime: event.updated })}`);
1222
1611
  console.log(` ID: ${event.id}`);
@@ -1262,13 +1651,7 @@ async function main(): Promise<void> {
1262
1651
  console.log(`Opening: ${event.summary || '(no title)'}`);
1263
1652
  console.log(` ${event.htmlLink}`);
1264
1653
 
1265
- if (process.platform === 'win32') {
1266
- execSync(`start "" "${event.htmlLink}"`, { stdio: 'ignore', shell: 'cmd.exe' });
1267
- } else if (process.platform === 'darwin') {
1268
- execSync(`open "${event.htmlLink}"`, { stdio: 'ignore' });
1269
- } else {
1270
- execSync(`xdg-open "${event.htmlLink}"`, { stdio: 'ignore' });
1271
- }
1654
+ openUrl(event.htmlLink);
1272
1655
  break;
1273
1656
  }
1274
1657
 
@@ -1281,9 +1664,15 @@ async function main(): Promise<void> {
1281
1664
 
1282
1665
  if (import.meta.main) {
1283
1666
  main()
1284
- .then(() => process.exit(0))
1285
- .catch(e => {
1667
+ .then(async () => {
1668
+ await teardownAbortHandler();
1669
+ // Don't call process.exit explicitly — Node 25 on Windows asserts
1670
+ // in libuv (UV_HANDLE_CLOSING) when process.exit races with handle
1671
+ // teardown. Let the event loop drain naturally instead.
1672
+ })
1673
+ .catch(async e => {
1674
+ await teardownAbortHandler();
1286
1675
  console.error(`Error: ${e.message}`);
1287
- process.exit(1);
1676
+ process.exitCode = 1;
1288
1677
  });
1289
1678
  }