@bobfrankston/gcal 0.1.5 → 0.1.7

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.
Files changed (3) hide show
  1. package/gcal.ts +91 -8
  2. package/glib/gutils.ts +57 -3
  3. package/package.json +1 -1
package/gcal.ts CHANGED
@@ -14,7 +14,7 @@ import { authenticateOAuth } from '@bobfrankston/oauthsupport';
14
14
  import type { GoogleEvent, EventsListResponse, CalendarListEntry, CalendarListResponse } from './glib/types.ts';
15
15
  import {
16
16
  CREDENTIALS_FILE, loadConfig, saveConfig, getUserPaths,
17
- ensureUserDir, formatDateTime, parseDuration, parseDateTime, ts, normalizeUser
17
+ ensureUserDir, formatDateTime, formatDuration, parseDuration, parseDateTime, ts, normalizeUser
18
18
  } from './glib/gutils.ts';
19
19
 
20
20
  const CALENDAR_API_BASE = 'https://www.googleapis.com/calendar/v3';
@@ -125,6 +125,19 @@ async function createEvent(
125
125
  return await res.json() as GoogleEvent;
126
126
  }
127
127
 
128
+ async function deleteEvent(
129
+ accessToken: string,
130
+ eventId: string,
131
+ calendarId = 'primary'
132
+ ): Promise<void> {
133
+ const url = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`;
134
+ const res = await apiFetch(url, accessToken, { method: 'DELETE' });
135
+ if (!res.ok) {
136
+ const errText = await res.text();
137
+ throw new Error(`Failed to delete event: ${res.status} ${errText}`);
138
+ }
139
+ }
140
+
128
141
  async function importIcsFile(
129
142
  filePath: string,
130
143
  accessToken: string,
@@ -216,6 +229,7 @@ Usage:
216
229
  Commands:
217
230
  list [n] List upcoming n events (default: 10)
218
231
  add <title> <when> [duration] Add event
232
+ del|delete <id> [id2...] Delete event(s) by ID (prefix match)
219
233
  import <file.ics> Import events from ICS file
220
234
  calendars List available calendars
221
235
  help Show this help
@@ -225,12 +239,15 @@ Options:
225
239
  -defaultUser <email> Set default user for future use
226
240
  -c, -calendar <id> Calendar ID (default: primary)
227
241
  -n <count> Number of events to list
242
+ -v, -verbose Show event IDs and links
228
243
 
229
244
  Examples:
230
- gcal meeting.ics Import ICS file
231
- gcal list List next 10 events
245
+ gcal meeting.ics Import ICS file
246
+ gcal list List next 10 events
232
247
  gcal add "Dentist" "Friday 3pm" "1h"
233
- gcal -defaultUser bob@gmail.com Set default user
248
+ gcal add "Lunch" "1/14/2026 12:00" "1h"
249
+ gcal add "Meeting" "tomorrow 10:00"
250
+ gcal -defaultUser bob@gmail.com Set default user
234
251
 
235
252
  File Association (Windows):
236
253
  assoc .ics=icsfile
@@ -246,6 +263,7 @@ interface ParsedArgs {
246
263
  calendar: string;
247
264
  count: number;
248
265
  help: boolean;
266
+ verbose: boolean;
249
267
  icsFile: string; /** Direct .ics file path */
250
268
  }
251
269
 
@@ -258,6 +276,7 @@ function parseArgs(argv: string[]): ParsedArgs {
258
276
  calendar: 'primary',
259
277
  count: 10,
260
278
  help: false,
279
+ verbose: false,
261
280
  icsFile: ''
262
281
  };
263
282
 
@@ -283,6 +302,11 @@ function parseArgs(argv: string[]): ParsedArgs {
283
302
  case '-n':
284
303
  result.count = parseInt(argv[++i]) || 10;
285
304
  break;
305
+ case '-v':
306
+ case '-verbose':
307
+ case '--verbose':
308
+ result.verbose = true;
309
+ break;
286
310
  case '-h':
287
311
  case '-help':
288
312
  case '--help':
@@ -409,14 +433,40 @@ async function main(): Promise<void> {
409
433
  console.log('No upcoming events found.');
410
434
  } else {
411
435
  console.log(`\nUpcoming events (${events.length}):\n`);
436
+
437
+ // Build table data
438
+ const rows: string[][] = [];
412
439
  for (const event of events) {
440
+ const shortId = (event.id || '').slice(0, 8);
413
441
  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}`);
442
+ const duration = (event.start && event.end) ? formatDuration(event.start, event.end) : '';
443
+ const summary = event.summary || '(no title)';
444
+ const loc = event.location || '';
445
+ if (parsed.verbose) {
446
+ rows.push([shortId, start, duration, summary, loc, event.htmlLink || '']);
447
+ } else {
448
+ rows.push([shortId, start, duration, summary, loc]);
418
449
  }
419
450
  }
451
+
452
+ // Calculate column widths
453
+ const headers = parsed.verbose
454
+ ? ['ID', 'When', 'Dur', 'Event', 'Location', 'Link']
455
+ : ['ID', 'When', 'Dur', 'Event', 'Location'];
456
+ const colWidths = headers.map((h, i) =>
457
+ Math.max(h.length, ...rows.map(r => (r[i] || '').length))
458
+ );
459
+
460
+ // Print header
461
+ const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
462
+ console.log(headerLine);
463
+ console.log(colWidths.map(w => '-'.repeat(w)).join(' '));
464
+
465
+ // Print rows
466
+ for (const row of rows) {
467
+ const line = row.map((cell, i) => (cell || '').padEnd(colWidths[i])).join(' ');
468
+ console.log(line);
469
+ }
420
470
  }
