@bobfrankston/gcards 0.1.2 → 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/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, DeletedEntry, DeleteQueue, DeleteQueueEntry, 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
  }
50
51
 
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
- }
69
-
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,122 @@ 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, DeletedEntry>;
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, DeletedEntry> = {};
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
+ interface SavedPhoto {
138
+ url: string;
139
+ sourceType: 'CONTACT' | 'PROFILE';
140
+ }
141
+
142
+ interface PhotoEntry {
143
+ displayName: string;
144
+ photos: SavedPhoto[];
145
+ deletedAt: string;
146
+ }
147
+
148
+ interface PhotosIndex {
149
+ photos: Record<string, PhotoEntry>; // resourceName -> PhotoEntry
150
+ }
151
+
152
+ function loadPhotos(paths: UserPaths): PhotosIndex {
153
+ if (fs.existsSync(paths.photosFile)) {
154
+ return JSON.parse(fs.readFileSync(paths.photosFile, 'utf-8'));
155
+ }
156
+ return { photos: {} };
157
+ }
158
+
159
+ function savePhotos(paths: UserPaths, photosIndex: PhotosIndex): void {
160
+ // Sort by displayName
161
+ const sorted: Record<string, PhotoEntry> = {};
162
+ const entries = Object.entries(photosIndex.photos);
163
+ entries.sort((a, b) => a[1].displayName.localeCompare(b[1].displayName));
164
+ for (const [key, value] of entries) {
165
+ sorted[key] = value;
166
+ }
167
+ photosIndex.photos = sorted;
168
+ fs.writeFileSync(paths.photosFile, JSON.stringify(photosIndex, null, 2));
169
+ }
170
+
171
+ function loadDeleteQueue(paths: UserPaths): DeleteQueue {
172
+ if (fs.existsSync(paths.deleteQueueFile)) {
173
+ return JSON.parse(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
174
+ }
175
+ return { updatedAt: '', entries: [] };
176
+ }
177
+
178
+ function saveDeleteQueue(paths: UserPaths, queue: DeleteQueue): void {
179
+ queue.updatedAt = new Date().toISOString();
180
+ fs.writeFileSync(paths.deleteQueueFile, JSON.stringify(queue, null, 2));
181
+ }
182
+
183
+ function extractNonDefaultPhotos(contact: GooglePerson): SavedPhoto[] {
184
+ if (!contact.photos) return [];
185
+ return contact.photos
186
+ .filter(p => !p.default)
187
+ .map(p => ({
188
+ url: p.url,
189
+ sourceType: (p.metadata?.source?.type || 'CONTACT') as 'CONTACT' | 'PROFILE'
190
+ }));
191
+ }
192
+
193
+ function hasNonDefaultPhoto(contact: GooglePerson): boolean {
194
+ return contact.photos?.some(p => !p.default) || false;
195
+ }
196
+
197
+ function isStarred(contact: GooglePerson): boolean {
198
+ return contact.memberships?.some(m =>
199
+ m.contactGroupMembership?.contactGroupId === 'starred'
200
+ ) || false;
201
+ }
202
+
203
+ function generateGuid(): string {
204
+ return crypto.randomUUID();
205
+ }
206
+
207
+ function loadSyncToken(paths: UserPaths): string {
141
208
  if (fs.existsSync(paths.syncTokenFile)) {
142
209
  const data = JSON.parse(fs.readFileSync(paths.syncTokenFile, 'utf-8'));
143
210
  return data.syncToken || null;
@@ -163,7 +230,7 @@ async function fetchContactsWithRetry(
163
230
  const BASE_DELAY = 5000; /** 5 seconds base delay */
164
231
 
165
232
  const params = new URLSearchParams({
166
- personFields: 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays,photos,memberships,metadata',
233
+ personFields: 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays,photos,memberships,metadata,fileAses',
167
234
  pageSize: '100' /** Smaller page size to avoid quota issues */
168
235
  });
169
236
 
@@ -230,8 +297,39 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
230
297
  const accessToken = await getAccessToken(user);
231
298
 
232
299
  const index = loadIndex(paths);
300
+ const deletedIndex = loadDeleted(paths);
301
+ const photosIndex = loadPhotos(paths);
233
302
  let syncToken = options.full ? null : loadSyncToken(paths);
234
303
 
304
+ // Migrate any old deleted entries from index.json to deleted.json
305
+ // Also clean up old fields (deleted: false, etag)
306
+ let migrated = 0;
307
+ let cleaned = 0;
308
+ for (const [rn, entry] of Object.entries(index.contacts)) {
309
+ const oldEntry = entry as any;
310
+ if (oldEntry.deleted === true) {
311
+ deletedIndex.deleted[rn] = { ...entry, deletedAt: oldEntry.deletedAt || new Date().toISOString() };
312
+ delete index.contacts[rn];
313
+ migrated++;
314
+ } else {
315
+ // Clean up old fields
316
+ if ('deleted' in oldEntry) {
317
+ delete oldEntry.deleted;
318
+ cleaned++;
319
+ }
320
+ if ('etag' in oldEntry) {
321
+ delete oldEntry.etag;
322
+ cleaned++;
323
+ }
324
+ }
325
+ }
326
+ if (migrated > 0) {
327
+ console.log(`Migrated ${migrated} deleted entries to deleted.json`);
328
+ }
329
+ if (cleaned > 0) {
330
+ console.log(`Cleaned ${cleaned} old fields from index entries`);
331
+ }
332
+
235
333
  if (syncToken) {
236
334
  console.log('Performing incremental sync...');
237
335
  } else {
@@ -243,6 +341,7 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
243
341
  let added = 0;
244
342
  let updated = 0;
245
343
  let deleted = 0;
344
+ let conflicts = 0;
246
345
  let pageNum = 0;
247
346
 
248
347
  do {
@@ -257,23 +356,84 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
257
356
  const displayName = person.names?.[0]?.displayName || 'Unknown';
258
357
 
259
358
  if (isDeleted) {
260
- if (index.contacts[person.resourceName]) {
261
- index.contacts[person.resourceName].deleted = true;
262
- index.contacts[person.resourceName].deletedAt = new Date().toISOString();
263
- deleteContactFile(paths, person.resourceName);
359
+ const entry = index.contacts[person.resourceName];
360
+ if (entry) {
361
+ const id = person.resourceName.replace('people/', '');
362
+ const contactFile = path.join(paths.contactsDir, `${id}.json`);
363
+
364
+ // Save photos before deleting
365
+ if (fs.existsSync(contactFile)) {
366
+ try {
367
+ const contact = JSON.parse(fs.readFileSync(contactFile, 'utf-8')) as GooglePerson;
368
+ const photos = extractNonDefaultPhotos(contact);
369
+ if (photos.length > 0) {
370
+ photosIndex.photos[person.resourceName] = {
371
+ displayName: entry.displayName,
372
+ photos,
373
+ deletedAt: new Date().toISOString()
374
+ };
375
+ }
376
+ } catch { /* ignore read errors */ }
377
+ }
378
+
379
+ // Move to deleted.json
380
+ const deletedEntry = { ...entry, deletedAt: new Date().toISOString(), _delete: 'server' };
381
+ deletedIndex.deleted[person.resourceName] = deletedEntry;
382
+ delete index.contacts[person.resourceName];
383
+
384
+ // Backup contact file to deleted/ folder
385
+ if (fs.existsSync(contactFile)) {
386
+ if (!fs.existsSync(paths.deletedDir)) {
387
+ fs.mkdirSync(paths.deletedDir, { recursive: true });
388
+ }
389
+ const contactData = JSON.parse(fs.readFileSync(contactFile, 'utf-8'));
390
+ contactData._deletedAt = new Date().toISOString();
391
+ contactData._delete = 'server';
392
+ const destPath = path.join(paths.deletedDir, `${id}.json`);
393
+ fs.writeFileSync(destPath, JSON.stringify(contactData, null, 2));
394
+ fs.unlinkSync(contactFile);
395
+ }
396
+
264
397
  deleted++;
265
398
  if (options.verbose) console.log(`\n Deleted: ${displayName}`);
266
399
  }
267
400
  } else {
268
401
  const existed = !!index.contacts[person.resourceName];
402
+
403
+ // Check for local conflict before overwriting
404
+ const filePath = path.join(paths.contactsDir, `${person.resourceName.replace(/\//g, '_')}.json`);
405
+ const indexEntry = index.contacts[person.resourceName];
406
+ if (existed && fs.existsSync(filePath)) {
407
+ const stat = fs.statSync(filePath);
408
+ const modTime = stat.mtimeMs;
409
+ const indexUpdatedAt = indexEntry?.updatedAt ? new Date(indexEntry.updatedAt).getTime() : 0;
410
+
411
+ if (modTime > indexUpdatedAt + 1000) {
412
+ // Local file was modified - conflict!
413
+ console.log(`\n CONFLICT: ${displayName} - local changes exist, skipping (delete local file and sync to get server version)`);
414
+ conflicts++;
415
+ continue;
416
+ }
417
+ }
418
+
269
419
  saveContact(paths, person);
270
420
 
421
+ // Extract GUIDs from userDefined fields
422
+ const guids = extractGuids(person);
423
+ const hasPhoto = hasNonDefaultPhoto(person);
424
+ const starred = isStarred(person);
425
+
426
+ // Preserve _delete flag from existing entry
427
+ const existingDelete = index.contacts[person.resourceName]?._delete;
428
+
271
429
  index.contacts[person.resourceName] = {
272
430
  resourceName: person.resourceName,
273
431
  displayName,
274
- etag: person.etag,
275
- deleted: false,
276
- updatedAt: new Date().toISOString()
432
+ updatedAt: new Date().toISOString(),
433
+ ...(hasPhoto && { hasPhoto }),
434
+ ...(starred && { starred }),
435
+ ...(guids.length > 0 && { guids }),
436
+ ...(existingDelete && { _delete: existingDelete })
277
437
  };
278
438
 
279
439
  if (existed) {
@@ -298,21 +458,31 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
298
458
  // Save index after each page in case of interruption
299
459
  index.lastSync = new Date().toISOString();
300
460
  saveIndex(paths, index);
461
+ saveDeleted(paths, deletedIndex);
462
+ savePhotos(paths, photosIndex);
301
463
 
302
464
  // Small delay between pages to be nice to the API
303
465
  if (pageToken) {
304
466
  await sleep(500);
305
467
  }
468
+
469
+ if (escapePressed) {
470
+ console.log('\n\nStopped by user. Progress saved.');
471
+ break;
472
+ }
306
473
  } while (pageToken);
307
474
 
308
- const activeContacts = Object.values(index.contacts).filter(c => !c.deleted).length;
309
- const tombstones = Object.values(index.contacts).filter(c => c.deleted).length;
475
+ const activeContacts = Object.keys(index.contacts).length;
476
+ const tombstones = Object.keys(deletedIndex.deleted).length;
310
477
 
311
478
  console.log(`\n\nSync complete:`);
312
479
  console.log(` Processed: ${totalProcessed}`);
313
480
  console.log(` Added: ${added}`);
314
481
  console.log(` Updated: ${updated}`);
315
482
  console.log(` Deleted: ${deleted}`);
483
+ if (conflicts > 0) {
484
+ console.log(` Conflicts: ${conflicts} (delete local file and sync to resolve)`);
485
+ }
316
486
  console.log(` Active contacts: ${activeContacts}`);
317
487
  console.log(` Tombstones: ${tombstones}`);
318
488
  }
@@ -328,48 +498,96 @@ function saveStatus(paths: UserPaths, status: PushStatus): void {
328
498
  fs.writeFileSync(paths.statusFile, JSON.stringify(status, null, 2));
329
499
  }
330
500
 
331
- function findPendingChanges(paths: UserPaths): PendingChange[] {
332
- const status = loadStatus(paths);
333
- const lastPush = status.lastPush ? new Date(status.lastPush).getTime() : 0;
501
+ interface PendingChangesResult {
502
+ changes: PendingChange[];
503
+ parseErrors: string[];
504
+ }
505
+
506
+ async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?: string): Promise<PendingChangesResult> {
334
507
  const changes: PendingChange[] = [];
508
+ const parseErrors: string[] = [];
509
+ const index = loadIndex(paths);
510
+ // Use -since override if provided, otherwise use lastSync
511
+ const sinceTime = since ? new Date(since).getTime() : (index.lastSync ? new Date(index.lastSync).getTime() : 0);
512
+ if (since) {
513
+ console.log(`Using -since override: ${since}`);
514
+ }
515
+
516
+ // Check _delete.json for deletion requests
517
+ const deleteQueue = loadDeleteQueue(paths);
518
+ for (const entry of deleteQueue.entries) {
519
+ const filePath = path.join(paths.contactsDir, `${entry.resourceName.replace('people/', '')}.json`);
520
+ changes.push({
521
+ type: 'delete',
522
+ resourceName: entry.resourceName,
523
+ displayName: entry.displayName,
524
+ filePath,
525
+ _delete: entry._delete || 'queued'
526
+ });
527
+ }
335
528
 
336
- // Check contacts/ for modified files and _deleted markers
529
+ // Check contacts/ for modified files and _delete markers
530
+ // Only check files modified after lastSync (fast filter using mtime)
337
531
  if (fs.existsSync(paths.contactsDir)) {
338
532
  const files = fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'));
533
+ let checked = 0;
534
+
339
535
  for (const file of files) {
340
536
  const filePath = path.join(paths.contactsDir, file);
341
- const stat = fs.statSync(filePath);
537
+ const stat = await fp.stat(filePath);
342
538
  const modTime = stat.mtimeMs;
343
539
 
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';
540
+ // Skip files not modified since last sync (or -since override)
541
+ if (modTime <= sinceTime + 1000) continue;
542
+
543
+ checked++;
544
+ let content: GooglePerson & { _delete?: boolean };
545
+ try {
546
+ content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
547
+ } catch (e: any) {
548
+ const errMsg = `[PARSE ERROR] ${file}: ${e.message}`;
549
+ parseErrors.push(errMsg);
550
+ logger.error(errMsg);
551
+ continue;
552
+ }
347
553
 
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
- }
554
+ const displayName = content.names?.[0]?.displayName || 'Unknown';
555
+
556
+ if (content._delete) {
557
+ changes.push({
558
+ type: 'delete',
559
+ resourceName: content.resourceName,
560
+ displayName,
561
+ filePath
562
+ });
563
+ } else {
564
+ changes.push({
565
+ type: 'update',
566
+ resourceName: content.resourceName,
567
+ displayName,
568
+ filePath
569
+ });
363
570
  }
364
571
  }
572
+ if (checked > 0) {
573
+ console.log(`${ts()} Found ${checked} modified contact(s)`);
574
+ }
365
575
  }
366
576
 
367
- // Check deleted/ folder
368
- if (fs.existsSync(paths.deletedDir)) {
369
- const files = fs.readdirSync(paths.deletedDir).filter(f => f.endsWith('.json'));
577
+ // Check _delete/ folder for user deletion requests
578
+ if (fs.existsSync(paths.toDeleteDir)) {
579
+ const files = fs.readdirSync(paths.toDeleteDir).filter(f => f.endsWith('.json'));
370
580
  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;
581
+ const filePath = path.join(paths.toDeleteDir, file);
582
+ let content: GooglePerson;
583
+ try {
584
+ content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
585
+ } catch (e: any) {
586
+ const errMsg = `[PARSE ERROR] ${file}: ${e.message}`;
587
+ parseErrors.push(errMsg);
588
+ logger.error(errMsg);
589
+ continue;
590
+ }
373
591
  const displayName = content.names?.[0]?.displayName || 'Unknown';
374
592
 
375
593
  changes.push({
@@ -381,10 +599,41 @@ function findPendingChanges(paths: UserPaths): PendingChange[] {
381
599
  }
382
600
  }
383
601
 
384
- return changes;
602
+ // Check _add/ folder for new contacts to create
603
+ if (fs.existsSync(paths.toAddDir)) {
604
+ const files = fs.readdirSync(paths.toAddDir).filter(f => f.endsWith('.json'));
605
+ for (const file of files) {
606
+ const filePath = path.join(paths.toAddDir, file);
607
+ let content: GooglePerson;
608
+ try {
609
+ content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
610
+ } catch (e: any) {
611
+ const errMsg = `[PARSE ERROR] ${file}: ${e.message}`;
612
+ parseErrors.push(errMsg);
613
+ logger.error(errMsg);
614
+ continue;
615
+ }
616
+ const displayName = content.names?.[0]?.displayName || 'Unknown';
617
+
618
+ changes.push({
619
+ type: 'add',
620
+ resourceName: '', // Will be assigned by Google
621
+ displayName,
622
+ filePath
623
+ });
624
+ }
625
+ }
626
+
627
+ return { changes, parseErrors };
385
628
  }
386
629
 
387
630
  async function confirm(message: string): Promise<boolean> {
631
+ // Temporarily disable raw mode for readline to work
632
+ const wasRawMode = process.stdin.isTTY && (process.stdin as any).isRaw;
633
+ if (wasRawMode) {
634
+ process.stdin.setRawMode(false);
635
+ }
636
+
388
637
  const readline = await import('readline');
389
638
  const rl = readline.createInterface({
390
639
  input: process.stdin,
@@ -394,55 +643,182 @@ async function confirm(message: string): Promise<boolean> {
394
643
  return new Promise(resolve => {
395
644
  rl.question(`${message} (y/N): `, answer => {
396
645
  rl.close();
646
+ // Restore raw mode if it was enabled
647
+ if (wasRawMode && process.stdin.isTTY) {
648
+ process.stdin.setRawMode(true);
649
+ }
397
650
  resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
398
651
  });
399
652
  });
400
653
  }
401
654
 
402
- async function updateContactOnGoogle(accessToken: string, person: GooglePerson): Promise<void> {
403
- const updateMask = 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays';
655
+ async function updateContactOnGoogle(person: GooglePerson, retryCount = 0, tokenRefreshed = false): Promise<void> {
656
+ const updateMask = 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays,fileAses';
404
657
 
405
658
  const response = await fetch(`${PEOPLE_API_BASE}/${person.resourceName}:updateContact?updatePersonFields=${updateMask}`, {
406
659
  method: 'PATCH',
407
660
  headers: {
408
- 'Authorization': `Bearer ${accessToken}`,
661
+ 'Authorization': `Bearer ${currentAccessToken}`,
409
662
  'Content-Type': 'application/json'
410
663
  },
411
664
  body: JSON.stringify(person)
412
665
  });
413
666
 
667
+ // Token expired - refresh and retry once
668
+ if (response.status === 401 && !tokenRefreshed) {
669
+ await refreshAccessToken();
670
+ return updateContactOnGoogle(person, retryCount, true);
671
+ }
672
+
673
+ if (response.status === 429) {
674
+ if (retryCount >= 3 || escapePressed) {
675
+ throw new Error(`rate limit - try again later`);
676
+ }
677
+ const delay = Math.pow(2, retryCount + 1) * 10000; // 20s, 40s, 80s
678
+ console.log(` ${ts()} rate limited, waiting ${delay / 1000}s (ESC to stop)...`);
679
+ await sleep(delay);
680
+ if (escapePressed) throw new Error(`stopped by user`);
681
+ console.log(`${ts()} retrying...`);
682
+ return updateContactOnGoogle(person, retryCount + 1, tokenRefreshed);
683
+ }
684
+
414
685
  if (!response.ok) {
415
- throw new Error(`Update failed for ${person.resourceName}: ${response.status} ${await response.text()}`);
686
+ throw new Error(`${response.status} ${await response.text()}`);
416
687
  }
417
688
  }
418
689
 
419
- async function deleteContactOnGoogle(accessToken: string, resourceName: string): Promise<void> {
690
+ async function deleteContactOnGoogle(resourceName: string, retryCount = 0, tokenRefreshed = false): Promise<void> {
420
691
  const response = await fetch(`${PEOPLE_API_BASE}/${resourceName}:deleteContact`, {
421
692
  method: 'DELETE',
422
- headers: { 'Authorization': `Bearer ${accessToken}` }
693
+ headers: { 'Authorization': `Bearer ${currentAccessToken}` }
423
694
  });
424
695
 
696
+ // Already deleted - treat as success
697
+ if (response.status === 404) {
698
+ console.log(' (already deleted)');
699
+ return;
700
+ }
701
+
702
+ // Token expired - refresh and retry once
703
+ if (response.status === 401 && !tokenRefreshed) {
704
+ await refreshAccessToken();
705
+ return deleteContactOnGoogle(resourceName, retryCount, true);
706
+ }
707
+
708
+ if (response.status === 429) {
709
+ if (retryCount >= 3 || escapePressed) {
710
+ throw new Error(`rate limit - try again later`);
711
+ }
712
+ const delay = Math.pow(2, retryCount + 1) * 10000; // 20s, 40s, 80s
713
+ console.log(` ${ts()} rate limited, waiting ${delay / 1000}s (ESC to stop)...`);
714
+ await sleep(delay);
715
+ if (escapePressed) throw new Error(`stopped by user`);
716
+ console.log(`${ts()} retrying...`);
717
+ return deleteContactOnGoogle(resourceName, retryCount + 1, tokenRefreshed);
718
+ }
719
+
425
720
  if (!response.ok) {
426
- throw new Error(`Delete failed for ${resourceName}: ${response.status} ${await response.text()}`);
721
+ throw new Error(`${response.status} ${await response.text()}`);
427
722
  }
428
723
  }
429
724
 
430
- async function pushContacts(user: string, options: { yes: boolean; verbose: boolean }): Promise<void> {
725
+ async function createContactOnGoogle(person: GooglePerson, retryCount = 0, tokenRefreshed = false): Promise<GooglePerson> {
726
+ // Remove resourceName and etag - Google will assign new ones
727
+ const { resourceName, etag, ...personData } = person as any;
728
+
729
+ // Add a GUID if not already present in userDefined
730
+ if (!personData.userDefined) {
731
+ personData.userDefined = [];
732
+ }
733
+ const hasGuid = personData.userDefined.some((u: any) => u.key === GCARDS_GUID_KEY);
734
+ if (!hasGuid) {
735
+ personData.userDefined.push({ key: GCARDS_GUID_KEY, value: generateGuid() });
736
+ }
737
+
738
+ const response = await fetch(`${PEOPLE_API_BASE}/people:createContact`, {
739
+ method: 'POST',
740
+ headers: {
741
+ 'Authorization': `Bearer ${currentAccessToken}`,
742
+ 'Content-Type': 'application/json'
743
+ },
744
+ body: JSON.stringify(personData)
745
+ });
746
+
747
+ // Token expired - refresh and retry once
748
+ if (response.status === 401 && !tokenRefreshed) {
749
+ await refreshAccessToken();
750
+ return createContactOnGoogle(person, retryCount, true);
751
+ }
752
+
753
+ if (response.status === 429) {
754
+ if (retryCount >= 3 || escapePressed) {
755
+ throw new Error(`rate limit - try again later`);
756
+ }
757
+ const delay = Math.pow(2, retryCount + 1) * 10000; // 20s, 40s, 80s
758
+ console.log(` ${ts()} rate limited, waiting ${delay / 1000}s (ESC to stop)...`);
759
+ await sleep(delay);
760
+ if (escapePressed) throw new Error(`stopped by user`);
761
+ console.log(`${ts()} retrying...`);
762
+ return createContactOnGoogle(person, retryCount + 1, tokenRefreshed);
763
+ }
764
+
765
+ if (!response.ok) {
766
+ throw new Error(`${response.status} ${await response.text()}`);
767
+ }
768
+
769
+ return await response.json();
770
+ }
771
+
772
+ async function pushContacts(user: string, options: { yes: boolean; verbose: boolean; limit: number; since: string }): Promise<void> {
431
773
  const paths = getUserPaths(user);
432
774
  ensureUserDir(user);
433
775
 
776
+ // Create logger for problems
777
+ const problemsFile = path.join(paths.userDir, 'pushproblems.txt');
778
+ const logger = new FileLogger(problemsFile, true);
779
+
434
780
  console.log(`Pushing contacts for user: ${user}`);
435
- const changes = findPendingChanges(paths);
781
+ let { changes, parseErrors } = await findPendingChanges(paths, logger, options.since || undefined);
782
+
783
+ if (parseErrors.length > 0) {
784
+ console.log(`\n${parseErrors.length} files had parse errors (see pushproblems.txt)`);
785
+ }
436
786
 
437
787
  if (changes.length === 0) {
438
788
  console.log('No pending changes to push.');
439
789
  return;
440
790
  }
441
791
 
442
- const updates = changes.filter(c => c.type === 'update');
443
- const deletes = changes.filter(c => c.type === 'delete');
792
+ const adds = changes.filter(c => c.type === 'add').sort((a, b) => a.displayName.localeCompare(b.displayName));
793
+ const updates = changes.filter(c => c.type === 'update').sort((a, b) => a.displayName.localeCompare(b.displayName));
794
+ const deletes = changes.filter(c => c.type === 'delete').sort((a, b) => a.displayName.localeCompare(b.displayName));
795
+
796
+ // Write to toPush.txt for debugging
797
+ const toPushLines: string[] = [];
798
+ if (adds.length > 0) {
799
+ toPushLines.push(`New contacts (${adds.length}):`);
800
+ for (const c of adds) toPushLines.push(` ${c.displayName}`);
801
+ toPushLines.push('');
802
+ }
803
+ if (updates.length > 0) {
804
+ toPushLines.push(`Updates (${updates.length}):`);
805
+ for (const c of updates) toPushLines.push(` ${c.displayName} (${c.resourceName})`);
806
+ toPushLines.push('');
807
+ }
808
+ if (deletes.length > 0) {
809
+ toPushLines.push(`Deletions (${deletes.length}):`);
810
+ for (const c of deletes) toPushLines.push(` ${c.displayName} (${c.resourceName})`);
811
+ }
812
+ const toPushFile = path.join(paths.userDir, 'toPush.txt');
813
+ fs.writeFileSync(toPushFile, toPushLines.join('\n'));
444
814
 
445
815
  console.log('\nPending changes:');
816
+ if (adds.length > 0) {
817
+ console.log(`\n New contacts (${adds.length}):`);
818
+ for (const c of adds) {
819
+ console.log(` - ${c.displayName}`);
820
+ }
821
+ }
446
822
  if (updates.length > 0) {
447
823
  console.log(`\n Updates (${updates.length}):`);
448
824
  for (const c of updates) {
@@ -455,6 +831,10 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
455
831
  console.log(` - ${c.displayName} (${c.resourceName})`);
456
832
  }
457
833
  }
834
+ console.log(`\n(List written to ${toPushFile})`);
835
+
836
+ // Reorder changes to process in sorted order
837
+ changes = [...adds, ...updates, ...deletes];
458
838
 
459
839
  if (!options.yes) {
460
840
  const confirmed = await confirm(`\nPush ${changes.length} change(s) to Google?`);
@@ -465,110 +845,265 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
465
845
  }
466
846
 
467
847
  console.log('\nGetting access token (write access)...');
468
- const accessToken = await getAccessToken(user, true);
848
+ currentUser = user;
849
+ currentAccessToken = await getAccessToken(user, true);
469
850
 
470
851
  const index = loadIndex(paths);
852
+ const deletedIndex = loadDeleted(paths);
853
+ const deleteQueue = loadDeleteQueue(paths);
854
+ const completedDeletions = new Set<string>(); // Track successful deletions
855
+ // Apply limit if specified
856
+ if (options.limit > 0 && changes.length > options.limit) {
857
+ console.log(`[DEBUG] Limiting to first ${options.limit} of ${changes.length} changes\n`);
858
+ changes = changes.slice(0, options.limit);
859
+ }
860
+
471
861
  let successCount = 0;
472
862
  let errorCount = 0;
863
+ let skippedPhoto = 0;
864
+ let skippedStarred = 0;
865
+ let processed = 0;
866
+ const total = changes.length;
867
+ const problems: string[] = [];
868
+ const notDeleted: { resourceName: string; displayName: string; filePath: string; reason: string }[] = [];
473
869
 
474
870
  for (const change of changes) {
871
+ if (escapePressed) {
872
+ console.log('\n\nStopped by user. Progress saved.');
873
+ break;
874
+ }
875
+
876
+ processed++;
475
877
  try {
476
- if (change.type === 'update') {
878
+ if (change.type === 'add') {
477
879
  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');
880
+ process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Creating ${change.displayName}...`);
881
+ const created = await createContactOnGoogle(content);
882
+
883
+ // Extract GUIDs and flags from created contact
884
+ const guids = extractGuids(created);
885
+ const hasPhoto = hasNonDefaultPhoto(created);
886
+ const starred = isStarred(created);
887
+
888
+ // Add to index
889
+ index.contacts[created.resourceName] = {
890
+ resourceName: created.resourceName,
891
+ displayName: change.displayName,
892
+ updatedAt: new Date().toISOString(),
893
+ ...(hasPhoto && { hasPhoto }),
894
+ ...(starred && { starred }),
895
+ ...(guids.length > 0 && { guids })
896
+ };
897
+
898
+ // Save new contact to contacts/ folder
899
+ const newFilePath = path.join(paths.contactsDir, `${created.resourceName.replace(/\//g, '_')}.json`);
900
+ if (!fs.existsSync(paths.contactsDir)) {
901
+ fs.mkdirSync(paths.contactsDir, { recursive: true });
902
+ }
903
+ fs.writeFileSync(newFilePath, JSON.stringify(created, null, 2));
904
+
905
+ // Remove from _add/ folder
906
+ fs.unlinkSync(change.filePath);
907
+
908
+ console.log(` done (${created.resourceName})`);
481
909
  successCount++;
482
- } else {
483
- process.stdout.write(`Deleting ${change.displayName}...`);
484
- await deleteContactOnGoogle(accessToken, change.resourceName);
910
+ } else if (change.type === 'update') {
911
+ const content = JSON.parse(fs.readFileSync(change.filePath, 'utf-8')) as GooglePerson;
912
+ process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Updating ${change.displayName}...`);
913
+ await updateContactOnGoogle(content);
485
914
 
486
- // Update index
915
+ // Update index timestamp so file no longer appears modified
487
916
  if (index.contacts[change.resourceName]) {
488
- index.contacts[change.resourceName].deleted = true;
489
- index.contacts[change.resourceName].deletedAt = new Date().toISOString();
490
- }
491
-
492
- // Remove from deleted/ folder if it was there
493
- if (change.filePath.startsWith(paths.deletedDir)) {
494
- fs.unlinkSync(change.filePath);
917
+ index.contacts[change.resourceName].updatedAt = new Date().toISOString();
495
918
  }
496
919
 
497
920
  console.log(' done');
498
921
  successCount++;
922
+ } else if (change.type === 'delete') {
923
+ const contactFile = path.join(paths.contactsDir, `${change.resourceName.replace('people/', '')}.json`);
924
+ const fileToRead = fs.existsSync(contactFile) ? contactFile : change.filePath;
925
+
926
+ // Check if contact has a real photo or is starred - skip deletion if so
927
+ let contactHasPhoto = false;
928
+ let contactIsStarred = false;
929
+ if (fs.existsSync(fileToRead)) {
930
+ try {
931
+ const contact = JSON.parse(fs.readFileSync(fileToRead, 'utf-8')) as GooglePerson;
932
+ contactHasPhoto = hasNonDefaultPhoto(contact);
933
+ contactIsStarred = isStarred(contact);
934
+ } catch { /* ignore read errors */ }
935
+ }
936
+
937
+ // Determine skip reason (photo takes precedence over starred)
938
+ // Use * prefix to indicate "skipped, not deleted"
939
+ // 'force' overrides photo/starred protection
940
+ const isForced = change._delete === 'force' || index.contacts[change.resourceName]?._delete === 'force';
941
+ const skipReason = isForced ? null : (contactHasPhoto ? '*photo' : (contactIsStarred ? '*starred' : null));
942
+
943
+ if (skipReason) {
944
+ // Skip Google deletion - mark why in index.json
945
+ notDeleted.push({
946
+ resourceName: change.resourceName,
947
+ displayName: change.displayName,
948
+ filePath: fileToRead,
949
+ reason: skipReason
950
+ });
951
+ if (skipReason === '*photo') skippedPhoto++;
952
+ else if (skipReason === '*starred') skippedStarred++;
953
+ console.log(`${ts()} [${String(processed).padStart(5)}/${total}] SKIPPED ${change.displayName} (${skipReason})`);
954
+
955
+ // Update _delete to show skip reason
956
+ if (index.contacts[change.resourceName]) {
957
+ index.contacts[change.resourceName]._delete = skipReason;
958
+ }
959
+
960
+ // Remove from _delete/ folder if it was there (keep in contacts/)
961
+ if (change.filePath !== fileToRead && change.filePath.startsWith(paths.toDeleteDir) && fs.existsSync(change.filePath)) {
962
+ fs.unlinkSync(change.filePath);
963
+ }
964
+
965
+ completedDeletions.add(change.resourceName); // Remove from queue even if skipped
966
+ } else {
967
+ process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Deleting ${change.displayName}...`);
968
+ await deleteContactOnGoogle(change.resourceName);
969
+
970
+ // Move to deleted.json (preserve _delete reason)
971
+ const entry = index.contacts[change.resourceName];
972
+ if (entry) {
973
+ const deletedEntry = { ...entry, deletedAt: new Date().toISOString() };
974
+ // Keep _delete reason in deleted entry (it's the reason for deletion)
975
+ deletedIndex.deleted[change.resourceName] = deletedEntry;
976
+ delete index.contacts[change.resourceName];
977
+ }
978
+
979
+ // Move contact file to deleted/ folder (backup with timestamp)
980
+ if (!fs.existsSync(paths.deletedDir)) {
981
+ fs.mkdirSync(paths.deletedDir, { recursive: true });
982
+ }
983
+ if (fs.existsSync(contactFile)) {
984
+ // Add deletion metadata to the JSON file
985
+ const contactData = JSON.parse(fs.readFileSync(contactFile, 'utf-8'));
986
+ contactData._deletedAt = new Date().toISOString();
987
+ contactData._delete = change._delete || entry?._delete || 'unknown';
988
+ const destPath = path.join(paths.deletedDir, path.basename(contactFile));
989
+ fs.writeFileSync(destPath, JSON.stringify(contactData, null, 2));
990
+ fs.unlinkSync(contactFile);
991
+ }
992
+
993
+ // Remove from _delete/ folder if it was there
994
+ if (change.filePath.startsWith(paths.toDeleteDir) && fs.existsSync(change.filePath)) {
995
+ fs.unlinkSync(change.filePath);
996
+ }
997
+
998
+ completedDeletions.add(change.resourceName);
999
+ console.log(' done');
1000
+ successCount++;
1001
+ }
499
1002
  }
500
1003
 
501
- await sleep(200); // Be nice to the API
1004
+ await sleep(700); // 90 writes/min limit = ~700ms between ops
502
1005
  } catch (error: any) {
1006
+ const fileName = path.basename(change.filePath);
1007
+ const errorMsg = `${fileName}: ${error.message}`;
503
1008
  console.log(` ERROR: ${error.message}`);
1009
+ problems.push(errorMsg);
504
1010
  errorCount++;
505
- debugger;
506
1011
  }
507
1012
  }
508
1013
 
509
- // Save status with new timestamp
510
- saveStatus(paths, { lastPush: new Date().toISOString() });
511
- saveIndex(paths, index);
512
-
513
- console.log(`\nPush complete: ${successCount} succeeded, ${errorCount} failed`);
514
- }
1014
+ // Write problems to file if any
1015
+ if (problems.length > 0) {
1016
+ const problemsFile = path.join(paths.userDir, 'pushproblems.txt');
1017
+ fs.writeFileSync(problemsFile, problems.join('\n'));
1018
+ console.log(`\nErrors written to: ${problemsFile}`);
1019
+ }
515
1020
 
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;
1021
+ // Write notdeleted file if any contacts were skipped due to photos
1022
+ if (notDeleted.length > 0) {
1023
+ const notDeletedFile = path.join(paths.userDir, 'notdeleted.json');
1024
+ fs.writeFileSync(notDeletedFile, JSON.stringify(notDeleted, null, 2));
1025
+ console.log(`\n${notDeleted.length} contacts with photos not deleted from Google (see notdeleted.json)`);
524
1026
  }
525
1027
 
526
- // 2. Use lastUser from config (already normalized)
527
- const config = loadConfig();
528
- if (config.lastUser) {
529
- return config.lastUser;
1028
+ // Remove completed deletions from queue
1029
+ if (completedDeletions.size > 0) {
1030
+ deleteQueue.entries = deleteQueue.entries.filter(e => !completedDeletions.has(e.resourceName));
1031
+ saveDeleteQueue(paths, deleteQueue);
530
1032
  }
531
1033
 
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);
1034
+ // Save status with new timestamp
1035
+ saveStatus(paths, { lastPush: new Date().toISOString() });
1036
+ saveIndex(paths, index);
1037
+ saveDeleted(paths, deletedIndex);
1038
+
1039
+ const skipped = skippedPhoto + skippedStarred;
1040
+ const skippedDetails = skipped > 0 ? ` (${skippedPhoto} photo, ${skippedStarred} starred)` : '';
1041
+ console.log(`\nPush complete: ${successCount} succeeded, ${errorCount} failed, ${skipped} skipped${skippedDetails}`);
536
1042
  }
537
1043
 
538
1044
  async function main(): Promise<void> {
539
1045
  const args = process.argv.slice(2);
540
1046
  const options = parseArgs(args);
541
1047
 
542
- if (options.help || !options.command) {
1048
+ if (options.help) {
543
1049
  showHelp();
544
1050
  return;
545
1051
  }
546
1052
 
1053
+ if (!options.command) {
1054
+ showUsage();
1055
+ return;
1056
+ }
1057
+
1058
+ setupEscapeHandler();
1059
+
547
1060
  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();
1061
+ // Determine users to process
1062
+ let users: string[];
1063
+ if (options.all) {
1064
+ users = getAllUsers();
1065
+ if (users.length === 0) {
1066
+ console.error('No users found. Use --user <name> to create one first.');
560
1067
  process.exit(1);
1068
+ }
1069
+ console.log(`Processing all users: ${users.join(', ')}\n`);
1070
+ } else {
1071
+ users = [resolveUser(options.user, true)];
561
1072
  }
562
1073
 
563
- // Update lastUser in config
564
- const config = loadConfig();
565
- config.lastUser = user;
566
- saveConfig(config);
1074
+ for (const user of users) {
1075
+ if (escapePressed) break;
1076
+
1077
+ if (users.length > 1) {
1078
+ console.log(`\n${'='.repeat(50)}\nUser: ${user}\n${'='.repeat(50)}`);
1079
+ }
1080
+
1081
+ switch (options.command) {
1082
+ case 'sync':
1083
+ await syncContacts(user, { full: options.full, verbose: options.verbose });
1084
+ break;
1085
+ case 'push':
1086
+ await pushContacts(user, { yes: options.yes, verbose: options.verbose, limit: options.limit, since: options.since });
1087
+ break;
1088
+ default:
1089
+ console.error(`Unknown command: ${options.command}`);
1090
+ showHelp();
1091
+ process.exit(1);
1092
+ }
1093
+ }
1094
+
1095
+ // Update lastUser in config (use last processed user)
1096
+ if (!options.all && users.length === 1) {
1097
+ const config = loadConfig();
1098
+ config.lastUser = users[0];
1099
+ saveConfig(config);
1100
+ }
567
1101
 
568
1102
  } catch (error: any) {
569
1103
  console.error(`Error: ${error.message}`);
570
- debugger;
571
1104
  process.exit(1);
1105
+ } finally {
1106
+ cleanupEscapeHandler();
572
1107
  }
573
1108
  }
574
1109