@bobfrankston/gcards 0.1.2 → 0.1.4

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/gcards.ts CHANGED
@@ -5,102 +5,58 @@
5
5
  */
6
6
 
7
7
  import fs from 'fs';
8
+ import fp from 'fs/promises';
8
9
  import path from 'path';
9
- import { parseArgs, showHelp } from './cli.ts';
10
+ import crypto from 'crypto';
11
+ import { parseArgs, showUsage, showHelp } from './glib/parsecli.ts';
10
12
  import { authenticateOAuth } from '../../../projects/oauth/oauthsupport/index.ts';
11
- import type { GooglePerson, GoogleConnectionsResponse } from './types.ts';
12
-
13
- const APP_DIR = import.meta.dirname;
14
- const DATA_DIR = path.join(APP_DIR, 'data');
15
- const CREDENTIALS_FILE = path.join(APP_DIR, 'credentials.json');
16
- const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
13
+ import type { GooglePerson, GoogleConnectionsResponse } from './glib/types.ts';
14
+ import { GCARDS_GUID_KEY, extractGuids } from './glib/gctypes.ts';
15
+ import type { ContactIndex, IndexEntry, PushStatus, PendingChange, UserPaths } from './glib/gctypes.ts';
16
+ import { DATA_DIR, CREDENTIALS_FILE, loadConfig, saveConfig, getUserPaths, ensureUserDir, loadIndex, normalizeUser, getAllUsers, resolveUser, FileLogger } from './glib/gutils.ts';
17
17
 
18
18
  const PEOPLE_API_BASE = 'https://people.googleapis.com/v1';
19
19
  const CONTACTS_SCOPE_READ = 'https://www.googleapis.com/auth/contacts.readonly';
20
20
  const CONTACTS_SCOPE_WRITE = 'https://www.googleapis.com/auth/contacts';
21
21
 
