@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 +472 -105
- package/gcal.js.map +1 -1
- package/gcal.ts +487 -98
- package/glib/goauth.d.ts +1 -0
- package/glib/goauth.d.ts.map +1 -1
- package/glib/goauth.js +21 -2
- package/glib/goauth.js.map +1 -1
- package/glib/goauth.ts +21 -2
- package/glib/gutils.d.ts.map +1 -1
- package/glib/gutils.js +15 -0
- package/glib/gutils.js.map +1 -1
- package/glib/gutils.ts +14 -0
- package/gtask.js +19 -7
- package/gtask.js.map +1 -1
- package/gtask.ts +19 -7
- package/package.json +1 -1
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
|
-
|
|
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)')
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 =>
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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(() =>
|
|
1156
|
-
|
|
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.
|
|
1525
|
+
process.exitCode = 1;
|
|
1159
1526
|
});
|
|
1160
1527
|
}
|
|
1161
1528
|
//# sourceMappingURL=gcal.js.map
|