@bobfrankston/gcal 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # @bobfrankston/gcal
2
+
3
+ Google Calendar CLI tool with ICS import support.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g @bobfrankston/gcal
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ gcal [command] [options]
15
+ ```
16
+
17
+ ## Features
18
+
19
+ - Google Calendar integration
20
+ - ICS file import support
21
+ - OAuth authentication
22
+ - CLI interface
23
+
24
+ ## License
25
+
26
+ MIT
package/gcal.ts ADDED
@@ -0,0 +1,484 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gcal - Google Calendar CLI tool
4
+ * Manage Google Calendar events with ICS import support
5
+ *
6
+ * Can be associated with .ics files for direct import:
7
+ * assoc .ics=icsfile
8
+ * ftype icsfile=gcal "%1"
9
+ */
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { authenticateOAuth } from '@bobfrankston/oauthsupport';
14
+ import type { GoogleEvent, EventsListResponse, CalendarListEntry, CalendarListResponse } from './glib/types.ts';
15
+ import {
16
+ CREDENTIALS_FILE, loadConfig, saveConfig, getUserPaths,
17
+ ensureUserDir, formatDateTime, parseDuration, parseDateTime, ts, normalizeUser
18
+ } from './glib/gutils.ts';
19
+
20
+ const CALENDAR_API_BASE = 'https://www.googleapis.com/calendar/v3';
21
+ const CALENDAR_SCOPE_READ = 'https://www.googleapis.com/auth/calendar.readonly';
22
+ const CALENDAR_SCOPE_WRITE = 'https://www.googleapis.com/auth/calendar';
23
+
24
+ let abortController: AbortController = null;
25
+
26
+ function setupAbortHandler(): void {
27
+ abortController = new AbortController();
28
+ process.on('SIGINT', () => {
29
+ abortController?.abort();
30
+ console.log('\n\nCtrl+C pressed - aborting...');
31
+ });
32
+ }
33
+
34
+ async function getAccessToken(user: string, writeAccess = false, forceRefresh = false): Promise<string> {
35
+ if (!fs.existsSync(CREDENTIALS_FILE)) {
36
+ console.error(`\nCredentials file not found: ${CREDENTIALS_FILE}\n`);
37
+ console.error(`gcal uses the same credentials as gcards.`);
38
+ console.error(`Make sure gcards is set up with OAuth credentials first.`);
39
+ console.error(`See: https://github.com/BobFrankston/oauthsupport/blob/master/SETUP-GOOGLE-OAUTH.md`);
40
+ process.exit(1);
41
+ }
42
+
43
+ const paths = getUserPaths(user);
44
+ ensureUserDir(user);
45
+
46
+ const scope = writeAccess ? CALENDAR_SCOPE_WRITE : CALENDAR_SCOPE_READ;
47
+ const tokenFileName = writeAccess ? 'token-write.json' : 'token.json';
48
+ const tokenFilePath = path.join(paths.userDir, tokenFileName);
49
+
50
+ if (forceRefresh && fs.existsSync(tokenFilePath)) {
51
+ fs.unlinkSync(tokenFilePath);
52
+ console.log(`${ts()} Token expired, refreshing...`);
53
+ }
54
+
55
+ const token = await authenticateOAuth(CREDENTIALS_FILE, {
56
+ scope,
57
+ tokenDirectory: paths.userDir,
58
+ tokenFileName,
59
+ credentialsKey: 'web',
60
+ signal: abortController?.signal
61
+ });
62
+
63
+ if (!token) {
64
+ throw new Error('OAuth authentication failed');
65
+ }
66
+
67
+ return token.access_token;
68
+ }
69
+
70
+ async function apiFetch(url: string, accessToken: string, options: RequestInit = {}): Promise<Response> {
71
+ const headers = {
72
+ 'Authorization': `Bearer ${accessToken}`,
73
+ 'Content-Type': 'application/json',
74
+ ...options.headers
75
+ };
76
+ return fetch(url, { ...options, headers });
77
+ }
78
+
79
+ async function listCalendars(accessToken: string): Promise<CalendarListEntry[]> {
80
+ const url = `${CALENDAR_API_BASE}/users/me/calendarList`;
81
+ const res = await apiFetch(url, accessToken);
82
+ if (!res.ok) {
83
+ throw new Error(`Failed to list calendars: ${res.status} ${res.statusText}`);
84
+ }
85
+ const data = await res.json() as CalendarListResponse;
86
+ return data.items || [];
87
+ }
88
+
89
+ async function listEvents(
90
+ accessToken: string,
91
+ calendarId = 'primary',
92
+ maxResults = 10,
93
+ timeMin?: string
94
+ ): Promise<GoogleEvent[]> {
95
+ const params = new URLSearchParams({
96
+ maxResults: maxResults.toString(),
97
+ singleEvents: 'true',
98
+ orderBy: 'startTime',
99
+ timeMin: timeMin || new Date().toISOString()
100
+ });
101
+
102
+ const url = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events?${params}`;
103
+ const res = await apiFetch(url, accessToken);
104
+ if (!res.ok) {
105
+ throw new Error(`Failed to list events: ${res.status} ${res.statusText}`);
106
+ }
107
+ const data = await res.json() as EventsListResponse;
108
+ return data.items || [];
109
+ }
110
+
111
+ async function createEvent(
112
+ accessToken: string,
113
+ event: GoogleEvent,
114
+ calendarId = 'primary'
115
+ ): Promise<GoogleEvent> {
116
+ const url = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events`;
117
+ const res = await apiFetch(url, accessToken, {
118
+ method: 'POST',
119
+ body: JSON.stringify(event)
120
+ });
121
+ if (!res.ok) {
122
+ const errText = await res.text();
123
+ throw new Error(`Failed to create event: ${res.status} ${errText}`);
124
+ }
125
+ return await res.json() as GoogleEvent;
126
+ }
127
+
128
+ async function importIcsFile(
129
+ filePath: string,
130
+ accessToken: string,
131
+ calendarId = 'primary'
132
+ ): Promise<{ imported: number; errors: string[] }> {
133
+ const ICAL = await import('ical.js');
134
+ const result = { imported: 0, errors: [] as string[] };
135
+
136
+ const icsContent = fs.readFileSync(filePath, 'utf-8');
137
+
138
+ try {
139
+ const jcalData = ICAL.default.parse(icsContent);
140
+ const vcalendar = new ICAL.default.Component(jcalData);
141
+ const vevents = vcalendar.getAllSubcomponents('vevent');
142
+
143
+ console.log(`Found ${vevents.length} event(s)\n`);
144
+
145
+ for (const vevent of vevents) {
146
+ try {
147
+ const event = new ICAL.default.Event(vevent);
148
+ const googleEvent: GoogleEvent = {
149
+ summary: event.summary || 'Untitled Event',
150
+ description: event.description || undefined,
151
+ location: event.location || undefined,
152
+ start: {},
153
+ end: {}
154
+ };
155
+
156
+ const startDate = event.startDate;
157
+ if (startDate) {
158
+ if (startDate.isDate) {
159
+ googleEvent.start.date = startDate.toJSDate().toISOString().split('T')[0];
160
+ } else {
161
+ googleEvent.start.dateTime = startDate.toJSDate().toISOString();
162
+ googleEvent.start.timeZone = startDate.zone?.tzid || Intl.DateTimeFormat().resolvedOptions().timeZone;
163
+ }
164
+ }
165
+
166
+ const endDate = event.endDate;
167
+ if (endDate) {
168
+ if (endDate.isDate) {
169
+ googleEvent.end.date = endDate.toJSDate().toISOString().split('T')[0];
170
+ } else {
171
+ googleEvent.end.dateTime = endDate.toJSDate().toISOString();
172
+ googleEvent.end.timeZone = endDate.zone?.tzid || Intl.DateTimeFormat().resolvedOptions().timeZone;
173
+ }
174
+ }
175
+
176
+ const attendees = vevent.getAllProperties('attendee');
177
+ if (attendees.length > 0) {
178
+ googleEvent.attendees = attendees.map(att => {
179
+ const emailValue = att.getFirstValue();
180
+ const email = (typeof emailValue === 'string' ? emailValue.replace('mailto:', '') : emailValue?.toString() || '');
181
+ const cn = att.getParameter('cn');
182
+ const displayName = Array.isArray(cn) ? cn[0] : cn;
183
+ return { email, displayName: displayName || undefined };
184
+ });
185
+ }
186
+
187
+ const rrule = vevent.getFirstPropertyValue('rrule');
188
+ if (rrule) {
189
+ googleEvent.recurrence = [`RRULE:${rrule.toString()}`];
190
+ }
191
+
192
+ await createEvent(accessToken, googleEvent, calendarId);
193
+ console.log(` + ${googleEvent.summary}`);
194
+ result.imported++;
195
+ } catch (e: any) {
196
+ const summary = vevent.getFirstPropertyValue('summary') || 'unknown';
197
+ result.errors.push(`${summary}: ${e.message}`);
198
+ console.error(` ! Failed: ${summary}`);
199
+ }
200
+ }
201
+ } catch (e: any) {
202
+ result.errors.push(`Parse error: ${e.message}`);
203
+ }
204
+
205
+ return result;
206
+ }
207
+
208
+ function showUsage(): void {
209
+ console.log(`
210
+ gcal - Google Calendar CLI
211
+
212
+ Usage:
213
+ gcal <file.ics> Import ICS file (file association)
214
+ gcal <command> [options] Run command
215
+
216
+ Commands:
217
+ list [n] List upcoming n events (default: 10)
218
+ add <title> <when> [duration] Add event
219
+ import <file.ics> Import events from ICS file
220
+ calendars List available calendars
221
+ help Show this help
222
+
223
+ Options:
224
+ -u, -user <email> Google account (one-time)
225
+ -defaultUser <email> Set default user for future use
226
+ -c, -calendar <id> Calendar ID (default: primary)
227
+ -n <count> Number of events to list
228
+
229
+ Examples:
230
+ gcal meeting.ics Import ICS file
231
+ gcal list List next 10 events
232
+ gcal add "Dentist" "Friday 3pm" "1h"
233
+ gcal -defaultUser bob@gmail.com Set default user
234
+
235
+ File Association (Windows):
236
+ assoc .ics=icsfile
237
+ ftype icsfile=gcal "%1"
238
+ `);
239
+ }
240
+
241
+ interface ParsedArgs {
242
+ command: string;
243
+ args: string[];
244
+ user: string;
245
+ defaultUser: string;
246
+ calendar: string;
247
+ count: number;
248
+ help: boolean;
249
+ icsFile: string; /** Direct .ics file path */
250
+ }
251
+
252
+ function parseArgs(argv: string[]): ParsedArgs {
253
+ const result: ParsedArgs = {
254
+ command: '',
255
+ args: [],
256
+ user: '',
257
+ defaultUser: '',
258
+ calendar: 'primary',
259
+ count: 10,
260
+ help: false,
261
+ icsFile: ''
262
+ };
263
+
264
+ const unknown: string[] = [];
265
+ let i = 0;
266
+ while (i < argv.length) {
267
+ const arg = argv[i];
268
+ switch (arg) {
269
+ case '-u':
270
+ case '-user':
271
+ case '--user':
272
+ result.user = argv[++i] || '';
273
+ break;
274
+ case '-defaultUser':
275
+ case '--defaultUser':
276
+ result.defaultUser = argv[++i] || '';
277
+ break;
278
+ case '-c':
279
+ case '-calendar':
280
+ case '--calendar':
281
+ result.calendar = argv[++i] || 'primary';
282
+ break;
283
+ case '-n':
284
+ result.count = parseInt(argv[++i]) || 10;
285
+ break;
286
+ case '-h':
287
+ case '-help':
288
+ case '--help':
289
+ case 'help':
290
+ result.help = true;
291
+ break;
292
+ default:
293
+ if (arg.startsWith('-')) {
294
+ unknown.push(arg);
295
+ } else if (!result.command) {
296
+ // Check if it's an .ics file
297
+ if (arg.toLowerCase().endsWith('.ics')) {
298
+ result.icsFile = arg;
299
+ result.command = 'import';
300
+ } else {
301
+ result.command = arg;
302
+ }
303
+ } else {
304
+ result.args.push(arg);
305
+ }
306
+ }
307
+ i++;
308
+ }
309
+
310
+ if (unknown.length > 0) {
311
+ console.error(`Unknown options: ${unknown.join(', ')}`);
312
+ process.exit(1);
313
+ }
314
+
315
+ return result;
316
+ }
317
+
318
+ function resolveUser(cliUser: string, setAsDefault = false): string {
319
+ if (cliUser) {
320
+ const normalized = normalizeUser(cliUser);
321
+ if (setAsDefault) {
322
+ const config = loadConfig();
323
+ config.lastUser = normalized;
324
+ saveConfig(config);
325
+ }
326
+ return normalized;
327
+ }
328
+
329
+ const config = loadConfig();
330
+ if (config.lastUser) {
331
+ return config.lastUser;
332
+ }
333
+
334
+ return '';
335
+ }
336
+
337
+ async function main(): Promise<void> {
338
+ setupAbortHandler();
339
+
340
+ const parsed = parseArgs(process.argv.slice(2));
341
+
342
+ // Handle -defaultUser first (can be combined with other commands)
343
+ if (parsed.defaultUser) {
344
+ const normalized = normalizeUser(parsed.defaultUser);
345
+ const config = loadConfig();
346
+ config.lastUser = normalized;
347
+ saveConfig(config);
348
+ console.log(`Default user set to: ${normalized}`);
349
+
350
+ // If no command, exit after setting default
351
+ if (!parsed.command && !parsed.icsFile) {
352
+ process.exit(0);
353
+ }
354
+ }
355
+
356
+ if (parsed.help) {
357
+ showUsage();
358
+ process.exit(0);
359
+ }
360
+
361
+ if (!parsed.command) {
362
+ showUsage();
363
+ process.exit(1);
364
+ }
365
+
366
+ // Resolve user
367
+ const user = resolveUser(parsed.user, false);
368
+ if (!user) {
369
+ console.error('No user configured.');
370
+ console.error('Use -u <email> for one-time, or -defaultUser <email> to set default.');
371
+ process.exit(1);
372
+ }
373
+
374
+ console.log(`${ts()} User: ${user}`);
375
+
376
+ switch (parsed.command) {
377
+ case 'import': {
378
+ const filePath = parsed.icsFile || parsed.args[0];
379
+ if (!filePath) {
380
+ console.error('Usage: gcal import <file.ics> or gcal <file.ics>');
381
+ process.exit(1);
382
+ }
383
+
384
+ const resolvedPath = path.resolve(filePath);
385
+ if (!fs.existsSync(resolvedPath)) {
386
+ console.error(`File not found: ${resolvedPath}`);
387
+ process.exit(1);
388
+ }
389
+
390
+ console.log(`Importing: ${path.basename(resolvedPath)}`);
391
+ console.log(`Calendar: ${parsed.calendar}\n`);
392
+
393
+ const token = await getAccessToken(user, true);
394
+ const result = await importIcsFile(resolvedPath, token, parsed.calendar);
395
+
396
+ console.log(`\n${result.imported} event(s) imported`);
397
+ if (result.errors.length > 0) {
398
+ console.log(`${result.errors.length} error(s)`);
399
+ }
400
+ break;
401
+ }
402
+
403
+ case 'list': {
404
+ const count = parsed.args[0] ? parseInt(parsed.args[0]) : parsed.count;
405
+ const token = await getAccessToken(user, false);
406
+ const events = await listEvents(token, parsed.calendar, count);
407
+
408
+ if (events.length === 0) {
409
+ console.log('No upcoming events found.');
410
+ } else {
411
+ console.log(`\nUpcoming events (${events.length}):\n`);
412
+ for (const event of events) {
413
+ const start = event.start ? formatDateTime(event.start) : '?';
414
+ const loc = event.location ? ` @ ${event.location}` : '';
415
+ console.log(` ${start} - ${event.summary || '(no title)'}${loc}`);
416
+ if (event.htmlLink) {
417
+ console.log(` ${event.htmlLink}`);
418
+ }
419
+ }
420
+ }
421
+ break;
422
+ }
423
+
424
+ case 'add': {
425
+ if (parsed.args.length < 2) {
426
+ console.error('Usage: gcal add <title> <when> [duration]');
427
+ console.error('Example: gcal add "Meeting" "tomorrow 2pm" "1h"');
428
+ process.exit(1);
429
+ }
430
+
431
+ const [title, when, duration = '1h'] = parsed.args;
432
+ const startTime = parseDateTime(when);
433
+ const durationMins = parseDuration(duration);
434
+ const endTime = new Date(startTime.getTime() + durationMins * 60 * 1000);
435
+
436
+ const event: GoogleEvent = {
437
+ summary: title,
438
+ start: {
439
+ dateTime: startTime.toISOString(),
440
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
441
+ },
442
+ end: {
443
+ dateTime: endTime.toISOString(),
444
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
445
+ }
446
+ };
447
+
448
+ const token = await getAccessToken(user, true);
449
+ const created = await createEvent(token, event, parsed.calendar);
450
+ console.log(`\nEvent created: ${created.summary}`);
451
+ console.log(` When: ${formatDateTime(created.start)} - ${formatDateTime(created.end)}`);
452
+ if (created.htmlLink) {
453
+ console.log(` Link: ${created.htmlLink}`);
454
+ }
455
+ break;
456
+ }
457
+
458
+ case 'calendars': {
459
+ const token = await getAccessToken(user, false);
460
+ const calendars = await listCalendars(token);
461
+
462
+ console.log(`\nCalendars (${calendars.length}):\n`);
463
+ for (const cal of calendars) {
464
+ const primary = cal.primary ? ' (primary)' : '';
465
+ const role = cal.accessRole ? ` [${cal.accessRole}]` : '';
466
+ console.log(` ${cal.summary || cal.id}${primary}${role}`);
467
+ console.log(` ID: ${cal.id}`);
468
+ }
469
+ break;
470
+ }
471
+
472
+ default:
473
+ console.error(`Unknown command: ${parsed.command}`);
474
+ showUsage();
475
+ process.exit(1);
476
+ }
477
+ }
478
+
479
+ if (import.meta.main) {
480
+ main().catch(e => {
481
+ console.error(`Error: ${e.message}`);
482
+ process.exit(1);
483
+ });
484
+ }
package/glib/gutils.ts ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Shared utilities for gcal tools
3
+ */
4
+
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import os from 'os';
8
+
9
+ export interface GcalConfig {
10
+ lastUser?: string;
11
+ defaultCalendar?: string;
12
+ }
13
+
14
+ export interface UserPaths {
15
+ userDir: string;
16
+ eventsDir: string;
17
+ tokenFile: string;
18
+ tokenWriteFile: string;
19
+ syncTokenFile: string;
20
+ configFile: string;
21
+ }
22
+
23
+ export const APP_DIR = path.dirname(import.meta.dirname); // Parent of glib/
24
+
25
+ /** Get the app directory (%APPDATA%\gcal or ~/.config/gcal) */
26
+ export function getAppDir(): string {
27
+ if (process.platform === 'win32') {
28
+ return path.join(process.env.APPDATA || os.homedir(), 'gcal');
29
+ }
30
+ return path.join(os.homedir(), '.config', 'gcal');
31
+ }
32
+
33
+ /** Get the data directory (app directory/data, or local data/ symlink for dev) */
34
+ export function getDataDir(): string {
35
+ const localData = path.join(APP_DIR, 'data');
36
+ if (fs.existsSync(localData)) {
37
+ return localData;
38
+ }
39
+ return path.join(getAppDir(), 'data');
40
+ }
41
+
42
+ export const DATA_DIR = getDataDir();
43
+ export const CONFIG_FILE = path.join(getAppDir(), 'config.json');
44
+
45
+ /** Use credentials from gcards (shared OAuth app) */
46
+ export const CREDENTIALS_FILE = path.join(path.dirname(APP_DIR), 'gcards', 'credentials.json');
47
+
48
+ export function loadConfig(): GcalConfig {
49
+ if (fs.existsSync(CONFIG_FILE)) {
50
+ try {
51
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
52
+ } catch (e: any) {
53
+ throw new Error(`Failed to parse ${CONFIG_FILE}: ${e.message}`);
54
+ }
55
+ }
56
+ return {};
57
+ }
58
+
59
+ export function saveConfig(config: GcalConfig): void {
60
+ const appDir = getAppDir();
61
+ if (!fs.existsSync(appDir)) {
62
+ fs.mkdirSync(appDir, { recursive: true });
63
+ }
64
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
65
+ }
66
+
67
+ export function getUserPaths(user: string): UserPaths {
68
+ const userDir = path.join(DATA_DIR, user);
69
+ return {
70
+ userDir,
71
+ eventsDir: path.join(userDir, 'events'),
72
+ tokenFile: path.join(userDir, 'token.json'),
73
+ tokenWriteFile: path.join(userDir, 'token-write.json'),
74
+ syncTokenFile: path.join(userDir, 'sync-token.json'),
75
+ configFile: path.join(userDir, 'config.json')
76
+ };
77
+ }
78
+
79
+ export function ensureUserDir(user: string): void {
80
+ const paths = getUserPaths(user);
81
+ if (!fs.existsSync(paths.userDir)) {
82
+ fs.mkdirSync(paths.userDir, { recursive: true });
83
+ }
84
+ if (!fs.existsSync(paths.eventsDir)) {
85
+ fs.mkdirSync(paths.eventsDir, { recursive: true });
86
+ }
87
+ }
88
+
89
+ export function normalizeUser(user: string): string {
90
+ return user.toLowerCase().split(/[+@]/)[0].replace(/\./g, '');
91
+ }
92
+
93
+ export function getAllUsers(): string[] {
94
+ if (!fs.existsSync(DATA_DIR)) {
95
+ return [];
96
+ }
97
+ return fs.readdirSync(DATA_DIR)
98
+ .filter(f => {
99
+ const fullPath = path.join(DATA_DIR, f);
100
+ return fs.statSync(fullPath).isDirectory() && !f.startsWith('.');
101
+ });
102
+ }
103
+
104
+ export function matchUsers(pattern: string): string[] {
105
+ const allUsers = getAllUsers();
106
+ const normalizedPattern = pattern.toLowerCase();
107
+ return allUsers.filter(user => user.toLowerCase().includes(normalizedPattern));
108
+ }
109
+
110
+ export function resolveUser(cliUser: string, setAsDefault = false): string {
111
+ if (cliUser && cliUser !== 'default') {
112
+ const normalized = normalizeUser(cliUser);
113
+ const matches = matchUsers(normalized);
114
+
115
+ if (matches.length === 1) {
116
+ const matched = matches[0];
117
+ console.log(`Matched '${cliUser}' to existing user: ${matched}`);
118
+ if (setAsDefault) {
119
+ const config = loadConfig();
120
+ config.lastUser = matched;
121
+ saveConfig(config);
122
+ }
123
+ return matched;
124
+ } else if (matches.length > 1) {
125
+ console.error(`Ambiguous user '${cliUser}' matches multiple users: ${matches.join(', ')}`);
126
+ console.error('Please be more specific.');
127
+ process.exit(1);
128
+ }
129
+
130
+ if (!cliUser.includes('@')) {
131
+ console.error(`New user '${cliUser}' must be specified as an email address (e.g., ${cliUser}@gmail.com)`);
132
+ process.exit(1);
133
+ }
134
+
135
+ if (setAsDefault) {
136
+ const config = loadConfig();
137
+ config.lastUser = normalized;
138
+ saveConfig(config);
139
+ }
140
+ return normalized;
141
+ }
142
+
143
+ const config = loadConfig();
144
+ if (config.lastUser) {
145
+ return config.lastUser;
146
+ }
147
+
148
+ const allUsers = getAllUsers();
149
+ if (allUsers.length === 1) {
150
+ const onlyUser = allUsers[0];
151
+ console.log(`Auto-selected only user: ${onlyUser}`);
152
+ config.lastUser = onlyUser;
153
+ saveConfig(config);
154
+ return onlyUser;
155
+ }
156
+
157
+ if (allUsers.length > 1) {
158
+ console.error(`Multiple users exist: ${allUsers.join(', ')}`);
159
+ console.error('Use -u <name> to select one.');
160
+ } else {
161
+ console.error('No user specified. Use -u <email> on first run (e.g., your Gmail address).');
162
+ }
163
+ process.exit(1);
164
+ }
165
+
166
+ /** Format datetime for display */
167
+ export function formatDateTime(dt: { date?: string; dateTime?: string; timeZone?: string }): string {
168
+ if (dt.date) {
169
+ return dt.date; // All-day event
170
+ }
171
+ if (dt.dateTime) {
172
+ const d = new Date(dt.dateTime);
173
+ return d.toLocaleString();
174
+ }
175
+ return '(no time)';
176
+ }
177
+
178
+ /** Parse duration string like "1h", "30m", "1h30m" to minutes */
179
+ export function parseDuration(duration: string): number {
180
+ let minutes = 0;
181
+ const hourMatch = duration.match(/(\d+)h/i);
182
+ const minMatch = duration.match(/(\d+)m/i);
183
+ if (hourMatch) minutes += parseInt(hourMatch[1]) * 60;
184
+ if (minMatch) minutes += parseInt(minMatch[1]);
185
+ if (!hourMatch && !minMatch) {
186
+ minutes = parseInt(duration) || 60; // Default 60 minutes
187
+ }
188
+ return minutes;
189
+ }
190
+
191
+ /** Parse natural date/time strings */
192
+ export function parseDateTime(input: string): Date {
193
+ const now = new Date();
194
+ const lower = input.toLowerCase().trim();
195
+
196
+ // Handle relative dates
197
+ if (lower === 'today') {
198
+ return now;
199
+ }
200
+ if (lower === 'tomorrow') {
201
+ const d = new Date(now);
202
+ d.setDate(d.getDate() + 1);
203
+ return d;
204
+ }
205
+
206
+ // Handle "tomorrow 2pm" or "tomorrow at 2pm"
207
+ const relMatch = lower.match(/^(today|tomorrow)\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i);
208
+ if (relMatch) {
209
+ const [, rel, hour, min, ampm] = relMatch;
210
+ const d = new Date(now);
211
+ if (rel === 'tomorrow') d.setDate(d.getDate() + 1);
212
+ let h = parseInt(hour);
213
+ if (ampm?.toLowerCase() === 'pm' && h < 12) h += 12;
214
+ if (ampm?.toLowerCase() === 'am' && h === 12) h = 0;
215
+ d.setHours(h, parseInt(min || '0'), 0, 0);
216
+ return d;
217
+ }
218
+
219
+ // Handle weekday names
220
+ const weekdays = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
221
+ const dayMatch = lower.match(/^(next\s+)?(sunday|monday|tuesday|wednesday|thursday|friday|saturday)\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i);
222
+ if (dayMatch) {
223
+ const [, next, day, hour, min, ampm] = dayMatch;
224
+ const targetDay = weekdays.indexOf(day.toLowerCase());
225
+ const d = new Date(now);
226
+ let daysUntil = targetDay - d.getDay();
227
+ if (daysUntil <= 0 || next) daysUntil += 7;
228
+ d.setDate(d.getDate() + daysUntil);
229
+ let h = parseInt(hour);
230
+ if (ampm?.toLowerCase() === 'pm' && h < 12) h += 12;
231
+ if (ampm?.toLowerCase() === 'am' && h === 12) h = 0;
232
+ d.setHours(h, parseInt(min || '0'), 0, 0);
233
+ return d;
234
+ }
235
+
236
+ // Try native Date parsing
237
+ const parsed = new Date(input);
238
+ if (!isNaN(parsed.getTime())) {
239
+ return parsed;
240
+ }
241
+
242
+ throw new Error(`Cannot parse date/time: ${input}`);
243
+ }
244
+
245
+ /** Timestamp for logging */
246
+ export function ts(): string {
247
+ const now = new Date();
248
+ return `[${now.toTimeString().slice(0, 8)}]`;
249
+ }
package/glib/types.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Google Calendar API types
3
+ */
4
+
5
+ /** Google Calendar Event */
6
+ export interface GoogleEvent {
7
+ kind?: string;
8
+ etag?: string;
9
+ id?: string;
10
+ status?: string;
11
+ htmlLink?: string;
12
+ created?: string;
13
+ updated?: string;
14
+ summary?: string;
15
+ description?: string;
16
+ location?: string;
17
+ colorId?: string;
18
+ creator?: {
19
+ id?: string;
20
+ email?: string;
21
+ displayName?: string;
22
+ self?: boolean;
23
+ };
24
+ organizer?: {
25
+ id?: string;
26
+ email?: string;
27
+ displayName?: string;
28
+ self?: boolean;
29
+ };
30
+ start?: EventDateTime;
31
+ end?: EventDateTime;
32
+ endTimeUnspecified?: boolean;
33
+ recurrence?: string[];
34
+ recurringEventId?: string;
35
+ originalStartTime?: EventDateTime;
36
+ transparency?: string;
37
+ visibility?: string;
38
+ iCalUID?: string;
39
+ sequence?: number;
40
+ attendees?: EventAttendee[];
41
+ attendeesOmitted?: boolean;
42
+ hangoutLink?: string;
43
+ conferenceData?: ConferenceData;
44
+ reminders?: {
45
+ useDefault?: boolean;
46
+ overrides?: EventReminder[];
47
+ };
48
+ source?: {
49
+ url?: string;
50
+ title?: string;
51
+ };
52
+ attachments?: EventAttachment[];
53
+ eventType?: string;
54
+ }
55
+
56
+ export interface EventDateTime {
57
+ date?: string; /** Date in YYYY-MM-DD format (all-day event) */
58
+ dateTime?: string; /** RFC3339 timestamp with timezone */
59
+ timeZone?: string; /** IANA timezone */
60
+ }
61
+
62
+ export interface EventAttendee {
63
+ id?: string;
64
+ email?: string;
65
+ displayName?: string;
66
+ organizer?: boolean;
67
+ self?: boolean;
68
+ resource?: boolean;
69
+ optional?: boolean;
70
+ responseStatus?: 'needsAction' | 'declined' | 'tentative' | 'accepted';
71
+ comment?: string;
72
+ additionalGuests?: number;
73
+ }
74
+
75
+ export interface EventReminder {
76
+ method?: 'email' | 'popup';
77
+ minutes?: number;
78
+ }
79
+
80
+ export interface EventAttachment {
81
+ fileUrl?: string;
82
+ title?: string;
83
+ mimeType?: string;
84
+ iconLink?: string;
85
+ fileId?: string;
86
+ }
87
+
88
+ export interface ConferenceData {
89
+ createRequest?: {
90
+ requestId?: string;
91
+ conferenceSolutionKey?: { type?: string };
92
+ status?: { statusCode?: string };
93
+ };
94
+ entryPoints?: {
95
+ entryPointType?: string;
96
+ uri?: string;
97
+ label?: string;
98
+ pin?: string;
99
+ accessCode?: string;
100
+ meetingCode?: string;
101
+ passcode?: string;
102
+ password?: string;
103
+ }[];
104
+ conferenceSolution?: {
105
+ key?: { type?: string };
106
+ name?: string;
107
+ iconUri?: string;
108
+ };
109
+ conferenceId?: string;
110
+ signature?: string;
111
+ notes?: string;
112
+ }
113
+
114
+ /** Google Calendar list entry */
115
+ export interface CalendarListEntry {
116
+ kind?: string;
117
+ etag?: string;
118
+ id?: string;
119
+ summary?: string;
120
+ description?: string;
121
+ location?: string;
122
+ timeZone?: string;
123
+ summaryOverride?: string;
124
+ colorId?: string;
125
+ backgroundColor?: string;
126
+ foregroundColor?: string;
127
+ hidden?: boolean;
128
+ selected?: boolean;
129
+ accessRole?: 'freeBusyReader' | 'reader' | 'writer' | 'owner';
130
+ defaultReminders?: EventReminder[];
131
+ notificationSettings?: {
132
+ notifications?: {
133
+ type?: string;
134
+ method?: string;
135
+ }[];
136
+ };
137
+ primary?: boolean;
138
+ deleted?: boolean;
139
+ conferenceProperties?: {
140
+ allowedConferenceSolutionTypes?: string[];
141
+ };
142
+ }
143
+
144
+ /** Events list response from API */
145
+ export interface EventsListResponse {
146
+ kind?: string;
147
+ etag?: string;
148
+ summary?: string;
149
+ description?: string;
150
+ updated?: string;
151
+ timeZone?: string;
152
+ accessRole?: string;
153
+ defaultReminders?: EventReminder[];
154
+ nextPageToken?: string;
155
+ nextSyncToken?: string;
156
+ items?: GoogleEvent[];
157
+ }
158
+
159
+ /** Calendar list response from API */
160
+ export interface CalendarListResponse {
161
+ kind?: string;
162
+ etag?: string;
163
+ nextPageToken?: string;
164
+ nextSyncToken?: string;
165
+ items?: CalendarListEntry[];
166
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@bobfrankston/gcal",
3
+ "version": "0.1.5",
4
+ "description": "Google Calendar CLI tool with ICS import support",
5
+ "type": "module",
6
+ "main": "gcal.ts",
7
+ "bin": {
8
+ "gcal": "gcal.ts"
9
+ },
10
+ "files": [
11
+ "gcal.ts",
12
+ "glib/**/*.ts",
13
+ "tsconfig.json",
14
+ "README.md"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/BobFrankston/gcal.git"
19
+ },
20
+ "scripts": {
21
+ "check": "tsc --noEmit",
22
+ "prerelease:local": "git add -A && (git diff-index --quiet HEAD || git commit -m \"Pre-release commit\")",
23
+ "preversion": "npm run check && git add -A",
24
+ "postversion": "git push && git push --tags",
25
+ "release": "npm run prerelease:local && npm version patch && npm publish --access public",
26
+ "release:local": "npm run prerelease:local && npm version patch && npm publish --access public --ignore-scripts"
27
+ },
28
+ "keywords": [
29
+ "google",
30
+ "calendar",
31
+ "ics",
32
+ "ical",
33
+ "oauth",
34
+ "cli"
35
+ ],
36
+ "author": "Bob Frankston",
37
+ "license": "MIT",
38
+ "devDependencies": {
39
+ "@types/node": "^25.0.3"
40
+ },
41
+ "dependencies": {
42
+ "@bobfrankston/oauthsupport": "^1.0.1",
43
+ "ical.js": "^2.1.0"
44
+ }
45
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "allowSyntheticDefaultImports": true,
7
+ "esModuleInterop": true,
8
+ "allowJs": true,
9
+ "allowImportingTsExtensions": true,
10
+ "strict": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "skipLibCheck": true,
13
+ "noEmit": true,
14
+ "strictNullChecks": false,
15
+ "noImplicitAny": true,
16
+ "noImplicitReturns": false,
17
+ "noImplicitThis": true,
18
+ "newLine": "lf"
19
+ },
20
+ "exclude": [
21
+ "node_modules",
22
+ "cruft",
23
+ ".git",
24
+ "tests",
25
+ "prev"
26
+ ]
27
+ }