22
- interface AppConfig {
23
- lastUser?: string;
22
+ function ts(): string {
23
+ const now = new Date();
24
+ return `[${now.toTimeString().slice(0, 8)}]`;
24
25
  }
25
26
 
26
- interface UserPaths {
27
- userDir: string;
28
- contactsDir: string;
29
- deletedDir: string;
30
- indexFile: string;
31
- statusFile: string;
32
- syncTokenFile: string;
33
- tokenFile: string;
34
- tokenWriteFile: string;
35
- }
27
+ let escapePressed = false;
36
28
 
37
- function loadConfig(): AppConfig {
38
- if (fs.existsSync(CONFIG_FILE)) {
39
- return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
40
- }
41
- return {};
42
- }
29
+ function setupEscapeHandler(): void {
30
+ // Handle Ctrl+C via SIGINT (works without raw mode)
31
+ process.on('SIGINT', () => {
32
+ escapePressed = true;
33
+ console.log('\n\nCtrl+C pressed - stopping after current operation...');
34
+ });
43
35
 
44
- function saveConfig(config: AppConfig): void {
45
- if (!fs.existsSync(DATA_DIR)) {
46
- fs.mkdirSync(DATA_DIR, { recursive: true });
36
+ // Handle ESC key via raw mode (optional, for graceful stop)
37
+ if (process.stdin.isTTY) {
38
+ process.stdin.setRawMode(true);
39
+ process.stdin.resume();
40
+ process.stdin.on('data', (key) => {
41
+ if (key[0] === 27) { // ESC key
42
+ escapePressed = true;
43
+ console.log('\n\nESC pressed - stopping after current operation...');
44
+ } else if (key[0] === 3) { // Ctrl+C in raw mode
45
+ escapePressed = true;
46
+ console.log('\n\nCtrl+C pressed - stopping after current operation...');
47
+ }
48
+ });
47
49
  }
48
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
49
- }
50
-
51
- /** Normalize Gmail username: lowercase, remove dots, strip +suffix and @domain */
52
- function normalizeUser(user: string): string {
53
- return user.toLowerCase().split(/[+@]/)[0].replace(/\./g, '');
54
- }
55
-
56
- function getUserPaths(user: string): UserPaths {
57
- const userDir = path.join(DATA_DIR, user);
58
- return {
59
- userDir,
60
- contactsDir: path.join(userDir, 'contacts'),
61
- deletedDir: path.join(userDir, 'deleted'),
62
- indexFile: path.join(userDir, 'index.json'),
63
- statusFile: path.join(userDir, 'status.json'),
64
- syncTokenFile: path.join(userDir, 'sync-token.json'),
65
- tokenFile: path.join(userDir, 'token.json'),
66
- tokenWriteFile: path.join(userDir, 'token-write.json')
67
- };
68
50
  }
69
51
 
70
- function ensureUserDir(user: string): void {
71
- const paths = getUserPaths(user);
72
- if (!fs.existsSync(paths.userDir)) {
73
- fs.mkdirSync(paths.userDir, { recursive: true });
52
+ function cleanupEscapeHandler(): void {
53
+ if (process.stdin.isTTY) {
54
+ process.stdin.setRawMode(false);
55
+ process.stdin.pause();
74
56
  }
75
57
  }
76
58
 
77
- interface ContactIndex {
78
- contacts: Record<string, IndexEntry>; /** resourceName -> entry */
79
- lastSync: string;
80
- syncToken?: string;
81
- }
82
-
83
- interface IndexEntry {
84
- resourceName: string;
85
- displayName: string;
86
- etag?: string;
87
- deleted: boolean;
88
- deletedAt?: string;
89
- updatedAt: string;
90
- }
91
-
92
- interface PushStatus {
93
- lastPush: string; /** ISO timestamp of last push */
94
- }
95
-
96
- interface PendingChange {
97
- type: 'update' | 'delete';
98
- resourceName: string;
99
- displayName: string;
100
- filePath: string;
101
- }
102
-
103
- async function getAccessToken(user: string, writeAccess = false): Promise<string> {
59
+ async function getAccessToken(user: string, writeAccess = false, forceRefresh = false): Promise<string> {
104
60
  if (!fs.existsSync(CREDENTIALS_FILE)) {
105
61
  throw new Error(`Credentials file not found: ${CREDENTIALS_FILE}`);
106
62
  }
@@ -110,6 +66,13 @@ async function getAccessToken(user: string, writeAccess = false): Promise<string
110
66
 
111
67
  const scope = writeAccess ? CONTACTS_SCOPE_WRITE : CONTACTS_SCOPE_READ;
112
68
  const tokenFileName = writeAccess ? 'token-write.json' : 'token.json';
69
+ const tokenFilePath = path.join(paths.userDir, tokenFileName);
70
+
71
+ // Delete cached token to force re-auth
72
+ if (forceRefresh && fs.existsSync(tokenFilePath)) {
73
+ fs.unlinkSync(tokenFilePath);
74
+ console.log(`${ts()} Token expired, refreshing...`);
75
+ }
113
76
 
114
77
  const token = await authenticateOAuth(CREDENTIALS_FILE, {
115
78
  scope,
@@ -126,18 +89,56 @@ async function getAccessToken(user: string, writeAccess = false): Promise<string
126
89
  return token.access_token;
127
90
  }
128
91
 
129
- function loadIndex(paths: UserPaths): ContactIndex {
130
- if (fs.existsSync(paths.indexFile)) {
131
- return JSON.parse(fs.readFileSync(paths.indexFile, 'utf-8'));
132
- }
133
- return { contacts: {}, lastSync: '' };
92
+ // Mutable token holder for refresh during long operations
93
+ let currentAccessToken = '';
94
+ let currentUser = '';
95
+
96
+ async function refreshAccessToken(): Promise<string> {
97
+ currentAccessToken = await getAccessToken(currentUser, true, true);
98
+ return currentAccessToken;
134
99
  }
135
100
 
136
101
  function saveIndex(paths: UserPaths, index: ContactIndex): void {
102
+ // Sort contacts by displayName for convenience
103
+ const sortedContacts: Record<string, IndexEntry> = {};
104
+ const entries = Object.entries(index.contacts);
105
+ entries.sort((a, b) => a[1].displayName.localeCompare(b[1].displayName));
106
+ for (const [key, value] of entries) {
107
+ sortedContacts[key] = value;
108
+ }
109
+ index.contacts = sortedContacts;
137
110
  fs.writeFileSync(paths.indexFile, JSON.stringify(index, null, 2));
138
111
  }
139
112
 
140
- function loadSyncToken(paths: UserPaths): string | null {
113
+ /** Deleted contacts index (separate from active index) */
114
+ interface DeletedIndex {
115
+ deleted: Record<string, IndexEntry>;
116
+ }
117
+
118
+ function loadDeleted(paths: UserPaths): DeletedIndex {
119
+ if (fs.existsSync(paths.deletedFile)) {
120
+ return JSON.parse(fs.readFileSync(paths.deletedFile, 'utf-8'));
121
+ }
122
+ return { deleted: {} };
123
+ }
124
+
125
+ function saveDeleted(paths: UserPaths, deletedIndex: DeletedIndex): void {
126
+ // Sort by displayName
127
+ const sorted: Record<string, IndexEntry> = {};
128
+ const entries = Object.entries(deletedIndex.deleted);
129
+ entries.sort((a, b) => a[1].displayName.localeCompare(b[1].displayName));
130
+ for (const [key, value] of entries) {
131
+ sorted[key] = value;
132
+ }
133
+ deletedIndex.deleted = sorted;
134
+ fs.writeFileSync(paths.deletedFile, JSON.stringify(deletedIndex, null, 2));
135
+ }
136
+
137
+ function generateGuid(): string {
138
+ return crypto.randomUUID();
139
+ }
140
+
141
+ function loadSyncToken(paths: UserPaths): string {
141
142
  if (fs.existsSync(paths.syncTokenFile)) {
142
143
  const data = JSON.parse(fs.readFileSync(paths.syncTokenFile, 'utf-8'));
143
144
  return data.syncToken || null;
@@ -163,7 +164,7 @@ async function fetchContactsWithRetry(
163
164
  const BASE_DELAY = 5000; /** 5 seconds base delay */
164
165
 
165
166
  const params = new URLSearchParams({
166
- personFields: 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays,photos,memberships,metadata',
167
+ personFields: 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays,photos,memberships,metadata,fileAses',
167
168
  pageSize: '100' /** Smaller page size to avoid quota issues */
168
169
  });
169
170
 
@@ -230,8 +231,22 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
230
231
  const accessToken = await getAccessToken(user);
231
232
 
232
233
  const index = loadIndex(paths);
234
+ const deletedIndex = loadDeleted(paths);
233
235
  let syncToken = options.full ? null : loadSyncToken(paths);
234
236
 
237
+ // Migrate any deleted entries from index.json to deleted.json
238
+ let migrated = 0;
239
+ for (const [rn, entry] of Object.entries(index.contacts)) {
240
+ if (entry.deleted) {
241
+ deletedIndex.deleted[rn] = entry;
242
+ delete index.contacts[rn];
243
+ migrated++;
244
+ }
245
+ }
246
+ if (migrated > 0) {
247
+ console.log(`Migrated ${migrated} deleted entries to deleted.json`);
248
+ }
249
+
235
250
  if (syncToken) {
236
251
  console.log('Performing incremental sync...');
237
252
  } else {
@@ -243,6 +258,7 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
243
258
  let added = 0;
244
259
  let updated = 0;
245
260
  let deleted = 0;
261
+ let conflicts = 0;
246
262
  let pageNum = 0;
247
263
 
248
264
  do {
@@ -257,23 +273,48 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
257
273
  const displayName = person.names?.[0]?.displayName || 'Unknown';
258
274
 
259
275
  if (isDeleted) {
260
- if (index.contacts[person.resourceName]) {
261
- index.contacts[person.resourceName].deleted = true;
262
- index.contacts[person.resourceName].deletedAt = new Date().toISOString();
276
+ const entry = index.contacts[person.resourceName];
277
+ if (entry) {
278
+ // Move to deleted.json
279
+ entry.deleted = true;
280
+ entry.deletedAt = new Date().toISOString();
281
+ deletedIndex.deleted[person.resourceName] = entry;
282
+ delete index.contacts[person.resourceName];
263
283
  deleteContactFile(paths, person.resourceName);
264
284
  deleted++;
265
285
  if (options.verbose) console.log(`\n Deleted: ${displayName}`);
266
286
  }
267
287
  } else {
268
288
  const existed = !!index.contacts[person.resourceName];
289
+
290
+ // Check for local conflict before overwriting
291
+ const filePath = path.join(paths.contactsDir, `${person.resourceName.replace(/\//g, '_')}.json`);
292
+ const indexEntry = index.contacts[person.resourceName];
293
+ if (existed && fs.existsSync(filePath)) {
294
+ const stat = fs.statSync(filePath);
295
+ const modTime = stat.mtimeMs;
296
+ const indexUpdatedAt = indexEntry?.updatedAt ? new Date(indexEntry.updatedAt).getTime() : 0;
297
+
298
+ if (modTime > indexUpdatedAt + 1000) {
299
+ // Local file was modified - conflict!
300
+ console.log(`\n CONFLICT: ${displayName} - local changes exist, skipping (delete local file and sync to get server version)`);
301
+ conflicts++;
302
+ continue;
303
+ }
304
+ }
305
+
269
306
  saveContact(paths, person);
270
307
 
308
+ // Extract GUIDs from userDefined fields
309
+ const guids = extractGuids(person);
310
+
271
311
  index.contacts[person.resourceName] = {
272
312
  resourceName: person.resourceName,
273
313
  displayName,
274
314
  etag: person.etag,
275
315
  deleted: false,
276
- updatedAt: new Date().toISOString()
316
+ updatedAt: new Date().toISOString(),
317
+ guids: guids.length > 0 ? guids : undefined
277
318
  };
278
319
 
279
320
  if (existed) {
@@ -298,21 +339,30 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
298
339
  // Save index after each page in case of interruption
299
340
  index.lastSync = new Date().toISOString();
300
341
  saveIndex(paths, index);
342
+ saveDeleted(paths, deletedIndex);
301
343
 
302
344
  // Small delay between pages to be nice to the API
303
345
  if (pageToken) {
304
346
  await sleep(500);
305
347
  }
348
+
349
+ if (escapePressed) {
350
+ console.log('\n\nStopped by user. Progress saved.');
351
+ break;
352
+ }
306
353
  } while (pageToken);
307
354
 
308
- const activeContacts = Object.values(index.contacts).filter(c => !c.deleted).length;
309
- const tombstones = Object.values(index.contacts).filter(c => c.deleted).length;
355
+ const activeContacts = Object.keys(index.contacts).length;
356
+ const tombstones = Object.keys(deletedIndex.deleted).length;
310
357
 
311
358
  console.log(`\n\nSync complete:`);
312
359
  console.log(` Processed: ${totalProcessed}`);
313
360
  console.log(` Added: ${added}`);
314
361
  console.log(` Updated: ${updated}`);
315
362
  console.log(` Deleted: ${deleted}`);
363
+ if (conflicts > 0) {
364
+ console.log(` Conflicts: ${conflicts} (delete local file and sync to resolve)`);
365
+ }
316
366
  console.log(` Active contacts: ${activeContacts}`);
317
367
  console.log(` Tombstones: ${tombstones}`);
318
368
  }
@@ -328,48 +378,92 @@ function saveStatus(paths: UserPaths, status: PushStatus): void {
328
378
  fs.writeFileSync(paths.statusFile, JSON.stringify(status, null, 2));
329
379
  }
330
380
 
331
- function findPendingChanges(paths: UserPaths): PendingChange[] {
332
- const status = loadStatus(paths);
333
- const lastPush = status.lastPush ? new Date(status.lastPush).getTime() : 0;
381
+ interface PendingChangesResult {
382
+ changes: PendingChange[];
383
+ parseErrors: string[];
384
+ }
385
+
386
+ async function findPendingChanges(paths: UserPaths, logger: FileLogger): Promise<PendingChangesResult> {
334
387
  const changes: PendingChange[] = [];
388
+ const parseErrors: string[] = [];
389
+ const index = loadIndex(paths);
390
+ const lastSync = index.lastSync ? new Date(index.lastSync).getTime() : 0;
391
+
392
+ // Check index.json for _delete requests
393
+ for (const [resourceName, entry] of Object.entries(index.contacts)) {
394
+ if (entry._delete && !entry.deleted) {
395
+ const filePath = path.join(paths.contactsDir, `${resourceName.replace(/\//g, '_')}.json`);
396
+ changes.push({
397
+ type: 'delete',
398
+ resourceName,
399
+ displayName: entry.displayName,
400
+ filePath
401
+ });
402
+ }
403
+ }
335
404
 
336
- // Check contacts/ for modified files and _deleted markers
405
+ // Check contacts/ for modified files and _delete markers
406
+ // Only check files modified after lastSync (fast filter using mtime)
337
407
  if (fs.existsSync(paths.contactsDir)) {
338
408
  const files = fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'));
409
+ let checked = 0;
410
+
339
411
  for (const file of files) {
340
412
  const filePath = path.join(paths.contactsDir, file);
341
- const stat = fs.statSync(filePath);
413
+ const stat = await fp.stat(filePath);
342
414
  const modTime = stat.mtimeMs;
343
415
 
344
- if (modTime > lastPush) {
345
- const content = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson & { _deleted?: boolean };
346
- const displayName = content.names?.[0]?.displayName || 'Unknown';
416
+ // Skip files not modified since last sync
417
+ if (modTime <= lastSync + 1000) continue;
418
+
419
+ checked++;
420
+ let content: GooglePerson & { _delete?: boolean };
421
+ try {
422
+ content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
423
+ } catch (e: any) {
424
+ const errMsg = `[PARSE ERROR] ${file}: ${e.message}`;
425
+ parseErrors.push(errMsg);
426
+ logger.error(errMsg);
427
+ continue;
428
+ }
347
429
 
348
- if (content._deleted) {
349
- changes.push({
350
- type: 'delete',
351
- resourceName: content.resourceName,
352
- displayName,
353
- filePath
354
- });
355
- } else {
356
- changes.push({
357
- type: 'update',
358
- resourceName: content.resourceName,
359
- displayName,
360
- filePath
361
- });
362
- }
430
+ const displayName = content.names?.[0]?.displayName || 'Unknown';
431
+
432
+ if (content._delete) {
433
+ changes.push({
434
+ type: 'delete',
435
+ resourceName: content.resourceName,
436
+ displayName,
437
+ filePath
438
+ });
439
+ } else {
440
+ changes.push({
441
+ type: 'update',
442
+ resourceName: content.resourceName,
443
+ displayName,
444
+ filePath
445
+ });
363
446
  }
364
447
  }
448
+ if (checked > 0) {
449
+ console.log(`${ts()} Found ${checked} modified contact(s)`);
450
+ }
365
451
  }
366
452
 
367
- // Check deleted/ folder
368
- if (fs.existsSync(paths.deletedDir)) {
369
- const files = fs.readdirSync(paths.deletedDir).filter(f => f.endsWith('.json'));
453
+ // Check _delete/ folder for user deletion requests
454
+ if (fs.existsSync(paths.toDeleteDir)) {
455
+ const files = fs.readdirSync(paths.toDeleteDir).filter(f => f.endsWith('.json'));
370
456
  for (const file of files) {
371
- const filePath = path.join(paths.deletedDir, file);
372
- const content = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
457
+ const filePath = path.join(paths.toDeleteDir, file);
458
+ let content: GooglePerson;
459
+ try {
460
+ content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
461
+ } catch (e: any) {
462
+ const errMsg = `[PARSE ERROR] ${file}: ${e.message}`;
463
+ parseErrors.push(errMsg);
464
+ logger.error(errMsg);
465
+ continue;
466
+ }
373
467
  const displayName = content.names?.[0]?.displayName || 'Unknown';
374
468
 
375
469
  changes.push({
@@ -381,10 +475,41 @@ function findPendingChanges(paths: UserPaths): PendingChange[] {
381
475
  }
382
476
  }
383
477
 
384
- return changes;
478
+ // Check _add/ folder for new contacts to create
479
+ if (fs.existsSync(paths.toAddDir)) {
480
+ const files = fs.readdirSync(paths.toAddDir).filter(f => f.endsWith('.json'));
481
+ for (const file of files) {
482
+ const filePath = path.join(paths.toAddDir, file);
483
+ let content: GooglePerson;
484
+ try {
485
+ content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
486
+ } catch (e: any) {
487
+ const errMsg = `[PARSE ERROR] ${file}: ${e.message}`;
488
+ parseErrors.push(errMsg);
489
+ logger.error(errMsg);
490
+ continue;
491
+ }
492
+ const displayName = content.names?.[0]?.displayName || 'Unknown';
493
+
494
+ changes.push({
495
+ type: 'add',
496
+ resourceName: '', // Will be assigned by Google
497
+ displayName,
498
+ filePath
499
+ });
500
+ }
501
+ }
502
+
503
+ return { changes, parseErrors };
385
504
  }
386
505
 
387
506
  async function confirm(message: string): Promise<boolean> {
507
+ // Temporarily disable raw mode for readline to work
508
+ const wasRawMode = process.stdin.isTTY && (process.stdin as any).isRaw;
509
+ if (wasRawMode) {
510
+ process.stdin.setRawMode(false);
511
+ }
512
+
388
513
  const readline = await import('readline');
389
514
  const rl = readline.createInterface({
390
515
  input: process.stdin,
@@ -394,55 +519,163 @@ async function confirm(message: string): Promise<boolean> {
394
519
  return new Promise(resolve => {
395
520
  rl.question(`${message} (y/N): `, answer => {
396
521
  rl.close();
522
+ // Restore raw mode if it was enabled
523
+ if (wasRawMode && process.stdin.isTTY) {
524
+ process.stdin.setRawMode(true);
525
+ }
397
526
  resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
398
527
  });
399
528
  });
400
529
  }
401
530
 
402
- async function updateContactOnGoogle(accessToken: string, person: GooglePerson): Promise<void> {
403
- const updateMask = 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays';
531
+ async function updateContactOnGoogle(person: GooglePerson, retryCount = 0, tokenRefreshed = false): Promise<void> {
532
+ const updateMask = 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays,fileAses';
404
533
 
405
534
  const response = await fetch(`${PEOPLE_API_BASE}/${person.resourceName}:updateContact?updatePersonFields=${updateMask}`, {
406
535
  method: 'PATCH',
407
536
  headers: {
408
- 'Authorization': `Bearer ${accessToken}`,
537
+ 'Authorization': `Bearer ${currentAccessToken}`,
409
538
  'Content-Type': 'application/json'
410
539
  },
411
540
  body: JSON.stringify(person)
412
541
  });
413
542
 
543
+ // Token expired - refresh and retry once
544
+ if (response.status === 401 && !tokenRefreshed) {
545
+ await refreshAccessToken();
546
+ return updateContactOnGoogle(person, retryCount, true);
547
+ }
548
+
549
+ if (response.status === 429) {
550
+ if (retryCount >= 3 || escapePressed) {
551
+ throw new Error(`rate limit - try again later`);
552
+ }
553
+ const delay = Math.pow(2, retryCount + 1) * 10000; // 20s, 40s, 80s
554
+ console.log(` ${ts()} rate limited, waiting ${delay / 1000}s (ESC to stop)...`);
555
+ await sleep(delay);
556
+ if (escapePressed) throw new Error(`stopped by user`);
557
+ console.log(`${ts()} retrying...`);
558
+ return updateContactOnGoogle(person, retryCount + 1, tokenRefreshed);
559
+ }
560
+
414
561
  if (!response.ok) {
415
- throw new Error(`Update failed for ${person.resourceName}: ${response.status} ${await response.text()}`);
562
+ throw new Error(`${response.status} ${await response.text()}`);
416
563
  }
417
564
  }
418
565
 
419
- async function deleteContactOnGoogle(accessToken: string, resourceName: string): Promise<void> {
566
+ async function deleteContactOnGoogle(resourceName: string, retryCount = 0, tokenRefreshed = false): Promise<void> {
420
567
  const response = await fetch(`${PEOPLE_API_BASE}/${resourceName}:deleteContact`, {
421
568
  method: 'DELETE',
422
- headers: { 'Authorization': `Bearer ${accessToken}` }
569
+ headers: { 'Authorization': `Bearer ${currentAccessToken}` }
570
+ });
571
+
572
+ // Already deleted - treat as success
573
+ if (response.status === 404) {
574
+ console.log(' (already deleted)');
575
+ return;
576
+ }
577
+
578
+ // Token expired - refresh and retry once
579
+ if (response.status === 401 && !tokenRefreshed) {
580
+ await refreshAccessToken();
581
+ return deleteContactOnGoogle(resourceName, retryCount, true);
582
+ }
583
+
584
+ if (response.status === 429) {
585
+ if (retryCount >= 3 || escapePressed) {
586
+ throw new Error(`rate limit - try again later`);
587
+ }
588
+ const delay = Math.pow(2, retryCount + 1) * 10000; // 20s, 40s, 80s
589
+ console.log(` ${ts()} rate limited, waiting ${delay / 1000}s (ESC to stop)...`);
590
+ await sleep(delay);
591
+ if (escapePressed) throw new Error(`stopped by user`);
592
+ console.log(`${ts()} retrying...`);
593
+ return deleteContactOnGoogle(resourceName, retryCount + 1, tokenRefreshed);
594
+ }
595
+
596
+ if (!response.ok) {
597
+ throw new Error(`${response.status} ${await response.text()}`);
598
+ }
599
+ }
600
+
601
+ async function createContactOnGoogle(person: GooglePerson, retryCount = 0, tokenRefreshed = false): Promise<GooglePerson> {
602
+ // Remove resourceName and etag - Google will assign new ones
603
+ const { resourceName, etag, ...personData } = person as any;
604
+
605
+ // Add a GUID if not already present in userDefined
606
+ if (!personData.userDefined) {
607
+ personData.userDefined = [];
608
+ }
609
+ const hasGuid = personData.userDefined.some((u: any) => u.key === GCARDS_GUID_KEY);
610
+ if (!hasGuid) {
611
+ personData.userDefined.push({ key: GCARDS_GUID_KEY, value: generateGuid() });
612
+ }
613
+
614
+ const response = await fetch(`${PEOPLE_API_BASE}/people:createContact`, {
615
+ method: 'POST',
616
+ headers: {
617
+ 'Authorization': `Bearer ${currentAccessToken}`,
618
+ 'Content-Type': 'application/json'
619
+ },
620
+ body: JSON.stringify(personData)
423
621
  });
424
622
 
623
+ // Token expired - refresh and retry once
624
+ if (response.status === 401 && !tokenRefreshed) {
625
+ await refreshAccessToken();
626
+ return createContactOnGoogle(person, retryCount, true);
627
+ }
628
+
629
+ if (response.status === 429) {
630
+ if (retryCount >= 3 || escapePressed) {
631
+ throw new Error(`rate limit - try again later`);
632
+ }
633
+ const delay = Math.pow(2, retryCount + 1) * 10000; // 20s, 40s, 80s
634
+ console.log(` ${ts()} rate limited, waiting ${delay / 1000}s (ESC to stop)...`);
635
+ await sleep(delay);
636
+ if (escapePressed) throw new Error(`stopped by user`);
637
+ console.log(`${ts()} retrying...`);
638
+ return createContactOnGoogle(person, retryCount + 1, tokenRefreshed);
639
+ }
640
+
425
641
  if (!response.ok) {
426
- throw new Error(`Delete failed for ${resourceName}: ${response.status} ${await response.text()}`);
642
+ throw new Error(`${response.status} ${await response.text()}`);
427
643
  }
644
+
645
+ return await response.json();
428
646
  }
429
647
 
430
- async function pushContacts(user: string, options: { yes: boolean; verbose: boolean }): Promise<void> {
648
+ async function pushContacts(user: string, options: { yes: boolean; verbose: boolean; limit: number }): Promise<void> {
431
649
  const paths = getUserPaths(user);
432
650
  ensureUserDir(user);
433
651
 
652
+ // Create logger for problems
653
+ const problemsFile = path.join(paths.userDir, 'pushproblems.txt');
654
+ const logger = new FileLogger(problemsFile, true);
655
+
434
656
  console.log(`Pushing contacts for user: ${user}`);
435
- const changes = findPendingChanges(paths);
657
+ let { changes, parseErrors } = await findPendingChanges(paths, logger);
658
+
659
+ if (parseErrors.length > 0) {
660
+ console.log(`\n${parseErrors.length} files had parse errors (see pushproblems.txt)`);
661
+ }
436
662
 
437
663
  if (changes.length === 0) {
438
664
  console.log('No pending changes to push.');
439
665
  return;
440
666
  }
441
667
 
668
+ const adds = changes.filter(c => c.type === 'add');
442
669
  const updates = changes.filter(c => c.type === 'update');
443
670
  const deletes = changes.filter(c => c.type === 'delete');
444
671
 
445
672
  console.log('\nPending changes:');
673
+ if (adds.length > 0) {
674
+ console.log(`\n New contacts (${adds.length}):`);
675
+ for (const c of adds) {
676
+ console.log(` - ${c.displayName}`);
677
+ }
678
+ }
446
679
  if (updates.length > 0) {
447
680
  console.log(`\n Updates (${updates.length}):`);
448
681
  for (const c of updates) {
@@ -465,32 +698,95 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
465
698
  }
466
699
 
467
700
  console.log('\nGetting access token (write access)...');
468
- const accessToken = await getAccessToken(user, true);
701
+ currentUser = user;
702
+ currentAccessToken = await getAccessToken(user, true);
469
703
 
470
704
  const index = loadIndex(paths);
705
+ const deletedIndex = loadDeleted(paths);
706
+ // Apply limit if specified
707
+ if (options.limit > 0 && changes.length > options.limit) {
708
+ console.log(`[DEBUG] Limiting to first ${options.limit} of ${changes.length} changes\n`);
709
+ changes = changes.slice(0, options.limit);
710
+ }
711
+
471
712
  let successCount = 0;
472
713
  let errorCount = 0;
714
+ let processed = 0;
715
+ const total = changes.length;
716
+ const problems: string[] = [];
473
717
 
474
718
  for (const change of changes) {
719
+ if (escapePressed) {
720
+ console.log('\n\nStopped by user. Progress saved.');
721
+ break;
722
+ }
723
+
724
+ processed++;
475
725
  try {
476
- if (change.type === 'update') {
726
+ if (change.type === 'add') {
477
727
  const content = JSON.parse(fs.readFileSync(change.filePath, 'utf-8')) as GooglePerson;
478
- process.stdout.write(`Updating ${change.displayName}...`);
479
- await updateContactOnGoogle(accessToken, content);
480
- console.log(' done');
728
+ process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Creating ${change.displayName}...`);
729
+ const created = await createContactOnGoogle(content);
730
+
731
+ // Extract GUIDs from userDefined
732
+ const guids = extractGuids(created);
733
+
734
+ // Add to index
735
+ index.contacts[created.resourceName] = {
736
+ resourceName: created.resourceName,
737
+ displayName: change.displayName,
738
+ etag: created.etag,
739
+ deleted: false,
740
+ updatedAt: new Date().toISOString(),
741
+ guids: guids.length > 0 ? guids : undefined
742
+ };
743
+
744
+ // Save new contact to contacts/ folder
745
+ const newFilePath = path.join(paths.contactsDir, `${created.resourceName.replace(/\//g, '_')}.json`);
746
+ if (!fs.existsSync(paths.contactsDir)) {
747
+ fs.mkdirSync(paths.contactsDir, { recursive: true });
748
+ }
749
+ fs.writeFileSync(newFilePath, JSON.stringify(created, null, 2));
750
+
751
+ // Remove from _add/ folder
752
+ fs.unlinkSync(change.filePath);
753
+
754
+ console.log(` done (${created.resourceName})`);
481
755
  successCount++;
482
- } else {
483
- process.stdout.write(`Deleting ${change.displayName}...`);
484
- await deleteContactOnGoogle(accessToken, change.resourceName);
756
+ } else if (change.type === 'update') {
757
+ const content = JSON.parse(fs.readFileSync(change.filePath, 'utf-8')) as GooglePerson;
758
+ process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Updating ${change.displayName}...`);
759
+ await updateContactOnGoogle(content);
485
760
 
486
- // Update index
761
+ // Update index timestamp so file no longer appears modified
487
762
  if (index.contacts[change.resourceName]) {
488
- index.contacts[change.resourceName].deleted = true;
489
- index.contacts[change.resourceName].deletedAt = new Date().toISOString();
763
+ index.contacts[change.resourceName].updatedAt = new Date().toISOString();
764
+ }
765
+
766
+ console.log(' done');
767
+ successCount++;
768
+ } else if (change.type === 'delete') {
769
+ process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Deleting ${change.displayName}...`);
770
+ await deleteContactOnGoogle(change.resourceName);
771
+
772
+ // Move to deleted.json
773
+ const entry = index.contacts[change.resourceName];
774
+ if (entry) {
775
+ entry.deleted = true;
776
+ entry.deletedAt = new Date().toISOString();
777
+ delete entry._delete; // Clear the delete request
778
+ deletedIndex.deleted[change.resourceName] = entry;
779
+ delete index.contacts[change.resourceName];
780
+ }
781
+
782
+ // Remove contact file from contacts/
783
+ const contactFile = path.join(paths.contactsDir, `${change.resourceName.replace('people/', '')}.json`);
784
+ if (fs.existsSync(contactFile)) {
785
+ fs.unlinkSync(contactFile);
490
786
  }
491
787
 
492
- // Remove from deleted/ folder if it was there
493
- if (change.filePath.startsWith(paths.deletedDir)) {
788
+ // Remove from _delete/ folder if it was there
789
+ if (change.filePath.startsWith(paths.toDeleteDir) && fs.existsSync(change.filePath)) {
494
790
  fs.unlinkSync(change.filePath);
495
791
  }
496
792
 
@@ -498,77 +794,94 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
498
794
  successCount++;
499
795
  }
500
796
 
501
- await sleep(200); // Be nice to the API
797
+ await sleep(700); // 90 writes/min limit = ~700ms between ops
502
798
  } catch (error: any) {
799
+ const fileName = path.basename(change.filePath);
800
+ const errorMsg = `${fileName}: ${error.message}`;
503
801
  console.log(` ERROR: ${error.message}`);
802
+ problems.push(errorMsg);
504
803
  errorCount++;
505
- debugger;
506
804
  }
507
805
  }
508
806
 
807
+ // Write problems to file if any
808
+ if (problems.length > 0) {
809
+ const problemsFile = path.join(paths.userDir, 'pushproblems.txt');
810
+ fs.writeFileSync(problemsFile, problems.join('\n'));
811
+ console.log(`\nErrors written to: ${problemsFile}`);
812
+ }
813
+
509
814
  // Save status with new timestamp
510
815
  saveStatus(paths, { lastPush: new Date().toISOString() });
511
816
  saveIndex(paths, index);
817
+ saveDeleted(paths, deletedIndex);
512
818
 
513
819
  console.log(`\nPush complete: ${successCount} succeeded, ${errorCount} failed`);
514
820
  }
515
821
 
516
- function resolveUser(cliUser: string): string {
517
- // 1. Use CLI-specified user if provided (and not 'default')
518
- if (cliUser && cliUser !== 'default') {
519
- const normalized = normalizeUser(cliUser);
520
- const config = loadConfig();
521
- config.lastUser = normalized;
522
- saveConfig(config);
523
- return normalized;
524
- }
525
-
526
- // 2. Use lastUser from config (already normalized)
527
- const config = loadConfig();
528
- if (config.lastUser) {
529
- return config.lastUser;
530
- }
531
-
532
- // 3. No user - require --user on first use
533
- console.error('No user specified. Use --user <name> on first run (e.g., your Gmail username).');
534
- console.error('This will be remembered for future runs.');
535
- process.exit(1);
536
- }
537
-
538
822
  async function main(): Promise<void> {
539
823
  const args = process.argv.slice(2);
540
824
  const options = parseArgs(args);
541
825
 
542
- if (options.help || !options.command) {
826
+ if (options.help) {
543
827
  showHelp();
544
828
  return;
545
829
  }
546
830
 
831
+ if (!options.command) {
832
+ showUsage();
833
+ return;
834
+ }
835
+
836
+ setupEscapeHandler();
837
+
547
838
  try {
548
- const user = resolveUser(options.user);
549
-
550
- switch (options.command) {
551
- case 'sync':
552
- await syncContacts(user, { full: options.full, verbose: options.verbose });
553
- break;
554
- case 'push':
555
- await pushContacts(user, { yes: options.yes, verbose: options.verbose });
556
- break;
557
- default:
558
- console.error(`Unknown command: ${options.command}`);
559
- showHelp();
839
+ // Determine users to process
840
+ let users: string[];
841
+ if (options.all) {
842
+ users = getAllUsers();
843
+ if (users.length === 0) {
844
+ console.error('No users found. Use --user <name> to create one first.');
560
845
  process.exit(1);
846
+ }
847
+ console.log(`Processing all users: ${users.join(', ')}\n`);
848
+ } else {
849
+ users = [resolveUser(options.user, true)];
850
+ }
851
+
852
+ for (const user of users) {
853
+ if (escapePressed) break;
854
+
855
+ if (users.length > 1) {
856
+ console.log(`\n${'='.repeat(50)}\nUser: ${user}\n${'='.repeat(50)}`);
857
+ }
858
+
859
+ switch (options.command) {
860
+ case 'sync':
861
+ await syncContacts(user, { full: options.full, verbose: options.verbose });
862
+ break;
863
+ case 'push':
864
+ await pushContacts(user, { yes: options.yes, verbose: options.verbose, limit: options.limit });
865
+ break;
866
+ default:
867
+ console.error(`Unknown command: ${options.command}`);
868
+ showHelp();
869
+ process.exit(1);
870
+ }
561
871
  }
562
872
 
563
- // Update lastUser in config
564
- const config = loadConfig();
565
- config.lastUser = user;
566
- saveConfig(config);
873
+ // Update lastUser in config (use last processed user)
874
+ if (!options.all && users.length === 1) {
875
+ const config = loadConfig();
876
+ config.lastUser = users[0];
877
+ saveConfig(config);
878
+ }
567
879
 
568
880
  } catch (error: any) {
569
881
  console.error(`Error: ${error.message}`);
570
- debugger;
571
882
  process.exit(1);
883
+ } finally {
884
+ cleanupEscapeHandler();
572
885
  }
573
886
  }
574
887