@bobfrankston/gcal 0.1.45 → 0.1.47

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/gtask.ts ADDED
@@ -0,0 +1,459 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gtask - Google Tasks CLI tool
4
+ * Sibling to gcal. Shares OAuth credentials, scopes, and token files.
5
+ */
6
+
7
+ import { createInterface } from 'readline/promises';
8
+ import {
9
+ loadConfig, saveConfig, parseDateTime, formatYMD, normalizeUser
10
+ } from './glib/gutils.js';
11
+ import { setupAbortHandler, getAccessToken } from './glib/goauth.js';
12
+ import {
13
+ listTaskLists, listTasks, createTask, patchTask, deleteTask,
14
+ moveTask, clearCompleted, resolveTaskList
15
+ } from './glib/tasksapi.js';
16
+ import type { Task } from './glib/tasktypes.js';
17
+
18
+ import pkg from './package.json' with { type: 'json' };
19
+ const VERSION: string = pkg.version;
20
+
21
+ interface ParsedArgs {
22
+ command: string;
23
+ args: string[];
24
+ user: string;
25
+ list: string; /** -l <listname|id> */
26
+ notes: string; /** -n <notes> */
27
+ title: string; /** -t <title> for edit */
28
+ when: string; /** -when <date> for edit */
29
+ showAll: boolean; /** -a: include completed */
30
+ help: boolean;
31
+ helpCmd: string; /** `gtask help <cmd>` */
32
+ }
33
+
34
+ const USAGE_SUMMARY = `gtask v${VERSION} - Google Tasks CLI
35
+
36
+ Usage: gtask <command> [options]
37
+ gtask help <command> Detailed help for a command
38
+
39
+ Commands:
40
+ add <title> [when] Add a task (optional due date)
41
+ list List open tasks
42
+ lists List all tasklists
43
+ done <id> Mark task completed (id prefix)
44
+ undone <id> Reopen a completed task
45
+ del <id> Delete a task
46
+ edit <id> [-t title] [-when date] [-n notes]
47
+ clear Remove all completed tasks from list
48
+ move <id> -l <list> Move task to another tasklist
49
+ help [command] Show help
50
+
51
+ Global options:
52
+ -u, -user <email> Set / use Google account
53
+ -l, -list <name|id> Tasklist (default: primary)
54
+ `;
55
+
56
+ const USAGE: Record<string, string> = {
57
+ add: `gtask add <title> [when] [-l <list>] [-n <notes>]
58
+ Add a task. <when> is an optional due date (date-only; time is ignored
59
+ by Google Tasks). Wrap multi-word titles in quotes.
60
+
61
+ Examples:
62
+ gtask add "Write report"
63
+ gtask add "Write report" friday
64
+ gtask add "Pay bills" "april 30" -n "rent + utilities"
65
+ gtask add "Call plumber" tomorrow -l Errands
66
+ `,
67
+ list: `gtask list [-l <list>] [-a] [-since <date>] [-till <date>]
68
+ List open tasks in a tasklist.
69
+ -a Include completed tasks
70
+ -since <date> Only tasks due from <date> forward
71
+ -till <date> Only tasks due up to <date>
72
+
73
+ Examples:
74
+ gtask list
75
+ gtask list -l Errands
76
+ gtask list -a
77
+ gtask list -since "april 1" -till "may 1"
78
+ `,
79
+ lists: `gtask lists
80
+ Show all tasklists with their IDs.
81
+ `,
82
+ done: `gtask done <id>
83
+ Mark a task completed. <id> is a prefix of the task's ID (as shown by
84
+ gtask list).
85
+
86
+ Example:
87
+ gtask done abc12345
88
+ `,
89
+ undone: `gtask undone <id>
90
+ Reopen a completed task (sets status back to needsAction).
91
+ Note: requires -a on a previous list, or knowing the id.
92
+ `,
93
+ del: `gtask del <id> [-l <list>]
94
+ Delete a task by id prefix.
95
+ `,
96
+ edit: `gtask edit <id> [-t <title>] [-when <date>] [-n <notes>] [-l <list>]
97
+ Update fields of an existing task. Only supplied fields change.
98
+
99
+ Examples:
100
+ gtask edit abc12345 -t "Write final report"
101
+ gtask edit abc12345 -when "next monday"
102
+ gtask edit abc12345 -n "draft attached"
103
+ `,
104
+ clear: `gtask clear [-l <list>]
105
+ Permanently remove all completed tasks from a tasklist.
106
+ `,
107
+ move: `gtask move <id> -l <destination-list>
108
+ Move a task to a different tasklist.
109
+ `,
110
+ help: `gtask help [command]
111
+ Show summary, or detailed help for a single command.
112
+ `
113
+ };
114
+
115
+ function showUsage(cmd?: string): void {
116
+ if (cmd && USAGE[cmd]) {
117
+ console.log(USAGE[cmd]);
118
+ return;
119
+ }
120
+ if (cmd) {
121
+ console.error(`Unknown command: ${cmd}\n`);
122
+ }
123
+ console.log(USAGE_SUMMARY);
124
+ }
125
+
126
+ function parseArgs(argv: string[]): ParsedArgs {
127
+ const result: ParsedArgs = {
128
+ command: '',
129
+ args: [],
130
+ user: '',
131
+ list: '',
132
+ notes: '',
133
+ title: '',
134
+ when: '',
135
+ showAll: false,
136
+ help: false,
137
+ helpCmd: ''
138
+ };
139
+
140
+ const unknown: string[] = [];
141
+ let i = 0;
142
+ while (i < argv.length) {
143
+ const arg = argv[i];
144
+ switch (arg) {
145
+ case '-u':
146
+ case '-user':
147
+ case '--user':
148
+ result.user = argv[++i] || '';
149
+ break;
150
+ case '-l':
151
+ case '-list':
152
+ case '--list':
153
+ result.list = argv[++i] || '';
154
+ break;
155
+ case '-n':
156
+ case '-notes':
157
+ case '--notes':
158
+ result.notes = argv[++i] || '';
159
+ break;
160
+ case '-t':
161
+ case '-title':
162
+ case '--title':
163
+ result.title = argv[++i] || '';
164
+ break;
165
+ case '-when':
166
+ case '--when':
167
+ result.when = argv[++i] || '';
168
+ break;
169
+ case '-a':
170
+ case '-all':
171
+ case '--all':
172
+ result.showAll = true;
173
+ break;
174
+ case '-h':
175
+ case '-help':
176
+ case '--help':
177
+ result.help = true;
178
+ break;
179
+ case '-V':
180
+ case '-version':
181
+ case '--version':
182
+ console.log(`gtask v${VERSION}`);
183
+ process.exit(0);
184
+ default:
185
+ if (arg.startsWith('-')) {
186
+ unknown.push(arg);
187
+ } else if (!result.command) {
188
+ result.command = arg;
189
+ } else {
190
+ result.args.push(arg);
191
+ }
192
+ }
193
+ i++;
194
+ }
195
+
196
+ if (unknown.length > 0) {
197
+ console.error(`Unknown options: ${unknown.join(', ')}`);
198
+ process.exit(1);
199
+ }
200
+
201
+ if (result.command === 'help') {
202
+ result.help = true;
203
+ result.helpCmd = result.args[0] || '';
204
+ }
205
+
206
+ return result;
207
+ }
208
+
209
+ function resolveUser(cliUser: string): string {
210
+ if (cliUser) return normalizeUser(cliUser);
211
+ const config = loadConfig();
212
+ return config.lastUser || '';
213
+ }
214
+
215
+ function dueToYMD(due: string | undefined): string {
216
+ if (!due) return '';
217
+ return due.slice(0, 10);
218
+ }
219
+
220
+ function formatTaskRow(t: Task): string[] {
221
+ const id = (t.id || '').slice(0, 8);
222
+ const status = t.status === 'completed' ? '*' : ' ';
223
+ const due = dueToYMD(t.due);
224
+ const title = t.title || '(no title)';
225
+ const notes = (t.notes || '').replace(/\n/g, ' ').slice(0, 60);
226
+ return [status, id, due, title, notes];
227
+ }
228
+
229
+ function printTaskTable(tasks: Task[]): void {
230
+ if (tasks.length === 0) {
231
+ console.log('No tasks.');
232
+ return;
233
+ }
234
+ const headers = [' ', 'ID', 'Due', 'Title', 'Notes'];
235
+ const rows = tasks.map(formatTaskRow);
236
+ const widths = headers.map((h, i) =>
237
+ Math.max(h.length, ...rows.map(r => (r[i] || '').length))
238
+ );
239
+ const last = headers.length - 1;
240
+ const pad = (s: string, i: number) => i === last ? s : s.padEnd(widths[i]);
241
+ console.log(headers.map(pad).join(' '));
242
+ console.log(widths.map(w => '-'.repeat(w)).join(' '));
243
+ for (const r of rows) {
244
+ console.log(r.map((c, i) => pad(c || '', i)).join(' '));
245
+ }
246
+ }
247
+
248
+ /** Build a date-only RFC3339 due value (Google ignores the time portion). */
249
+ function buildDue(whenArg: string): string {
250
+ const d = parseDateTime(whenArg);
251
+ return `${formatYMD(d)}T00:00:00.000Z`;
252
+ }
253
+
254
+ async function findTask(
255
+ accessToken: string,
256
+ tasklistId: string,
257
+ idPrefix: string,
258
+ includeCompleted: boolean
259
+ ): Promise<Task> {
260
+ const tasks = await listTasks(accessToken, tasklistId, {
261
+ showCompleted: includeCompleted,
262
+ showHidden: includeCompleted,
263
+ maxResults: 100
264
+ });
265
+ const matches = tasks.filter(t => t.id?.startsWith(idPrefix));
266
+ if (matches.length === 0) throw new Error(`${idPrefix}: not found`);
267
+ if (matches.length > 1) {
268
+ const list = matches.map(t => ` ${(t.id || '').slice(0, 8)} - ${t.title}`).join('\n');
269
+ throw new Error(`${idPrefix}: ambiguous (${matches.length} matches)\n${list}`);
270
+ }
271
+ return matches[0];
272
+ }
273
+
274
+ async function main(): Promise<void> {
275
+ setupAbortHandler();
276
+ const parsed = parseArgs(process.argv.slice(2));
277
+
278
+ if (parsed.user) {
279
+ const normalized = normalizeUser(parsed.user);
280
+ const config = loadConfig();
281
+ config.lastUser = normalized;
282
+ saveConfig(config);
283
+ console.log(`Default user set to: ${normalized}`);
284
+ if (!parsed.command) process.exit(0);
285
+ }
286
+
287
+ if (parsed.help) {
288
+ showUsage(parsed.helpCmd);
289
+ process.exit(0);
290
+ }
291
+
292
+ if (!parsed.command) {
293
+ showUsage();
294
+ process.exit(1);
295
+ }
296
+
297
+ const user = resolveUser(parsed.user);
298
+ if (!user) {
299
+ console.error('No user configured. Use -u <email> to set default user.');
300
+ process.exit(1);
301
+ }
302
+
303
+ switch (parsed.command) {
304
+ case 'lists': {
305
+ const token = await getAccessToken(user, false);
306
+ const lists = await listTaskLists(token);
307
+ console.log(`\nTasklists (${lists.length}):\n`);
308
+ for (const l of lists) {
309
+ console.log(` ${l.title}`);
310
+ console.log(` ID: ${l.id}`);
311
+ }
312
+ break;
313
+ }
314
+
315
+ case 'list': {
316
+ const token = await getAccessToken(user, false);
317
+ const tl = await resolveTaskList(token, parsed.list);
318
+ const tasks = await listTasks(token, tl.id!, {
319
+ showCompleted: parsed.showAll,
320
+ showHidden: parsed.showAll,
321
+ maxResults: 100
322
+ });
323
+ console.log(`\n${tl.title} (${tasks.length}):\n`);
324
+ printTaskTable(tasks);
325
+ break;
326
+ }
327
+
328
+ case 'add': {
329
+ if (parsed.args.length < 1) {
330
+ showUsage('add');
331
+ process.exit(1);
332
+ }
333
+ const [title, when] = parsed.args;
334
+ const task: Task = { title };
335
+ if (when) task.due = buildDue(when);
336
+ if (parsed.notes) task.notes = parsed.notes;
337
+
338
+ const token = await getAccessToken(user, true);
339
+ const tl = await resolveTaskList(token, parsed.list);
340
+ const created = await createTask(token, task, tl.id!);
341
+ console.log(`\nTask created in ${tl.title}: ${created.title}`);
342
+ if (created.due) console.log(` Due: ${dueToYMD(created.due)}`);
343
+ if (created.notes) console.log(` Notes: ${created.notes}`);
344
+ console.log(` ID: ${(created.id || '').slice(0, 8)}`);
345
+ break;
346
+ }
347
+
348
+ case 'done': {
349
+ if (parsed.args.length < 1) { showUsage('done'); process.exit(1); }
350
+ const token = await getAccessToken(user, true);
351
+ const tl = await resolveTaskList(token, parsed.list);
352
+ const task = await findTask(token, tl.id!, parsed.args[0], false);
353
+ const updated = await patchTask(token, task.id!, { status: 'completed' }, tl.id!);
354
+ console.log(`Completed: ${updated.title}`);
355
+ break;
356
+ }
357
+
358
+ case 'undone':
359
+ case 'reopen': {
360
+ if (parsed.args.length < 1) { showUsage('undone'); process.exit(1); }
361
+ const token = await getAccessToken(user, true);
362
+ const tl = await resolveTaskList(token, parsed.list);
363
+ const task = await findTask(token, tl.id!, parsed.args[0], true);
364
+ const updated = await patchTask(
365
+ token, task.id!,
366
+ { status: 'needsAction', completed: null as unknown as undefined },
367
+ tl.id!
368
+ );
369
+ console.log(`Reopened: ${updated.title}`);
370
+ break;
371
+ }
372
+
373
+ case 'del':
374
+ case 'delete': {
375
+ if (parsed.args.length < 1) { showUsage('del'); process.exit(1); }
376
+ const token = await getAccessToken(user, true);
377
+ const tl = await resolveTaskList(token, parsed.list);
378
+ const task = await findTask(token, tl.id!, parsed.args[0], parsed.showAll);
379
+ await deleteTask(token, task.id!, tl.id!);
380
+ console.log(`Deleted: ${task.title}`);
381
+ break;
382
+ }
383
+
384
+ case 'edit': {
385
+ if (parsed.args.length < 1) { showUsage('edit'); process.exit(1); }
386
+ const patch: Partial<Task> = {};
387
+ if (parsed.title) patch.title = parsed.title;
388
+ if (parsed.when) patch.due = buildDue(parsed.when);
389
+ if (parsed.notes) patch.notes = parsed.notes;
390
+ if (Object.keys(patch).length === 0) {
391
+ console.error('Nothing to change. Provide -t, -when, or -n.');
392
+ process.exit(1);
393
+ }
394
+ const token = await getAccessToken(user, true);
395
+ const tl = await resolveTaskList(token, parsed.list);
396
+ const task = await findTask(token, tl.id!, parsed.args[0], parsed.showAll);
397
+ const updated = await patchTask(token, task.id!, patch, tl.id!);
398
+ console.log(`Updated: ${updated.title}`);
399
+ if (patch.due) console.log(` Due: ${dueToYMD(updated.due)}`);
400
+ if (patch.notes) console.log(` Notes: ${updated.notes}`);
401
+ break;
402
+ }
403
+
404
+ case 'clear': {
405
+ const token = await getAccessToken(user, true);
406
+ const tl = await resolveTaskList(token, parsed.list);
407
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
408
+ const ans = (await rl.question(`Clear all completed tasks from "${tl.title}"? [y/N] `)).trim().toLowerCase();
409
+ rl.close();
410
+ if (ans !== 'y' && ans !== 'yes') {
411
+ console.log('Cancelled.');
412
+ break;
413
+ }
414
+ await clearCompleted(token, tl.id!);
415
+ console.log(`Cleared completed tasks from ${tl.title}.`);
416
+ break;
417
+ }
418
+
419
+ case 'move': {
420
+ if (parsed.args.length < 1 || !parsed.list) { showUsage('move'); process.exit(1); }
421
+ const token = await getAccessToken(user, true);
422
+ const lists = await listTaskLists(token);
423
+ const dest = lists.find(l =>
424
+ (l.title || '').toLowerCase() === parsed.list.toLowerCase() || l.id === parsed.list
425
+ );
426
+ if (!dest) {
427
+ console.error(`Destination tasklist not found: ${parsed.list}`);
428
+ process.exit(1);
429
+ }
430
+ // Source: must locate task across all lists since we don't know which
431
+ let srcList = '';
432
+ let task: Task | undefined;
433
+ for (const l of lists) {
434
+ const ts = await listTasks(token, l.id!, { showCompleted: true, showHidden: true });
435
+ const m = ts.find(t => t.id?.startsWith(parsed.args[0]));
436
+ if (m) { task = m; srcList = l.id!; break; }
437
+ }
438
+ if (!task) {
439
+ console.error(`${parsed.args[0]}: not found in any tasklist`);
440
+ process.exit(1);
441
+ }
442
+ const moved = await moveTask(token, task.id!, srcList, dest.id);
443
+ console.log(`Moved "${moved.title}" to ${dest.title}`);
444
+ break;
445
+ }
446
+
447
+ default:
448
+ console.error(`Unknown command: ${parsed.command}`);
449
+ showUsage();
450
+ process.exit(1);
451
+ }
452
+ }
453
+
454
+ if (import.meta.main) {
455
+ main().catch(e => {
456
+ console.error(`Error: ${e.message}`);
457
+ process.exit(1);
458
+ });
459
+ }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@bobfrankston/gcal",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "description": "Google Calendar CLI tool with ICS import support",
5
5
  "type": "module",
6
6
  "main": "gcal.js",
7
7
  "bin": {
8
- "gcal": "gcal.js"
8
+ "gcal": "gcal.js",
9
+ "gtask": "gtask.js"
9
10
  },
10
11
  "files": [
11
12
  "gcal.ts",
@@ -13,6 +14,11 @@
13
14
  "gcal.js.map",
14
15
  "gcal.d.ts",
15
16
  "gcal.d.ts.map",
17
+ "gtask.ts",
18
+ "gtask.js",
19
+ "gtask.js.map",
20
+ "gtask.d.ts",
21
+ "gtask.d.ts.map",
16
22
  "glib/**/*.ts",
17
23
  "glib/**/*.js",
18
24
  "glib/**/*.js.map",