@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.js CHANGED
@@ -12,7 +12,7 @@ import path from 'path';
12
12
  import { execSync } from 'child_process';
13
13
  import { createInterface } from 'readline/promises';
14
14
  import { loadConfig, saveConfig, formatDateTime, formatDuration, parseDuration, parseDateTime, hasTimeComponent, parseAllDay, formatYMD, normalizeUser } from './glib/gutils.js';
15
- import { setupAbortHandler, getAccessToken, apiFetch } from './glib/goauth.js';
15
+ import { setupAbortHandler, teardownAbortHandler, getAccessToken, apiFetch } from './glib/goauth.js';
16
16
  import { extractEventsFromText, readClipboard } from './glib/aihelper.js';
17
17
  import pkg from './package.json' with { type: 'json' };
18
18
  const VERSION = pkg.version;
@@ -43,6 +43,35 @@ async function listEvents(accessToken, calendarId = 'primary', maxResults = 10,
43
43
  const data = await res.json();
44
44
  return data.items || [];
45
45
  }
46
+ async function listRecurringEvents(accessToken, calendarId = 'primary', maxResults = 250) {
47
+ // singleEvents=false returns the master records; orderBy=startTime is incompatible
48
+ // so we omit it and sort client-side. Pages until done or maxResults reached.
49
+ const out = [];
50
+ let pageToken;
51
+ do {
52
+ const params = new URLSearchParams({
53
+ maxResults: '250',
54
+ singleEvents: 'false'
55
+ });
56
+ if (pageToken)
57
+ params.set('pageToken', pageToken);
58
+ const url = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events?${params}`;
59
+ const res = await apiFetch(url, accessToken);
60
+ if (!res.ok) {
61
+ throw new Error(`Failed to list events: ${res.status} ${res.statusText}`);
62
+ }
63
+ const data = await res.json();
64
+ for (const ev of data.items || []) {
65
+ if (ev.recurrence && ev.status !== 'cancelled') {
66
+ out.push(ev);
67
+ if (out.length >= maxResults)
68
+ return out;
69
+ }
70
+ }
71
+ pageToken = data.nextPageToken;
72
+ } while (pageToken);
73
+ return out;
74
+ }
46
75
  async function createEvent(accessToken, event, calendarId = 'primary') {
47
76
  const url = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events`;
48
77
  const res = await apiFetch(url, accessToken, {
@@ -211,12 +240,14 @@ Commands:
211
240
  show Show full details for an event (-json for JSON)
212
241
  open Open event in browser
213
242
  add Add event (explicit, AI, or interactive)
243
+ update | edit | set Change an event's time/title/location/busy/...
214
244
  del | delete Delete event(s) by ID
215
245
  remind Add reminder(s) to existing event
216
246
  resched Reschedule event
217
247
  snooze Snooze event (default: +1d)
218
248
  import Import events from ICS file
219
249
  calendars | listc | list-calendars List available calendars
250
+ listr | list-recurring List recurring event masters (RRULE)
220
251
  assoc Set up .ics file association (Windows)
221
252
  help [command] Show help
222
253
 
@@ -257,20 +288,55 @@ const USAGE = {
257
288
  Examples:
258
289
  gcal open abc12345
259
290
  `,
260
- add: `gcal add <title> <when> [duration] Explicit
291
+ add: `gcal add <title> <when> [duration] Explicit (timed)
292
+ gcal add <title> <date> [days] -allday Explicit (all-day)
261
293
  gcal add "<free text>" AI-parsed single arg
262
294
  gcal add -clip AI-parsed from clipboard
263
295
  gcal add Interactive (type description)
264
296
  Add a calendar event. Default duration 1h. Use -r <dur> to add reminder(s).
265
297
 
298
+ -allday Create an all-day event. The third arg is a day count
299
+ (default 1); the event spans that many days.
300
+ -free Mark the event as Free (does not block time / not busy).
301
+ -busy Mark the event as Busy (the default).
302
+ -open Open the event in the browser after creating it.
303
+
266
304
  Examples:
267
305
  gcal add "Dentist" "Friday 3pm" "1h"
268
306
  gcal add "Lunch" "1/14/2026 12:00" "1h"
269
307
  gcal add "Meeting" "tomorrow 10:00"
270
308
  gcal add "Appointment" "jan 15 2pm"
309
+ gcal add "Vacation" "jul 1" -allday (1 day, all-day)
310
+ gcal add "Conference" "jul 1" 3 -allday (3-day all-day event)
311
+ gcal add "Out of office" "jul 1" -allday -free (all-day, not busy)
271
312
  gcal add "Dentist appointment Friday 3pm for 1 hour"
272
313
  gcal add -clip
273
314
  gcal add "Dentist" "Friday 3pm" -r 30m
315
+ `,
316
+ update: `gcal update <id> [options]
317
+ gcal edit <id> ... (aliases: edit, set)
318
+ Change settings on an existing event. Only the options you give are
319
+ changed. Searches up to 30 days back; widen with -since.
320
+
321
+ -when <when> New start time/date (accepts +Nd / +Nh advances too).
322
+ -dur <dur> New duration ("1h30m"); for all-day events, a day count.
323
+ -title <text> New event title.
324
+ -loc <text> New location ("" to clear).
325
+ -note <text> New description ("" to clear).
326
+ -free Mark as Free (not busy).
327
+ -busy Mark as Busy.
328
+ -r <dur> Add a popup reminder (repeatable).
329
+ -rx <dur> Remove a reminder matching that duration (repeatable).
330
+ -nr Remove all reminders.
331
+ -open Open the event in the browser afterward.
332
+
333
+ Examples:
334
+ gcal update abc12345 -free
335
+ gcal update abc12345 -busy -title "Team sync"
336
+ gcal update abc12345 -when "wed 2pm" -dur 90m
337
+ gcal update abc12345 -r 30m -r 1h (add two reminders)
338
+ gcal update abc12345 -rx 30m (remove the 30m reminder)
339
+ gcal update abc12345 -nr (clear all reminders)
274
340
  `,
275
341
  del: `gcal del <id> [id2...] [-all] [-b]
276
342
  gcal delete <id> [id2...]
@@ -312,6 +378,12 @@ const USAGE = {
312
378
  `,
313
379
  calendars: `gcal calendars (aliases: listc, list-calendars)
314
380
  List available calendars (id, name, access role).
381
+ `,
382
+ listr: `gcal listr (alias: list-recurring)
383
+ List recurring event masters in the current calendar, showing the
384
+ RRULE for each. Use this to confirm whether something like a daily
385
+ "Statin" reminder actually exists as a recurring event in Google
386
+ Calendar (vs. being defined only in another tool's local store).
315
387
  `,
316
388
  assoc: `gcal assoc
317
389
  Set up Windows .ics file association so double-clicking imports to gcal.
@@ -322,7 +394,10 @@ const USAGE = {
322
394
  };
323
395
  const HELP_ALIASES = {
324
396
  'listc': 'calendars',
325
- 'list-calendars': 'calendars'
397
+ 'list-calendars': 'calendars',
398
+ 'list-recurring': 'listr',
399
+ 'set': 'update',
400
+ 'edit': 'update'
326
401
  };
327
402
  function showUsage(cmd) {
328
403
  if (cmd)
@@ -350,7 +425,13 @@ function parseArgs(argv) {
350
425
  clip: false,
351
426
  all: false,
352
427
  json: false,
428
+ allDay: false,
429
+ transparency: '',
430
+ removeReminders: [],
431
+ clearReminders: false,
432
+ open: false,
353
433
  reminders: [],
434
+ rrule: '',
354
435
  helpCmd: ''
355
436
  };
356
437
  const unknown = [];
@@ -394,6 +475,57 @@ function parseArgs(argv) {
394
475
  case '--json':
395
476
  result.json = true;
396
477
  break;
478
+ case '-allday':
479
+ case '-all-day':
480
+ case '--allday':
481
+ result.allDay = true;
482
+ break;
483
+ case '-free':
484
+ case '--free':
485
+ result.transparency = 'transparent';
486
+ break;
487
+ case '-busy':
488
+ case '--busy':
489
+ result.transparency = 'opaque';
490
+ break;
491
+ case '-title':
492
+ case '--title':
493
+ result.setTitle = argv[++i] || '';
494
+ break;
495
+ case '-loc':
496
+ case '-location':
497
+ case '--location':
498
+ result.setLoc = argv[++i] || '';
499
+ break;
500
+ case '-note':
501
+ case '-desc':
502
+ case '-description':
503
+ case '--note':
504
+ result.setNote = argv[++i] || '';
505
+ break;
506
+ case '-when':
507
+ case '--when':
508
+ result.setWhen = argv[++i] || '';
509
+ break;
510
+ case '-dur':
511
+ case '-duration':
512
+ case '--duration':
513
+ result.setDur = argv[++i] || '';
514
+ break;
515
+ case '-rx':
516
+ case '-rmr':
517
+ case '--remove-reminder':
518
+ result.removeReminders.push(parseDuration(argv[++i] || ''));
519
+ break;
520
+ case '-nr':
521
+ case '-noreminders':
522
+ case '--no-reminders':
523
+ result.clearReminders = true;
524
+ break;
525
+ case '-open':
526
+ case '--open':
527
+ result.open = true;
528
+ break;
397
529
  case '-r':
398
530
  case '-reminder':
399
531
  case '--reminder': {
@@ -402,6 +534,15 @@ function parseArgs(argv) {
402
534
  result.reminders.push(mins);
403
535
  break;
404
536
  }
537
+ case '-rule':
538
+ case '-rrule':
539
+ case '--rrule': {
540
+ let v = argv[++i] || '';
541
+ if (v.toUpperCase().startsWith('RRULE:'))
542
+ v = v.slice(6);
543
+ result.rrule = v;
544
+ break;
545
+ }
405
546
  case '-since':
406
547
  case '--since': {
407
548
  const val = argv[++i] || '';
@@ -473,6 +614,104 @@ function buildReminders(minutes) {
473
614
  overrides: minutes.map(m => ({ method: 'popup', minutes: m }))
474
615
  };
475
616
  }
617
+ /** Compute a start/end patch for moving and/or resizing an event.
618
+ * whenArg undefined => keep original start; durationArg undefined => keep original length.
619
+ * For all-day events durationArg is a day count; for timed events a duration string. */
620
+ function reschedulePatch(event, whenArg, durationArg) {
621
+ const origIsAllDay = !!event.start?.date;
622
+ if (origIsAllDay) {
623
+ const origStart = parseAllDay(event.start.date);
624
+ const origEnd = parseAllDay(event.end.date);
625
+ const origDurDays = Math.max(1, Math.round((origEnd.getTime() - origStart.getTime()) / 86400_000));
626
+ let newStart;
627
+ if (whenArg) {
628
+ const adv = whenArg.match(/^\+(\d+)([dw])$/i);
629
+ if (adv) {
630
+ const [, n, unit] = adv;
631
+ const amt = parseInt(n);
632
+ newStart = new Date(origStart);
633
+ newStart.setDate(newStart.getDate() + (unit.toLowerCase() === 'w' ? amt * 7 : amt));
634
+ }
635
+ else {
636
+ newStart = parseDateTime(whenArg);
637
+ newStart.setHours(0, 0, 0, 0);
638
+ }
639
+ }
640
+ else {
641
+ newStart = new Date(origStart);
642
+ }
643
+ const durDays = durationArg ? (parseInt(durationArg, 10) || origDurDays) : origDurDays;
644
+ const newEnd = new Date(newStart);
645
+ newEnd.setDate(newEnd.getDate() + durDays);
646
+ const patch = { start: { date: formatYMD(newStart) }, end: { date: formatYMD(newEnd) } };
647
+ return { patch, startDisplay: patch.start, endDisplay: patch.end };
648
+ }
649
+ const origStart = new Date(event.start.dateTime);
650
+ const origEnd = new Date(event.end.dateTime);
651
+ const origDurMs = origEnd.getTime() - origStart.getTime();
652
+ let newStart;
653
+ if (whenArg) {
654
+ const adv = whenArg.match(/^\+(\d+)([dwhm])$/i);
655
+ if (adv) {
656
+ const [, n, unit] = adv;
657
+ const amt = parseInt(n);
658
+ newStart = new Date(origStart);
659
+ switch (unit.toLowerCase()) {
660
+ case 'd':
661
+ newStart.setDate(newStart.getDate() + amt);
662
+ break;
663
+ case 'w':
664
+ newStart.setDate(newStart.getDate() + amt * 7);
665
+ break;
666
+ case 'h':
667
+ newStart.setHours(newStart.getHours() + amt);
668
+ break;
669
+ case 'm':
670
+ newStart.setMinutes(newStart.getMinutes() + amt);
671
+ break;
672
+ }
673
+ }
674
+ else {
675
+ newStart = parseDateTime(whenArg);
676
+ if (!hasTimeComponent(whenArg)) {
677
+ newStart.setHours(origStart.getHours(), origStart.getMinutes(), 0, 0);
678
+ }
679
+ }
680
+ }
681
+ else {
682
+ newStart = new Date(origStart);
683
+ }
684
+ const durMs = durationArg ? parseDuration(durationArg) * 60_000 : origDurMs;
685
+ const newEnd = new Date(newStart.getTime() + durMs);
686
+ const tz = event.start.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
687
+ const patch = {
688
+ start: { dateTime: newStart.toISOString(), timeZone: tz },
689
+ end: { dateTime: newEnd.toISOString(), timeZone: tz }
690
+ };
691
+ return { patch, startDisplay: patch.start, endDisplay: patch.end };
692
+ }
693
+ /** Format a reminder override list for display, e.g. "10m, 1h" or "(none)". */
694
+ function formatReminders(overrides) {
695
+ if (!overrides || overrides.length === 0)
696
+ return '(none)';
697
+ return overrides
698
+ .map(r => r.minutes ?? 0)
699
+ .sort((a, b) => a - b)
700
+ .map(m => (m >= 60 && m % 60 === 0 ? `${m / 60}h` : `${m}m`))
701
+ .join(', ');
702
+ }
703
+ /** Open a URL in the platform's default browser. */
704
+ function openUrl(url) {
705
+ if (process.platform === 'win32') {
706
+ execSync(`start "" "${url}"`, { stdio: 'ignore', shell: 'cmd.exe' });
707
+ }
708
+ else if (process.platform === 'darwin') {
709
+ execSync(`open "${url}"`, { stdio: 'ignore' });
710
+ }
711
+ else {
712
+ execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
713
+ }
714
+ }
476
715
  /** Match events by ID prefix and dedup recurring instances to the earliest.
477
716
  * `events` must be ordered by startTime (as returned by listEvents). */
478
717
  function findByPrefix(events, prefix, includeBirthdays) {
@@ -599,6 +838,29 @@ async function main() {
599
838
  console.error('No user configured. Use -u <email> to set default user.');
600
839
  process.exit(1);
601
840
  }
841
+ // Resolve partial calendar names against the user's calendar list.
842
+ // 'primary' and full IDs (containing '@') pass through unchanged.
843
+ if (parsed.calendar !== 'primary' && !parsed.calendar.includes('@')) {
844
+ const token = await getAccessToken(user, false);
845
+ const cals = await listCalendars(token);
846
+ const q = parsed.calendar.toLowerCase();
847
+ const exact = cals.filter(c => (c.summary || '').toLowerCase() === q);
848
+ const matches = exact.length > 0 ? exact : cals.filter(c => (c.summary || '').toLowerCase().includes(q) ||
849
+ (c.id || '').toLowerCase().includes(q));
850
+ if (matches.length === 0) {
851
+ console.error(`No calendar matches "${parsed.calendar}". Available:`);
852
+ for (const c of cals)
853
+ console.error(` ${c.summary} -- ${c.id}`);
854
+ process.exit(1);
855
+ }
856
+ if (matches.length > 1) {
857
+ console.error(`Ambiguous calendar "${parsed.calendar}":`);
858
+ for (const c of matches)
859
+ console.error(` ${c.summary} -- ${c.id}`);
860
+ process.exit(1);
861
+ }
862
+ parsed.calendar = matches[0].id;
863
+ }
602
864
  switch (parsed.command) {
603
865
  case 'import': {
604
866
  const filePath = parsed.icsFile || parsed.args[0];
@@ -634,7 +896,23 @@ async function main() {
634
896
  const token = await getAccessToken(user, false);
635
897
  const timeMin = parsed.since ? parsed.since.toISOString() : undefined;
636
898
  const timeMax = parsed.till ? parsed.till.toISOString() : undefined;
637
- let events = await listEvents(token, parsed.calendar, count, timeMin, timeMax);
899
+ // Oversample when collapsing recurring series so we still get
900
+ // <count> distinct items after dedup.
901
+ const fetchCount = parsed.all ? count : Math.max(count * 5, 50);
902
+ let events = await listEvents(token, parsed.calendar, fetchCount, timeMin, timeMax);
903
+ if (!parsed.all) {
904
+ const seen = new Set();
905
+ events = events.filter(e => {
906
+ const seriesKey = e.recurringEventId;
907
+ if (!seriesKey)
908
+ return true;
909
+ if (seen.has(seriesKey))
910
+ return false;
911
+ seen.add(seriesKey);
912
+ return true;
913
+ });
914
+ events = events.slice(0, count);
915
+ }
638
916
  const birthdayCount = events.filter(e => e.eventType === 'birthday').length;
639
917
  if (!parsed.birthdays) {
640
918
  events = events.filter(e => e.eventType !== 'birthday');
@@ -653,7 +931,9 @@ async function main() {
653
931
  const shortId = (event.id || '').slice(0, 8);
654
932
  const start = event.start ? formatDateTime(event.start) : '?';
655
933
  const duration = (event.start && event.end) ? formatDuration(event.start, event.end) : '';
656
- const summary = (event.summary || '(no title)') + (event.eventType === 'birthday' ? ' [from contact]' : '');
934
+ const summary = (event.summary || '(no title)')
935
+ + (event.eventType === 'birthday' ? ' [from contact]' : '')
936
+ + (event.recurringEventId ? ' [recurring]' : '');
657
937
  const loc = event.location || '';
658
938
  if (parsed.verbose) {
659
939
  rows.push([shortId, start, duration, summary, loc, event.htmlLink || '']);
@@ -683,30 +963,50 @@ async function main() {
683
963
  case 'add': {
684
964
  // Explicit mode: gcal add "title" "when" [duration]
685
965
  if (parsed.args.length >= 2 && !parsed.clip) {
686
- const [title, when, duration = '1h'] = parsed.args;
966
+ const [title, when, third] = parsed.args;
687
967
  const startTime = parseDateTime(when);
688
- const durationMins = parseDuration(duration);
689
- const endTime = new Date(startTime.getTime() + durationMins * 60 * 1000);
690
968
  const event = {
691
969
  summary: title,
692
- start: {
693
- dateTime: startTime.toISOString(),
694
- timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
695
- },
696
- end: {
697
- dateTime: endTime.toISOString(),
698
- timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
699
- },
970
+ start: {},
971
+ end: {},
700
972
  reminders: buildReminders(parsed.reminders)
701
973
  };
974
+ if (parsed.allDay) {
975
+ // All-day: [duration] is a day count (default 1). Google's
976
+ // end.date is exclusive, so a 1-day event ends the next day.
977
+ const days = third ? (parseInt(third, 10) || 1) : 1;
978
+ const startD = new Date(startTime);
979
+ startD.setHours(0, 0, 0, 0);
980
+ const endD = new Date(startD);
981
+ endD.setDate(endD.getDate() + days);
982
+ event.start = { date: formatYMD(startD) };
983
+ event.end = { date: formatYMD(endD) };
984
+ }
985
+ else {
986
+ const durationMins = parseDuration(third || '1h');
987
+ const endTime = new Date(startTime.getTime() + durationMins * 60 * 1000);
988
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
989
+ event.start = { dateTime: startTime.toISOString(), timeZone: tz };
990
+ event.end = { dateTime: endTime.toISOString(), timeZone: tz };
991
+ }
992
+ if (parsed.transparency)
993
+ event.transparency = parsed.transparency;
994
+ if (parsed.rrule)
995
+ event.recurrence = [`RRULE:${parsed.rrule}`];
702
996
  const token = await getAccessToken(user, true);
703
- await checkProximity(token, parsed.calendar, startTime, endTime);
997
+ if (!parsed.allDay) {
998
+ await checkProximity(token, parsed.calendar, new Date(event.start.dateTime), new Date(event.end.dateTime));
999
+ }
704
1000
  const created = await createEvent(token, event, parsed.calendar);
705
1001
  console.log(`\nEvent created: ${created.summary}`);
706
1002
  console.log(` When: ${formatDateTime(created.start)} - ${formatDateTime(created.end)}`);
1003
+ if (created.transparency === 'transparent')
1004
+ console.log(` Free (not busy)`);
707
1005
  if (created.htmlLink) {
708
1006
  console.log(` Link: ${created.htmlLink}`);
709
1007
  }
1008
+ if (parsed.open && created.htmlLink)
1009
+ openUrl(created.htmlLink);
710
1010
  break;
711
1011
  }
712
1012
  // AI mode: freeform text from clipboard, keyboard, or single arg
@@ -764,6 +1064,8 @@ async function main() {
764
1064
  description: extracted.description,
765
1065
  reminders: buildReminders(parsed.reminders)
766
1066
  };
1067
+ if (parsed.transparency)
1068
+ event.transparency = parsed.transparency;
767
1069
  events.push(event);
768
1070
  console.log(`\n Event: ${extracted.summary}`);
769
1071
  console.log(` When: ${formatDateTime(event.start)} - ${formatDateTime(event.end)} (${extracted.duration || '1h'})${tz !== localTz ? ` [${tz}]` : ''}`);
@@ -781,13 +1083,18 @@ async function main() {
781
1083
  ? '\nCreate this event? [Y/n] (auto-yes in 60s) '
782
1084
  : `\nCreate ${events.length} events? [Y/n] (auto-yes in 60s) `;
783
1085
  const rl2 = createInterface({ input: process.stdin, output: process.stdout });
1086
+ let timeoutId;
784
1087
  const confirm = await Promise.race([
785
1088
  rl2.question(prompt).then(s => s.trim().toLowerCase()),
786
- new Promise(resolve => setTimeout(() => {
787
- console.log('\nNo response creating event(s).');
788
- resolve('');
789
- }, 60_000))
1089
+ new Promise(resolve => {
1090
+ timeoutId = setTimeout(() => {
1091
+ console.log('\nNo response — creating event(s).');
1092
+ resolve('');
1093
+ }, 60_000);
1094
+ })
790
1095
  ]);
1096
+ if (timeoutId)
1097
+ clearTimeout(timeoutId);
791
1098
  rl2.close();
792
1099
  if (confirm && confirm !== 'y' && confirm !== 'yes') {
793
1100
  console.log('Cancelled.');
@@ -800,6 +1107,8 @@ async function main() {
800
1107
  if (created.htmlLink) {
801
1108
  console.log(` Link: ${created.htmlLink}`);
802
1109
  }
1110
+ if (parsed.open && created.htmlLink)
1111
+ openUrl(created.htmlLink);
803
1112
  }
804
1113
  break;
805
1114
  }
@@ -852,6 +1161,34 @@ async function main() {
852
1161
  }
853
1162
  break;
854
1163
  }
1164
+ case 'listr':
1165
+ case 'list-recurring': {
1166
+ const token = await getAccessToken(user, false);
1167
+ const events = await listRecurringEvents(token, parsed.calendar, parsed.count || 250);
1168
+ if (events.length === 0) {
1169
+ console.log('No recurring events found.');
1170
+ break;
1171
+ }
1172
+ console.log(`\nRecurring events (${events.length}):\n`);
1173
+ const rows = [];
1174
+ for (const ev of events) {
1175
+ const shortId = (ev.id || '').slice(0, 8);
1176
+ const start = ev.start ? formatDateTime(ev.start) : '';
1177
+ const summary = ev.summary || '(no title)';
1178
+ const rule = (ev.recurrence || []).join('; ');
1179
+ rows.push([shortId, start, summary, rule]);
1180
+ }
1181
+ const headers = ['ID', 'Starts', 'Event', 'Recurrence'];
1182
+ const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => (r[i] || '').length)));
1183
+ const lastIdx = headers.length - 1;
1184
+ const padCell = (s, i) => i === lastIdx ? s : s.padEnd(colWidths[i]);
1185
+ console.log(headers.map(padCell).join(' '));
1186
+ console.log(colWidths.map(w => '-'.repeat(w)).join(' '));
1187
+ for (const row of rows) {
1188
+ console.log(row.map((cell, i) => padCell(cell || '', i)).join(' '));
1189
+ }
1190
+ break;
1191
+ }
855
1192
  case 'remind': {
856
1193
  if (parsed.args.length < 2) {
857
1194
  console.error('Usage: gcal remind <id> <duration> [duration2...]');
@@ -889,6 +1226,104 @@ async function main() {
889
1226
  }
890
1227
  break;
891
1228
  }
1229
+ case 'update':
1230
+ case 'edit':
1231
+ case 'set': {
1232
+ if (parsed.args.length < 1) {
1233
+ console.error('Usage: gcal update <id> [-when <when>] [-dur <dur>] [-title <text>]');
1234
+ console.error(' [-loc <text>] [-note <text>] [-free|-busy]');
1235
+ console.error(' [-r <dur>] [-rx <dur>] [-nr] [-open]');
1236
+ console.error('Use "gcal list" to see event IDs');
1237
+ process.exit(1);
1238
+ }
1239
+ const patch = {};
1240
+ if (parsed.setTitle !== undefined)
1241
+ patch.summary = parsed.setTitle;
1242
+ if (parsed.setLoc !== undefined)
1243
+ patch.location = parsed.setLoc;
1244
+ if (parsed.setNote !== undefined)
1245
+ patch.description = parsed.setNote;
1246
+ if (parsed.transparency)
1247
+ patch.transparency = parsed.transparency;
1248
+ const changingTime = parsed.setWhen !== undefined || parsed.setDur !== undefined;
1249
+ const changingReminders = parsed.clearReminders
1250
+ || parsed.reminders.length > 0 || parsed.removeReminders.length > 0;
1251
+ if (Object.keys(patch).length === 0 && !changingTime && !changingReminders) {
1252
+ console.error('Nothing to change. Specify -when, -dur, -title, -loc, -note,');
1253
+ console.error(' -free, -busy, -r (add reminder), -rx (remove reminder), or -nr.');
1254
+ process.exit(1);
1255
+ }
1256
+ const idPrefix = parsed.args[0];
1257
+ const lookback = parsed.since
1258
+ ? parsed.since.toISOString()
1259
+ : new Date(Date.now() - 30 * 86400_000).toISOString();
1260
+ const timeMax = parsed.till ? parsed.till.toISOString() : undefined;
1261
+ const token = await getAccessToken(user, true);
1262
+ const events = await listEvents(token, parsed.calendar, 250, lookback, timeMax);
1263
+ const unique = findByPrefix(events, idPrefix, parsed.birthdays);
1264
+ if (unique.length === 0) {
1265
+ console.error(`${idPrefix}: not found (searched from ${lookback.slice(0, 10)})`);
1266
+ process.exit(1);
1267
+ }
1268
+ if (unique.length > 1) {
1269
+ console.error(`${idPrefix}: ambiguous (${unique.length} matches)`);
1270
+ for (const e of unique) {
1271
+ console.error(` ${e.id?.slice(0, 8)} - ${e.summary}`);
1272
+ }
1273
+ process.exit(1);
1274
+ }
1275
+ const event = unique[0];
1276
+ // Time / duration change (reuses the resched logic)
1277
+ let timeFrom = '';
1278
+ let timeTo = '';
1279
+ if (changingTime) {
1280
+ const r = reschedulePatch(event, parsed.setWhen, parsed.setDur);
1281
+ patch.start = r.patch.start;
1282
+ patch.end = r.patch.end;
1283
+ timeFrom = `${formatDateTime(event.start)} - ${formatDateTime(event.end)}`;
1284
+ timeTo = `${formatDateTime(r.startDisplay)} - ${formatDateTime(r.endDisplay)}`;
1285
+ }
1286
+ // Reminders: -nr clears all; otherwise merge existing with -r adds and -rx removes
1287
+ if (parsed.clearReminders) {
1288
+ patch.reminders = { useDefault: false, overrides: [] };
1289
+ }
1290
+ else if (changingReminders) {
1291
+ const mins = new Set((event.reminders?.overrides ?? []).map(r => r.minutes ?? 0));
1292
+ for (const m of parsed.removeReminders)
1293
+ mins.delete(m);
1294
+ for (const m of parsed.reminders)
1295
+ mins.add(m);
1296
+ patch.reminders = {
1297
+ useDefault: false,
1298
+ overrides: [...mins].sort((a, b) => a - b).map(m => ({ method: 'popup', minutes: m }))
1299
+ };
1300
+ }
1301
+ // Proximity check when the timed slot moved
1302
+ if (patch.start?.dateTime && patch.end?.dateTime) {
1303
+ await checkProximity(token, parsed.calendar, new Date(patch.start.dateTime), new Date(patch.end.dateTime), (event.id || '').split('_')[0]);
1304
+ }
1305
+ const updated = await patchEvent(token, event.id, patch, parsed.calendar);
1306
+ console.log(`Updated: ${updated.summary}`);
1307
+ if (patch.summary !== undefined)
1308
+ console.log(` Title: ${updated.summary}`);
1309
+ if (changingTime) {
1310
+ console.log(` When: ${timeFrom}`);
1311
+ console.log(` -> ${timeTo}`);
1312
+ }
1313
+ if (patch.location !== undefined)
1314
+ console.log(` Where: ${updated.location || '(cleared)'}`);
1315
+ if (patch.description !== undefined)
1316
+ console.log(` Note: ${updated.description || '(cleared)'}`);
1317
+ if (patch.transparency !== undefined) {
1318
+ console.log(` Shows as: ${updated.transparency === 'transparent' ? 'free' : 'busy'}`);
1319
+ }
1320
+ if (patch.reminders !== undefined) {
1321
+ console.log(` Reminders: ${formatReminders(updated.reminders?.overrides)}`);
1322
+ }
1323
+ if (parsed.open && updated.htmlLink)
1324
+ openUrl(updated.htmlLink);
1325
+ break;
1326
+ }
892
1327
  case 'resched':
893
1328
  case 'reschedule':
894
1329
  case 'snooze': {
@@ -932,84 +1367,17 @@ async function main() {
932
1367
  process.exit(1);
933
1368
  }
934
1369
  }
935
- const origIsAllDay = !!event.start?.date;
936
- let patch;
937
- let newStartDisplay;
938
- let newEndDisplay;
939
- if (origIsAllDay) {
940
- const origStart = parseAllDay(event.start.date);
941
- const origEnd = parseAllDay(event.end.date);
942
- const origDurDays = Math.max(1, Math.round((origEnd.getTime() - origStart.getTime()) / 86400_000));
943
- let newStart;
944
- const adv = whenArg.match(/^\+(\d+)([dw])$/i);
945
- if (adv) {
946
- const [, n, unit] = adv;
947
- const amt = parseInt(n);
948
- newStart = new Date(origStart);
949
- newStart.setDate(newStart.getDate() + (unit.toLowerCase() === 'w' ? amt * 7 : amt));
950
- }
951
- else {
952
- newStart = parseDateTime(whenArg);
953
- newStart.setHours(0, 0, 0, 0);
954
- }
955
- const newEnd = new Date(newStart);
956
- newEnd.setDate(newEnd.getDate() + origDurDays);
957
- patch = {
958
- start: { date: formatYMD(newStart) },
959
- end: { date: formatYMD(newEnd) }
960
- };
961
- newStartDisplay = { date: formatYMD(newStart) };
962
- newEndDisplay = { date: formatYMD(newEnd) };
963
- }
964
- else {
965
- const origStart = new Date(event.start.dateTime);
966
- const origEnd = new Date(event.end.dateTime);
967
- const origDurMs = origEnd.getTime() - origStart.getTime();
968
- let newStart;
969
- const adv = whenArg.match(/^\+(\d+)([dwhm])$/i);
970
- if (adv) {
971
- const [, n, unit] = adv;
972
- const amt = parseInt(n);
973
- newStart = new Date(origStart);
974
- switch (unit.toLowerCase()) {
975
- case 'd':
976
- newStart.setDate(newStart.getDate() + amt);
977
- break;
978
- case 'w':
979
- newStart.setDate(newStart.getDate() + amt * 7);
980
- break;
981
- case 'h':
982
- newStart.setHours(newStart.getHours() + amt);
983
- break;
984
- case 'm':
985
- newStart.setMinutes(newStart.getMinutes() + amt);
986
- break;
987
- }
988
- }
989
- else {
990
- newStart = parseDateTime(whenArg);
991
- if (!hasTimeComponent(whenArg)) {
992
- newStart.setHours(origStart.getHours(), origStart.getMinutes(), 0, 0);
993
- }
994
- }
995
- const durMs = durationArg ? parseDuration(durationArg) * 60_000 : origDurMs;
996
- const newEnd = new Date(newStart.getTime() + durMs);
997
- const tz = event.start.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
998
- patch = {
999
- start: { dateTime: newStart.toISOString(), timeZone: tz },
1000
- end: { dateTime: newEnd.toISOString(), timeZone: tz }
1001
- };
1002
- newStartDisplay = patch.start;
1003
- newEndDisplay = patch.end;
1004
- }
1370
+ const { patch, startDisplay, endDisplay } = reschedulePatch(event, whenArg, durationArg);
1005
1371
  // Proximity check for timed events (skip all-day)
1006
- if (!origIsAllDay && patch.start?.dateTime && patch.end?.dateTime) {
1372
+ if (patch.start?.dateTime && patch.end?.dateTime) {
1007
1373
  await checkProximity(token, parsed.calendar, new Date(patch.start.dateTime), new Date(patch.end.dateTime), (event.id || '').split('_')[0]);
1008
1374
  }
1009
1375
  const updated = await patchEvent(token, event.id, patch, parsed.calendar);
1010
1376
  console.log(`Rescheduled: ${updated.summary}`);
1011
1377
  console.log(` From: ${formatDateTime(event.start)} - ${formatDateTime(event.end)}`);
1012
- console.log(` To: ${formatDateTime(newStartDisplay)} - ${formatDateTime(newEndDisplay)}`);
1378
+ console.log(` To: ${formatDateTime(startDisplay)} - ${formatDateTime(endDisplay)}`);
1379
+ if (parsed.open && updated.htmlLink)
1380
+ openUrl(updated.htmlLink);
1013
1381
  break;
1014
1382
  }
1015
1383
  case 'show': {
@@ -1094,6 +1462,7 @@ async function main() {
1094
1462
  if (event.htmlLink)
1095
1463
  console.log(` Link: ${event.htmlLink}`);
1096
1464
  console.log(` Status: ${event.status || 'confirmed'}`);
1465
+ console.log(` Shows as: ${event.transparency === 'transparent' ? 'free' : 'busy'}`);
1097
1466
  if (event.created)
1098
1467
  console.log(` Created: ${formatDateTime({ dateTime: event.created })}`);
1099
1468
  if (event.updated)
@@ -1133,15 +1502,7 @@ async function main() {
1133
1502
  }
1134
1503
  console.log(`Opening: ${event.summary || '(no title)'}`);
1135
1504
  console.log(` ${event.htmlLink}`);
1136
- if (process.platform === 'win32') {
1137
- execSync(`start "" "${event.htmlLink}"`, { stdio: 'ignore', shell: 'cmd.exe' });
1138
- }
1139
- else if (process.platform === 'darwin') {
1140
- execSync(`open "${event.htmlLink}"`, { stdio: 'ignore' });
1141
- }
1142
- else {
1143
- execSync(`xdg-open "${event.htmlLink}"`, { stdio: 'ignore' });
1144
- }
1505
+ openUrl(event.htmlLink);
1145
1506
  break;
1146
1507
  }
1147
1508
  default:
@@ -1152,10 +1513,16 @@ async function main() {
1152
1513
  }
1153
1514
  if (import.meta.main) {
1154
1515
  main()
1155
- .then(() => process.exit(0))
1156
- .catch(e => {
1516
+ .then(async () => {
1517
+ await teardownAbortHandler();
1518
+ // Don't call process.exit explicitly — Node 25 on Windows asserts
1519
+ // in libuv (UV_HANDLE_CLOSING) when process.exit races with handle
1520
+ // teardown. Let the event loop drain naturally instead.
1521
+ })
1522
+ .catch(async (e) => {
1523
+ await teardownAbortHandler();
1157
1524
  console.error(`Error: ${e.message}`);
1158
- process.exit(1);
1525
+ process.exitCode = 1;
1159
1526
  });
1160
1527
  }
1161
1528
  //# sourceMappingURL=gcal.js.map