421
471
  break;
422
472
  }
@@ -455,6 +505,39 @@ async function main(): Promise<void> {
455
505
  break;
456
506
  }
457
507
 
508
+ case 'del':
509
+ case 'delete': {
510
+ if (parsed.args.length === 0) {
511
+ console.error('Usage: gcal delete <id> [id2] [id3] ...');
512
+ console.error('Use "gcal list" to see event IDs');
513
+ process.exit(1);
514
+ }
515
+
516
+ const token = await getAccessToken(user, true);
517
+ const events = await listEvents(token, parsed.calendar, 50);
518
+
519
+ for (const idPrefix of parsed.args) {
520
+ const matches = events.filter(e => e.id?.startsWith(idPrefix));
521
+
522
+ if (matches.length === 0) {
523
+ console.error(`${idPrefix}: not found`);
524
+ continue;
525
+ }
526
+ if (matches.length > 1) {
527
+ console.error(`${idPrefix}: ambiguous (${matches.length} matches)`);
528
+ for (const e of matches) {
529
+ console.error(` ${e.id?.slice(0, 8)} - ${e.summary}`);
530
+ }
531
+ continue;
532
+ }
533
+
534
+ const event = matches[0];
535
+ await deleteEvent(token, event.id!, parsed.calendar);
536
+ console.log(`Deleted: ${event.summary}`);
537
+ }
538
+ break;
539
+ }
540
+
458
541
  case 'calendars': {
459
542
  const token = await getAccessToken(user, false);
460
543
  const calendars = await listCalendars(token);
package/glib/gutils.ts CHANGED
@@ -163,18 +163,44 @@ export function resolveUser(cliUser: string, setAsDefault = false): string {
163
163
  process.exit(1);
164
164
  }
165
165
 
166
- /** Format datetime for display */
166
+ /** Format datetime for display - yyyy-mm-dd HH:mm format */
167
167
  export function formatDateTime(dt: { date?: string; dateTime?: string; timeZone?: string }): string {
168
168
  if (dt.date) {
169
- return dt.date; // All-day event
169
+ return dt.date; // Already yyyy-mm-dd
170
170
  }
171
171
  if (dt.dateTime) {
172
172
  const d = new Date(dt.dateTime);
173
- return d.toLocaleString();
173
+ const yyyy = d.getFullYear();
174
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
175
+ const dd = String(d.getDate()).padStart(2, '0');
176
+ const hh = String(d.getHours()).padStart(2, '0');
177
+ const min = String(d.getMinutes()).padStart(2, '0');
178
+ return `${yyyy}-${mm}-${dd} ${hh}:${min}`;
174
179
  }
175
180
  return '(no time)';
176
181
  }
177
182
 
183
+ /** Format duration in hours (e.g., "1.5 hrs", "2 hrs", "30 min") */
184
+ export function formatDuration(start: { date?: string; dateTime?: string }, end: { date?: string; dateTime?: string }): string {
185
+ if (start.date || end.date) {
186
+ return ''; // All-day event
187
+ }
188
+ if (!start.dateTime || !end.dateTime) {
189
+ return '';
190
+ }
191
+ const startMs = new Date(start.dateTime).getTime();
192
+ const endMs = new Date(end.dateTime).getTime();
193
+ const mins = (endMs - startMs) / 60000;
194
+ if (mins < 60) {
195
+ return `${mins} min`;
196
+ }
197
+ const hrs = mins / 60;
198
+ if (hrs === Math.floor(hrs)) {
199
+ return `${hrs} hr${hrs !== 1 ? 's' : ''}`;
200
+ }
201
+ return `${hrs.toFixed(1)} hrs`;
202
+ }
203
+
178
204
  /** Parse duration string like "1h", "30m", "1h30m" to minutes */
179
205
  export function parseDuration(duration: string): number {
180
206
  let minutes = 0;
@@ -233,6 +259,34 @@ export function parseDateTime(input: string): Date {
233
259
  return d;
234
260
  }
235
261
 
262
+ // Handle explicit date/time formats: "MM/DD/YYYY HH:mm" or "YYYY-MM-DD HH:mm"
263
+ const dateTimeMatch = input.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2})$/);
264
+ if (dateTimeMatch) {
265
+ const [, month, day, year, hour, min] = dateTimeMatch;
266
+ const d = new Date(parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(min));
267
+ if (!isNaN(d.getTime())) {
268
+ return d;
269
+ }
270
+ }
271
+
272
+ const isoDateTimeMatch = input.match(/^(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2})$/);
273
+ if (isoDateTimeMatch) {
274
+ const [, year, month, day, hour, min] = isoDateTimeMatch;
275
+ const d = new Date(parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(min));
276
+ if (!isNaN(d.getTime())) {
277
+ return d;
278
+ }
279
+ }
280
+
281
+ // Handle time only (HH:mm) - assume today
282
+ const timeMatch = input.match(/^(\d{1,2}):(\d{2})$/);
283
+ if (timeMatch) {
284
+ const [, hour, min] = timeMatch;
285
+ const d = new Date(now);
286
+ d.setHours(parseInt(hour), parseInt(min), 0, 0);
287
+ return d;
288
+ }
289
+
236
290
  // Try native Date parsing
237
291
  const parsed = new Date(input);
238
292
  if (!isNaN(parsed.getTime())) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/gcal",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Google Calendar CLI tool with ICS import support",
5
5
  "type": "module",
6
6
  "main": "gcal.ts",