@bobfrankston/gcards 0.1.4 → 0.1.6

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.
@@ -19,7 +19,10 @@
19
19
  "Bash(node gfix.ts:*)",
20
20
  "Bash(node --import tsx gcards.ts:*)",
21
21
  "Bash(npm install:*)",
22
- "Bash(npx tsx:*)"
22
+ "Bash(npx tsx:*)",
23
+ "WebFetch(domain:lh3.googleusercontent.com)",
24
+ "Bash(curl:*)",
25
+ "Bash(bun build:*)"
23
26
  ],
24
27
  "deny": [],
25
28
  "ask": []
package/README.md CHANGED
@@ -47,6 +47,8 @@ gfix birthday -u user --apply # Extract to CSV and remove
47
47
 
48
48
  gfix undup -u user # Find duplicate contacts -> merger.json
49
49
  gfix merge -u user # Merge duplicates locally (then use gcards push)
50
+
51
+ gfix export -u source -to target "pattern" # Export contacts to another user
50
52
  ```
51
53
 
52
54
  ## Google People API Fields
@@ -119,18 +121,19 @@ gcards/ # App directory (%APPDATA%\gcards or ~/.config/gcards)
119
121
  data/ # User data directory
120
122
  <username>/
121
123
  contacts/ # Active contacts (*.json)
124
+ deleted/ # Backup of deleted contact files
122
125
  _delete/ # User requests to delete (move files here)
123
126
  _add/ # User requests to add new contacts
124
127
  fix-logs/ # Logs from gfix operations
125
- index.json # Active contact index
126
- deleted.json # Deleted contacts index (tombstones)
128
+ index.json # Active contact index (hasPhoto, starred flags)
129
+ deleted.json # Deleted contacts index
130
+ notdeleted.json # Contacts skipped due to photo/starred
127
131
  token.json # OAuth token (read-only)
128
132
  token-write.json # OAuth token (read-write)
129
133
  changes.log # Log from gfix names
130
134
  birthdays.csv # Extracted birthdays
131
135
  merger.json # Duplicates to merge (from gfix undup)
132
136
  merged.json # Processed merges (history)
133
- photos.json # Photos from deleted contacts
134
137
  ```
135
138
 
136
139
  ## Fixes Applied by `gfix inspect/apply`
@@ -158,13 +161,26 @@ When a contact has a full name in `givenName` but no `familyName`:
158
161
 
159
162
  ## Deleting Contacts
160
163
 
161
- Three ways to delete contacts:
164
+ Three ways to mark contacts for deletion:
162
165
 
163
166
  1. **In index.json**: Add `"_delete": true` to an entry
164
167
  2. **In contact JSON**: Add `"_delete": true` to the contact file
165
168
  3. **Move to _delete/**: Move the contact file to the `_delete/` folder
166
169
 
167
- Then run `gcards push` to apply deletions. Deleted contacts are moved to `deleted.json`.
170
+ Then run `gcards push` to apply deletions.
171
+
172
+ ### Photo/Starred Protection
173
+
174
+ Contacts with photos or starred status are **not deleted** from Google to prevent data loss:
175
+ - Contacts with non-default photos: `_delete` set to `"photo"`
176
+ - Starred/favorite contacts: `_delete` set to `"starred"`
177
+
178
+ These remain in Google and `index.json` with the skip reason. Review `notdeleted.json` after push.
179
+
180
+ ### Backup
181
+
182
+ All deleted contact files are moved to `deleted/` folder as backup (not permanently deleted).
183
+ Index entries move to `deleted.json`.
168
184
 
169
185
  ## Duplicate Contact Merging
170
186
 
@@ -203,6 +219,27 @@ gcards sync -a
203
219
  gcards push -a
204
220
  ```
205
221
 
222
+ ## Exporting Contacts Between Users
223
+
224
+ Transfer contacts from one user's account to another:
225
+
226
+ ```bash
227
+ gfix export -u bob -to alice "John*" # Export contacts matching "John*"
228
+ gfix export -u bob -to ali "*@example.com" # Export by email (partial user match)
229
+ gfix export -u bob -to alice c1234567890 # Export specific contact by ID
230
+ ```
231
+
232
+ **Features:**
233
+ - `-u` source user (partial match supported)
234
+ - `-to` target user (partial match - "ali" matches "alice" if unique)
235
+ - Pattern supports `*` (any chars) and `?` (single char) wildcards
236
+ - Matches on: displayName, givenName, familyName, "Last, First", email
237
+ - Searches both `contacts/` and `deleted/` directories
238
+ - Multiple matches prompt for selection: number(s), `*` for all, `q` to quit
239
+ - Creates cleaned file in target's `_add/` as `x<sourceId>.json`
240
+
241
+ After export, run `gcards push -u <target>` to upload.
242
+
206
243
  ## Setup
207
244
 
208
245
  1. Create Google Cloud project
package/gcards.ts CHANGED
@@ -12,7 +12,7 @@ import { parseArgs, showUsage, showHelp } from './glib/parsecli.ts';
12
12
  import { authenticateOAuth } from '../../../projects/oauth/oauthsupport/index.ts';
13
13
  import type { GooglePerson, GoogleConnectionsResponse } from './glib/types.ts';
14
14
  import { GCARDS_GUID_KEY, extractGuids } from './glib/gctypes.ts';
15
- import type { ContactIndex, IndexEntry, PushStatus, PendingChange, UserPaths } from './glib/gctypes.ts';
15
+ import type { ContactIndex, IndexEntry, DeletedEntry, DeleteQueue, DeleteQueueEntry, PushStatus, PendingChange, UserPaths } from './glib/gctypes.ts';
16
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';
@@ -79,7 +79,8 @@ async function getAccessToken(user: string, writeAccess = false, forceRefresh =
79
79
  tokenDirectory: paths.userDir,
80
80
  tokenFileName,
81
81
  credentialsKey: 'web',
82
- includeOfflineAccess: true
82
+ includeOfflineAccess: true,
83
+ prompt: 'select_account' // Always show account picker for new auth
83
84
  });
84
85
 
85
86
  if (!token) {
@@ -112,7 +113,7 @@ function saveIndex(paths: UserPaths, index: ContactIndex): void {
112
113
 
113
114
  /** Deleted contacts index (separate from active index) */
114
115
  interface DeletedIndex {
115
- deleted: Record<string, IndexEntry>;
116
+ deleted: Record<string, DeletedEntry>;
116
117
  }
117
118
 
118
119
  function loadDeleted(paths: UserPaths): DeletedIndex {
@@ -124,7 +125,7 @@ function loadDeleted(paths: UserPaths): DeletedIndex {
124
125
 
125
126
  function saveDeleted(paths: UserPaths, deletedIndex: DeletedIndex): void {
126
127
  // Sort by displayName
127
- const sorted: Record<string, IndexEntry> = {};
128
+ const sorted: Record<string, DeletedEntry> = {};
128
129
  const entries = Object.entries(deletedIndex.deleted);
129
130
  entries.sort((a, b) => a[1].displayName.localeCompare(b[1].displayName));
130
131
  for (const [key, value] of entries) {
@@ -134,6 +135,72 @@ function saveDeleted(paths: UserPaths, deletedIndex: DeletedIndex): void {
134
135
  fs.writeFileSync(paths.deletedFile, JSON.stringify(deletedIndex, null, 2));
135
136
  }
136
137
 
138
+ interface SavedPhoto {
139
+ url: string;
140
+ sourceType: 'CONTACT' | 'PROFILE';
141
+ }
142
+
143
+ interface PhotoEntry {
144
+ displayName: string;
145
+ photos: SavedPhoto[];
146
+ deletedAt: string;
147
+ }
148
+
149
+ interface PhotosIndex {
150
+ photos: Record<string, PhotoEntry>; // resourceName -> PhotoEntry
151
+ }
152
+
153
+ function loadPhotos(paths: UserPaths): PhotosIndex {
154
+ if (fs.existsSync(paths.photosFile)) {
155
+ return JSON.parse(fs.readFileSync(paths.photosFile, 'utf-8'));
156
+ }
157
+ return { photos: {} };
158
+ }
159
+
160
+ function savePhotos(paths: UserPaths, photosIndex: PhotosIndex): void {
161
+ // Sort by displayName
162
+ const sorted: Record<string, PhotoEntry> = {};
163
+ const entries = Object.entries(photosIndex.photos);
164
+ entries.sort((a, b) => a[1].displayName.localeCompare(b[1].displayName));
165
+ for (const [key, value] of entries) {
166
+ sorted[key] = value;
167
+ }
168
+ photosIndex.photos = sorted;
169
+ fs.writeFileSync(paths.photosFile, JSON.stringify(photosIndex, null, 2));
170
+ }
171
+
172
+ function loadDeleteQueue(paths: UserPaths): DeleteQueue {
173
+ if (fs.existsSync(paths.deleteQueueFile)) {
174
+ return JSON.parse(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
175
+ }
176
+ return { updatedAt: '', entries: [] };
177
+ }
178
+
179
+ function saveDeleteQueue(paths: UserPaths, queue: DeleteQueue): void {
180
+ queue.updatedAt = new Date().toISOString();
181
+ fs.writeFileSync(paths.deleteQueueFile, JSON.stringify(queue, null, 2));
182
+ }
183
+
184
+ function extractNonDefaultPhotos(contact: GooglePerson): SavedPhoto[] {
185
+ if (!contact.photos) return [];
186
+ return contact.photos
187
+ .filter(p => !p.default)
188
+ .map(p => ({
189
+ url: p.url,
190
+ sourceType: (p.metadata?.source?.type || 'CONTACT') as 'CONTACT' | 'PROFILE'
191
+ }));
192
+ }
193
+
194
+ function hasNonDefaultPhoto(contact: GooglePerson): boolean {
195
+ return contact.photos?.some(p => !p.default) || false;
196
+ }
197
+
198
+ function isStarred(contact: GooglePerson): boolean {
199
+ return contact.memberships?.some(m =>
200
+ m.contactGroupMembership?.contactGroupId === 'starred'
201
+ ) || false;
202
+ }
203
+
137
204
  function generateGuid(): string {
138
205
  return crypto.randomUUID();
139
206
  }
@@ -232,20 +299,37 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
232
299
 
233
300
  const index = loadIndex(paths);
234
301
  const deletedIndex = loadDeleted(paths);
302
+ const photosIndex = loadPhotos(paths);
235
303
  let syncToken = options.full ? null : loadSyncToken(paths);
236
304
 
237
- // Migrate any deleted entries from index.json to deleted.json
305
+ // Migrate any old deleted entries from index.json to deleted.json
306
+ // Also clean up old fields (deleted: false, etag)
238
307
  let migrated = 0;
308
+ let cleaned = 0;
239
309
  for (const [rn, entry] of Object.entries(index.contacts)) {
240
- if (entry.deleted) {
241
- deletedIndex.deleted[rn] = entry;
310
+ const oldEntry = entry as any;
311
+ if (oldEntry.deleted === true) {
312
+ deletedIndex.deleted[rn] = { ...entry, deletedAt: oldEntry.deletedAt || new Date().toISOString() };
242
313
  delete index.contacts[rn];
243
314
  migrated++;
315
+ } else {
316
+ // Clean up old fields
317
+ if ('deleted' in oldEntry) {
318
+ delete oldEntry.deleted;
319
+ cleaned++;
320
+ }
321
+ if ('etag' in oldEntry) {
322
+ delete oldEntry.etag;
323
+ cleaned++;
324
+ }
244
325
  }
245
326
  }
246
327
  if (migrated > 0) {
247
328
  console.log(`Migrated ${migrated} deleted entries to deleted.json`);
248
329
  }
330
+ if (cleaned > 0) {
331
+ console.log(`Cleaned ${cleaned} old fields from index entries`);
332
+ }
249
333
 
250
334
  if (syncToken) {
251
335
  console.log('Performing incremental sync...');
@@ -275,12 +359,42 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
275
359
  if (isDeleted) {
276
360
  const entry = index.contacts[person.resourceName];
277
361
  if (entry) {
362
+ const id = person.resourceName.replace('people/', '');
363
+ const contactFile = path.join(paths.contactsDir, `${id}.json`);
364
+
365
+ // Save photos before deleting
366
+ if (fs.existsSync(contactFile)) {
367
+ try {
368
+ const contact = JSON.parse(fs.readFileSync(contactFile, 'utf-8')) as GooglePerson;
369
+ const photos = extractNonDefaultPhotos(contact);
370
+ if (photos.length > 0) {
371
+ photosIndex.photos[person.resourceName] = {
372
+ displayName: entry.displayName,
373
+ photos,
374
+ deletedAt: new Date().toISOString()
375
+ };
376
+ }
377
+ } catch { /* ignore read errors */ }
378
+ }
379
+
278
380
  // Move to deleted.json
279
- entry.deleted = true;
280
- entry.deletedAt = new Date().toISOString();
281
- deletedIndex.deleted[person.resourceName] = entry;
381
+ const deletedEntry = { ...entry, deletedAt: new Date().toISOString(), _delete: 'server' };
382
+ deletedIndex.deleted[person.resourceName] = deletedEntry;
282
383
  delete index.contacts[person.resourceName];
283
- deleteContactFile(paths, person.resourceName);
384
+
385
+ // Backup contact file to deleted/ folder
386
+ if (fs.existsSync(contactFile)) {
387
+ if (!fs.existsSync(paths.deletedDir)) {
388
+ fs.mkdirSync(paths.deletedDir, { recursive: true });
389
+ }
390
+ const contactData = JSON.parse(fs.readFileSync(contactFile, 'utf-8'));
391
+ contactData._deletedAt = new Date().toISOString();
392
+ contactData._delete = 'server';
393
+ const destPath = path.join(paths.deletedDir, `${id}.json`);
394
+ fs.writeFileSync(destPath, JSON.stringify(contactData, null, 2));
395
+ fs.unlinkSync(contactFile);
396
+ }
397
+
284
398
  deleted++;
285
399
  if (options.verbose) console.log(`\n Deleted: ${displayName}`);
286
400
  }
@@ -307,14 +421,20 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
307
421
 
308
422
  // Extract GUIDs from userDefined fields
309
423
  const guids = extractGuids(person);
424
+ const hasPhoto = hasNonDefaultPhoto(person);
425
+ const starred = isStarred(person);
426
+
427
+ // Preserve _delete flag from existing entry
428
+ const existingDelete = index.contacts[person.resourceName]?._delete;
310
429
 
311
430
  index.contacts[person.resourceName] = {
312
431
  resourceName: person.resourceName,
313
432
  displayName,
314
- etag: person.etag,
315
- deleted: false,
316
433
  updatedAt: new Date().toISOString(),
317
- guids: guids.length > 0 ? guids : undefined
434
+ ...(hasPhoto && { hasPhoto }),
435
+ ...(starred && { starred }),
436
+ ...(guids.length > 0 && { guids }),
437
+ ...(existingDelete && { _delete: existingDelete })
318
438
  };
319
439
 
320
440
  if (existed) {
@@ -340,6 +460,7 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
340
460
  index.lastSync = new Date().toISOString();
341
461
  saveIndex(paths, index);
342
462
  saveDeleted(paths, deletedIndex);
463
+ savePhotos(paths, photosIndex);
343
464
 
344
465
  // Small delay between pages to be nice to the API
345
466
  if (pageToken) {
@@ -383,23 +504,27 @@ interface PendingChangesResult {
383
504
  parseErrors: string[];
384
505
  }
385
506
 
386
- async function findPendingChanges(paths: UserPaths, logger: FileLogger): Promise<PendingChangesResult> {
507
+ async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?: string): Promise<PendingChangesResult> {
387
508
  const changes: PendingChange[] = [];
388
509
  const parseErrors: string[] = [];
389
510
  const index = loadIndex(paths);
390
- const lastSync = index.lastSync ? new Date(index.lastSync).getTime() : 0;
511
+ // Use -since override if provided, otherwise use lastSync
512
+ const sinceTime = since ? new Date(since).getTime() : (index.lastSync ? new Date(index.lastSync).getTime() : 0);
513
+ if (since) {
514
+ console.log(`Using -since override: ${since}`);
515
+ }
391
516
 
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
- }
517
+ // Check _delete.json for deletion requests
518
+ const deleteQueue = loadDeleteQueue(paths);
519
+ for (const entry of deleteQueue.entries) {
520
+ const filePath = path.join(paths.contactsDir, `${entry.resourceName.replace('people/', '')}.json`);
521
+ changes.push({
522
+ type: 'delete',
523
+ resourceName: entry.resourceName,
524
+ displayName: entry.displayName,
525
+ filePath,
526
+ _delete: entry._delete || 'queued'
527
+ });
403
528
  }
404
529
 
405
530
  // Check contacts/ for modified files and _delete markers
@@ -413,8 +538,8 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger): Promise
413
538
  const stat = await fp.stat(filePath);
414
539
  const modTime = stat.mtimeMs;
415
540
 
416
- // Skip files not modified since last sync
417
- if (modTime <= lastSync + 1000) continue;
541
+ // Skip files not modified since last sync (or -since override)
542
+ if (modTime <= sinceTime + 1000) continue;
418
543
 
419
544
  checked++;
420
545
  let content: GooglePerson & { _delete?: boolean };
@@ -645,7 +770,7 @@ async function createContactOnGoogle(person: GooglePerson, retryCount = 0, token
645
770
  return await response.json();
646
771
  }
647
772
 
648
- async function pushContacts(user: string, options: { yes: boolean; verbose: boolean; limit: number }): Promise<void> {
773
+ async function pushContacts(user: string, options: { yes: boolean; verbose: boolean; limit: number; since: string }): Promise<void> {
649
774
  const paths = getUserPaths(user);
650
775
  ensureUserDir(user);
651
776
 
@@ -654,7 +779,7 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
654
779
  const logger = new FileLogger(problemsFile, true);
655
780
 
656
781
  console.log(`Pushing contacts for user: ${user}`);
657
- let { changes, parseErrors } = await findPendingChanges(paths, logger);
782
+ let { changes, parseErrors } = await findPendingChanges(paths, logger, options.since || undefined);
658
783
 
659
784
  if (parseErrors.length > 0) {
660
785
  console.log(`\n${parseErrors.length} files had parse errors (see pushproblems.txt)`);
@@ -665,9 +790,28 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
665
790
  return;
666
791
  }
667
792
 
668
- const adds = changes.filter(c => c.type === 'add');
669
- const updates = changes.filter(c => c.type === 'update');
670
- const deletes = changes.filter(c => c.type === 'delete');
793
+ const adds = changes.filter(c => c.type === 'add').sort((a, b) => a.displayName.localeCompare(b.displayName));
794
+ const updates = changes.filter(c => c.type === 'update').sort((a, b) => a.displayName.localeCompare(b.displayName));
795
+ const deletes = changes.filter(c => c.type === 'delete').sort((a, b) => a.displayName.localeCompare(b.displayName));
796
+
797
+ // Write to toPush.txt for debugging
798
+ const toPushLines: string[] = [];
799
+ if (adds.length > 0) {
800
+ toPushLines.push(`New contacts (${adds.length}):`);
801
+ for (const c of adds) toPushLines.push(` ${c.displayName}`);
802
+ toPushLines.push('');
803
+ }
804
+ if (updates.length > 0) {
805
+ toPushLines.push(`Updates (${updates.length}):`);
806
+ for (const c of updates) toPushLines.push(` ${c.displayName} (${c.resourceName})`);
807
+ toPushLines.push('');
808
+ }
809
+ if (deletes.length > 0) {
810
+ toPushLines.push(`Deletions (${deletes.length}):`);
811
+ for (const c of deletes) toPushLines.push(` ${c.displayName} (${c.resourceName})`);
812
+ }
813
+ const toPushFile = path.join(paths.userDir, 'toPush.txt');
814
+ fs.writeFileSync(toPushFile, toPushLines.join('\n'));
671
815
 
672
816
  console.log('\nPending changes:');
673
817
  if (adds.length > 0) {
@@ -688,6 +832,10 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
688
832
  console.log(` - ${c.displayName} (${c.resourceName})`);
689
833
  }
690
834
  }
835
+ console.log(`\n(List written to ${toPushFile})`);
836
+
837
+ // Reorder changes to process in sorted order
838
+ changes = [...adds, ...updates, ...deletes];
691
839
 
692
840
  if (!options.yes) {
693
841
  const confirmed = await confirm(`\nPush ${changes.length} change(s) to Google?`);
@@ -703,6 +851,8 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
703
851
 
704
852
  const index = loadIndex(paths);
705
853
  const deletedIndex = loadDeleted(paths);
854
+ const deleteQueue = loadDeleteQueue(paths);
855
+ const completedDeletions = new Set<string>(); // Track successful deletions
706
856
  // Apply limit if specified
707
857
  if (options.limit > 0 && changes.length > options.limit) {
708
858
  console.log(`[DEBUG] Limiting to first ${options.limit} of ${changes.length} changes\n`);
@@ -711,9 +861,12 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
711
861
 
712
862
  let successCount = 0;
713
863
  let errorCount = 0;
864
+ let skippedPhoto = 0;
865
+ let skippedStarred = 0;
714
866
  let processed = 0;
715
867
  const total = changes.length;
716
868
  const problems: string[] = [];
869
+ const notDeleted: { resourceName: string; displayName: string; filePath: string; reason: string }[] = [];
717
870
 
718
871
  for (const change of changes) {
719
872
  if (escapePressed) {
@@ -728,17 +881,19 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
728
881
  process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Creating ${change.displayName}...`);
729
882
  const created = await createContactOnGoogle(content);
730
883
 
731
- // Extract GUIDs from userDefined
884
+ // Extract GUIDs and flags from created contact
732
885
  const guids = extractGuids(created);
886
+ const hasPhoto = hasNonDefaultPhoto(created);
887
+ const starred = isStarred(created);
733
888
 
734
889
  // Add to index
735
890
  index.contacts[created.resourceName] = {
736
891
  resourceName: created.resourceName,
737
892
  displayName: change.displayName,
738
- etag: created.etag,
739
- deleted: false,
740
893
  updatedAt: new Date().toISOString(),
741
- guids: guids.length > 0 ? guids : undefined
894
+ ...(hasPhoto && { hasPhoto }),
895
+ ...(starred && { starred }),
896
+ ...(guids.length > 0 && { guids })
742
897
  };
743
898
 
744
899
  // Save new contact to contacts/ folder
@@ -766,32 +921,85 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
766
921
  console.log(' done');
767
922
  successCount++;
768
923
  } 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
924
  const contactFile = path.join(paths.contactsDir, `${change.resourceName.replace('people/', '')}.json`);
784
- if (fs.existsSync(contactFile)) {
785
- fs.unlinkSync(contactFile);
925
+ const fileToRead = fs.existsSync(contactFile) ? contactFile : change.filePath;
926
+
927
+ // Check if contact has a real photo or is starred - skip deletion if so
928
+ let contactHasPhoto = false;
929
+ let contactIsStarred = false;
930
+ if (fs.existsSync(fileToRead)) {
931
+ try {
932
+ const contact = JSON.parse(fs.readFileSync(fileToRead, 'utf-8')) as GooglePerson;
933
+ contactHasPhoto = hasNonDefaultPhoto(contact);
934
+ contactIsStarred = isStarred(contact);
935
+ } catch { /* ignore read errors */ }
786
936
  }
787
937
 
788
- // Remove from _delete/ folder if it was there
789
- if (change.filePath.startsWith(paths.toDeleteDir) && fs.existsSync(change.filePath)) {
790
- fs.unlinkSync(change.filePath);
791
- }
938
+ // Determine skip reason (photo takes precedence over starred)
939
+ // Use * prefix to indicate "skipped, not deleted"
940
+ // 'force' overrides photo/starred protection
941
+ const isForced = change._delete === 'force' || index.contacts[change.resourceName]?._delete === 'force';
942
+ const skipReason = isForced ? null : (contactHasPhoto ? '*photo' : (contactIsStarred ? '*starred' : null));
943
+
944
+ if (skipReason) {
945
+ // Skip Google deletion - mark why in index.json
946
+ notDeleted.push({
947
+ resourceName: change.resourceName,
948
+ displayName: change.displayName,
949
+ filePath: fileToRead,
950
+ reason: skipReason
951
+ });
952
+ if (skipReason === '*photo') skippedPhoto++;
953
+ else if (skipReason === '*starred') skippedStarred++;
954
+ console.log(`${ts()} [${String(processed).padStart(5)}/${total}] SKIPPED ${change.displayName} (${skipReason})`);
955
+
956
+ // Update _delete to show skip reason
957
+ if (index.contacts[change.resourceName]) {
958
+ index.contacts[change.resourceName]._delete = skipReason;
959
+ }
792
960
 
793
- console.log(' done');
794
- successCount++;
961
+ // Remove from _delete/ folder if it was there (keep in contacts/)
962
+ if (change.filePath !== fileToRead && change.filePath.startsWith(paths.toDeleteDir) && fs.existsSync(change.filePath)) {
963
+ fs.unlinkSync(change.filePath);
964
+ }
965
+
966
+ completedDeletions.add(change.resourceName); // Remove from queue even if skipped
967
+ } else {
968
+ process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Deleting ${change.displayName}...`);
969
+ await deleteContactOnGoogle(change.resourceName);
970
+
971
+ // Move to deleted.json (preserve _delete reason)
972
+ const entry = index.contacts[change.resourceName];
973
+ if (entry) {
974
+ const deletedEntry = { ...entry, deletedAt: new Date().toISOString() };
975
+ // Keep _delete reason in deleted entry (it's the reason for deletion)
976
+ deletedIndex.deleted[change.resourceName] = deletedEntry;
977
+ delete index.contacts[change.resourceName];
978
+ }
979
+
980
+ // Move contact file to deleted/ folder (backup with timestamp)
981
+ if (!fs.existsSync(paths.deletedDir)) {
982
+ fs.mkdirSync(paths.deletedDir, { recursive: true });
983
+ }
984
+ if (fs.existsSync(contactFile)) {
985
+ // Add deletion metadata to the JSON file
986
+ const contactData = JSON.parse(fs.readFileSync(contactFile, 'utf-8'));
987
+ contactData._deletedAt = new Date().toISOString();
988
+ contactData._delete = change._delete || entry?._delete || 'unknown';
989
+ const destPath = path.join(paths.deletedDir, path.basename(contactFile));
990
+ fs.writeFileSync(destPath, JSON.stringify(contactData, null, 2));
991
+ fs.unlinkSync(contactFile);
992
+ }
993
+
994
+ // Remove from _delete/ folder if it was there
995
+ if (change.filePath.startsWith(paths.toDeleteDir) && fs.existsSync(change.filePath)) {
996
+ fs.unlinkSync(change.filePath);
997
+ }
998
+
999
+ completedDeletions.add(change.resourceName);
1000
+ console.log(' done');
1001
+ successCount++;
1002
+ }
795
1003
  }
796
1004
 
797
1005
  await sleep(700); // 90 writes/min limit = ~700ms between ops
@@ -811,12 +1019,27 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
811
1019
  console.log(`\nErrors written to: ${problemsFile}`);
812
1020
  }
813
1021
 
1022
+ // Write notdeleted file if any contacts were skipped due to photos
1023
+ if (notDeleted.length > 0) {
1024
+ const notDeletedFile = path.join(paths.userDir, 'notdeleted.json');
1025
+ fs.writeFileSync(notDeletedFile, JSON.stringify(notDeleted, null, 2));
1026
+ console.log(`\n${notDeleted.length} contacts with photos not deleted from Google (see notdeleted.json)`);
1027
+ }
1028
+
1029
+ // Remove completed deletions from queue
1030
+ if (completedDeletions.size > 0) {
1031
+ deleteQueue.entries = deleteQueue.entries.filter(e => !completedDeletions.has(e.resourceName));
1032
+ saveDeleteQueue(paths, deleteQueue);
1033
+ }
1034
+
814
1035
  // Save status with new timestamp
815
1036
  saveStatus(paths, { lastPush: new Date().toISOString() });
816
1037
  saveIndex(paths, index);
817
1038
  saveDeleted(paths, deletedIndex);
818
1039
 
819
- console.log(`\nPush complete: ${successCount} succeeded, ${errorCount} failed`);
1040
+ const skipped = skippedPhoto + skippedStarred;
1041
+ const skippedDetails = skipped > 0 ? ` (${skippedPhoto} photo, ${skippedStarred} starred)` : '';
1042
+ console.log(`\nPush complete: ${successCount} succeeded, ${errorCount} failed, ${skipped} skipped${skippedDetails}`);
820
1043
  }
821
1044
 
822
1045
  async function main(): Promise<void> {
@@ -861,7 +1084,7 @@ async function main(): Promise<void> {
861
1084
  await syncContacts(user, { full: options.full, verbose: options.verbose });
862
1085
  break;
863
1086
  case 'push':
864
- await pushContacts(user, { yes: options.yes, verbose: options.verbose, limit: options.limit });
1087
+ await pushContacts(user, { yes: options.yes, verbose: options.verbose, limit: options.limit, since: options.since });
865
1088
  break;
866
1089
  default:
867
1090
  console.error(`Unknown command: ${options.command}`);
package/gfix.ts CHANGED
@@ -10,10 +10,24 @@
10
10
 
11
11
  import fs from 'fs';
12
12
  import path from 'path';
13
+ import readline from 'readline';
13
14
  import type { GooglePerson, GoogleName, GooglePhoneNumber, GoogleEmailAddress, GoogleBirthday } from './glib/types.ts';
14
- import { DATA_DIR, resolveUser, getUserPaths } from './glib/gutils.ts';
15
+ import type { DeleteQueue, DeleteQueueEntry, UserPaths } from './glib/gctypes.ts';
16
+ import { DATA_DIR, resolveUser, getUserPaths, loadIndex, getAllUsers, normalizeUser } from './glib/gutils.ts';
15
17
  import { mergeContacts as mergeContactData, collectSourcePhotos, type MergeEntry, type PhotoEntry } from './glib/gmerge.ts';
16
18
 
19
+ function loadDeleteQueue(paths: UserPaths): DeleteQueue {
20
+ if (fs.existsSync(paths.deleteQueueFile)) {
21
+ return JSON.parse(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
22
+ }
23
+ return { updatedAt: '', entries: [] };
24
+ }
25
+
26
+ function saveDeleteQueue(paths: UserPaths, queue: DeleteQueue): void {
27
+ queue.updatedAt = new Date().toISOString();
28
+ fs.writeFileSync(paths.deleteQueueFile, JSON.stringify(queue, null, 2));
29
+ }
30
+
17
31
  interface BirthdayEntry {
18
32
  name: string;
19
33
  date: string;
@@ -1243,15 +1257,23 @@ async function runMerge(user: string, limit: number): Promise<void> {
1243
1257
  }
1244
1258
  }
1245
1259
 
1246
- // Load existing photos.json and merged.json
1260
+ // Load existing files
1247
1261
  let savedPhotos: PhotoEntry[] = [];
1248
1262
  if (fs.existsSync(photosPath)) {
1249
1263
  savedPhotos = JSON.parse(fs.readFileSync(photosPath, 'utf-8'));
1250
1264
  }
1251
1265
 
1266
+ const deleteQueue = loadDeleteQueue(paths);
1267
+ const index = loadIndex(paths);
1268
+
1252
1269
  let mergedEntries: MergeEntry[] = [];
1253
1270
  if (fs.existsSync(mergedPath)) {
1254
- mergedEntries = JSON.parse(fs.readFileSync(mergedPath, 'utf-8'));
1271
+ const content = fs.readFileSync(mergedPath, 'utf-8').trim();
1272
+ if (content) {
1273
+ try {
1274
+ mergedEntries = JSON.parse(content);
1275
+ } catch { /* ignore parse errors, start fresh */ }
1276
+ }
1255
1277
  }
1256
1278
 
1257
1279
  let mergeSuccess = 0;
@@ -1304,12 +1326,20 @@ async function runMerge(user: string, limit: number): Promise<void> {
1304
1326
  fs.writeFileSync(targetPath, JSON.stringify(updatedTarget, null, 2));
1305
1327
  console.log(` Updated ${targetPath}`);
1306
1328
 
1307
- // Mark sources for deletion
1329
+ // Add sources to delete queue and set _delete reason in index
1308
1330
  for (const source of sources) {
1309
1331
  const sourceId = source.resourceName.replace('people/', '');
1310
- const sourcePath = path.join(paths.contactsDir, `${sourceId}.json`);
1311
- const markedSource = { _delete: true, ...source };
1312
- fs.writeFileSync(sourcePath, JSON.stringify(markedSource, null, 2));
1332
+ const displayName = source.names?.[0]?.displayName || 'Unknown';
1333
+ deleteQueue.entries.push({
1334
+ resourceName: source.resourceName,
1335
+ displayName,
1336
+ addedAt: new Date().toISOString(),
1337
+ _delete: 'duplicate'
1338
+ });
1339
+ // Set _delete reason in index.json
1340
+ if (index.contacts[source.resourceName]) {
1341
+ index.contacts[source.resourceName]._delete = 'duplicate';
1342
+ }
1313
1343
  console.log(` Marked ${sourceId} for deletion`);
1314
1344
  }
1315
1345
 
@@ -1321,7 +1351,7 @@ async function runMerge(user: string, limit: number): Promise<void> {
1321
1351
  }
1322
1352
  }
1323
1353
 
1324
- // Process deletes - just mark files
1354
+ // Process deletes - add to delete queue and set _delete reason
1325
1355
  for (const entry of toDelete) {
1326
1356
  console.log(`\nMarking for deletion: ${entry.name}...`);
1327
1357
 
@@ -1329,9 +1359,18 @@ async function runMerge(user: string, limit: number): Promise<void> {
1329
1359
  const id = rn.replace('people/', '');
1330
1360
  const filePath = path.join(paths.contactsDir, `${id}.json`);
1331
1361
  if (fs.existsSync(filePath)) {
1332
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
1333
- const markedContact = { _delete: true, ...contact };
1334
- fs.writeFileSync(filePath, JSON.stringify(markedContact, null, 2));
1362
+ const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1363
+ const displayName = contact.names?.[0]?.displayName || entry.name;
1364
+ deleteQueue.entries.push({
1365
+ resourceName: rn,
1366
+ displayName,
1367
+ addedAt: new Date().toISOString(),
1368
+ _delete: 'duplicate'
1369
+ });
1370
+ // Set _delete reason in index.json
1371
+ if (index.contacts[rn]) {
1372
+ index.contacts[rn]._delete = 'duplicate';
1373
+ }
1335
1374
  console.log(` Marked ${id} for deletion`);
1336
1375
  deleteSuccess++;
1337
1376
  }
@@ -1347,6 +1386,12 @@ async function runMerge(user: string, limit: number): Promise<void> {
1347
1386
  // Save merged.json (history of processed entries)
1348
1387
  fs.writeFileSync(mergedPath, JSON.stringify(mergedEntries, null, 2));
1349
1388
 
1389
+ // Save delete queue
1390
+ saveDeleteQueue(paths, deleteQueue);
1391
+
1392
+ // Save index.json with _delete reasons
1393
+ fs.writeFileSync(paths.indexFile, JSON.stringify(index, null, 2));
1394
+
1350
1395
  // Remove processed entries from merger.json
1351
1396
  const remainingEntries = JSON.parse(fs.readFileSync(mergerPath, 'utf-8')) as MergeEntry[];
1352
1397
  const processedNames = new Set(entries.map(e => e.name));
@@ -1361,6 +1406,387 @@ async function runMerge(user: string, limit: number): Promise<void> {
1361
1406
  console.log(`\nRun 'gcards push -u ${user}' to apply changes to Google.`);
1362
1407
  }
1363
1408
 
1409
+ // ============================================================
1410
+ // Export Feature - Transfer contacts between users
1411
+ // ============================================================
1412
+
1413
+ interface ContactMatch {
1414
+ id: string; /** JSON file id (without .json) */
1415
+ displayName: string;
1416
+ emails: string[];
1417
+ filePath: string;
1418
+ source: 'contacts' | 'deleted';
1419
+ }
1420
+
1421
+ /** Find a contact by ID in contacts or deleted directories */
1422
+ function findContactById(paths: UserPaths, id: string): { contact: GooglePerson; filePath: string; source: 'contacts' | 'deleted' } {
1423
+ const cleanId = id.replace(/\.json$/i, '');
1424
+
1425
+ // Check contacts directory
1426
+ const contactPath = path.join(paths.contactsDir, `${cleanId}.json`);
1427
+ if (fs.existsSync(contactPath)) {
1428
+ return {
1429
+ contact: JSON.parse(fs.readFileSync(contactPath, 'utf-8')),
1430
+ filePath: contactPath,
1431
+ source: 'contacts'
1432
+ };
1433
+ }
1434
+
1435
+ // Check deleted directory
1436
+ const deletedPath = path.join(paths.deletedDir, `${cleanId}.json`);
1437
+ if (fs.existsSync(deletedPath)) {
1438
+ return {
1439
+ contact: JSON.parse(fs.readFileSync(deletedPath, 'utf-8')),
1440
+ filePath: deletedPath,
1441
+ source: 'deleted'
1442
+ };
1443
+ }
1444
+
1445
+ return null;
1446
+ }
1447
+
1448
+ /** Match users by partial name - returns matching user names */
1449
+ function matchUsers(pattern: string): string[] {
1450
+ const allUsers = getAllUsers();
1451
+ const normalizedPattern = pattern.toLowerCase();
1452
+
1453
+ return allUsers.filter(user => user.toLowerCase().includes(normalizedPattern));
1454
+ }
1455
+
1456
+ /** Convert wildcard pattern to regex (supports * and ?) */
1457
+ function wildcardToRegex(pattern: string): RegExp {
1458
+ const escaped = pattern
1459
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars
1460
+ .replace(/\*/g, '.*') // * matches anything
1461
+ .replace(/\?/g, '.'); // ? matches single char
1462
+ return new RegExp(`^${escaped}$`, 'i');
1463
+ }
1464
+
1465
+ /** Search contacts by name or email pattern */
1466
+ function searchContacts(paths: UserPaths, pattern: string): ContactMatch[] {
1467
+ const regex = wildcardToRegex(pattern);
1468
+ const matches: ContactMatch[] = [];
1469
+
1470
+ // Search in contacts directory
1471
+ if (fs.existsSync(paths.contactsDir)) {
1472
+ for (const file of fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'))) {
1473
+ const filePath = path.join(paths.contactsDir, file);
1474
+ const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1475
+ if (matchesContact(contact, regex)) {
1476
+ matches.push({
1477
+ id: file.replace(/\.json$/, ''),
1478
+ displayName: getContactDisplayName(contact),
1479
+ emails: (contact.emailAddresses || []).map(e => e.value).filter(Boolean) as string[],
1480
+ filePath,
1481
+ source: 'contacts'
1482
+ });
1483
+ }
1484
+ }
1485
+ }
1486
+
1487
+ // Search in deleted directory
1488
+ if (fs.existsSync(paths.deletedDir)) {
1489
+ for (const file of fs.readdirSync(paths.deletedDir).filter(f => f.endsWith('.json'))) {
1490
+ const filePath = path.join(paths.deletedDir, file);
1491
+ const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1492
+ if (matchesContact(contact, regex)) {
1493
+ matches.push({
1494
+ id: file.replace(/\.json$/, ''),
1495
+ displayName: getContactDisplayName(contact),
1496
+ emails: (contact.emailAddresses || []).map(e => e.value).filter(Boolean) as string[],
1497
+ filePath,
1498
+ source: 'deleted'
1499
+ });
1500
+ }
1501
+ }
1502
+ }
1503
+
1504
+ return matches;
1505
+ }
1506
+
1507
+ /** Check if contact matches regex on name or email */
1508
+ function matchesContact(contact: GooglePerson, regex: RegExp): boolean {
1509
+ // Check display name
1510
+ const name = contact.names?.[0];
1511
+ if (name) {
1512
+ if (regex.test(name.displayName || '')) return true;
1513
+ if (regex.test(name.givenName || '')) return true;
1514
+ if (regex.test(name.familyName || '')) return true;
1515
+ // "Last, First" format
1516
+ if (name.givenName && name.familyName) {
1517
+ if (regex.test(`${name.familyName}, ${name.givenName}`)) return true;
1518
+ if (regex.test(`${name.givenName} ${name.familyName}`)) return true;
1519
+ }
1520
+ if (regex.test(name.displayNameLastFirst || '')) return true;
1521
+ }
1522
+
1523
+ // Check emails
1524
+ for (const email of contact.emailAddresses || []) {
1525
+ if (regex.test(email.value || '')) return true;
1526
+ }
1527
+
1528
+ return false;
1529
+ }
1530
+
1531
+ /** Get display name for a contact */
1532
+ function getContactDisplayName(contact: GooglePerson): string {
1533
+ const name = contact.names?.[0];
1534
+ return name?.displayName || name?.givenName || name?.familyName || 'Unknown';
1535
+ }
1536
+
1537
+ /** Clean contact for export - remove Google-specific metadata */
1538
+ function cleanContactForExport(contact: GooglePerson): GooglePerson {
1539
+ const cleaned = { ...contact };
1540
+
1541
+ // Remove resource-specific fields that will be regenerated
1542
+ delete cleaned.resourceName;
1543
+ delete cleaned.etag;
1544
+ delete (cleaned as any)._delete;
1545
+ delete (cleaned as any)._deleted;
1546
+
1547
+ // Remove metadata that links to source account
1548
+ if (cleaned.metadata) {
1549
+ delete cleaned.metadata.sources;
1550
+ delete cleaned.metadata.previousResourceNames;
1551
+ delete cleaned.metadata.linkedPeopleResourceNames;
1552
+ }
1553
+
1554
+ // Clean field metadata (source references)
1555
+ const cleanFieldMetadata = (items: any[]) => {
1556
+ if (!items) return;
1557
+ for (const item of items) {
1558
+ if (item.metadata) {
1559
+ delete item.metadata.source;
1560
+ }
1561
+ }
1562
+ };
1563
+
1564
+ cleanFieldMetadata(cleaned.names);
1565
+ cleanFieldMetadata(cleaned.emailAddresses);
1566
+ cleanFieldMetadata(cleaned.phoneNumbers);
1567
+ cleanFieldMetadata(cleaned.addresses);
1568
+ cleanFieldMetadata(cleaned.organizations);
1569
+ cleanFieldMetadata(cleaned.urls);
1570
+ cleanFieldMetadata(cleaned.biographies);
1571
+ cleanFieldMetadata(cleaned.photos);
1572
+ cleanFieldMetadata(cleaned.birthdays);
1573
+ cleanFieldMetadata(cleaned.relations);
1574
+ cleanFieldMetadata(cleaned.events);
1575
+ cleanFieldMetadata(cleaned.memberships);
1576
+ cleanFieldMetadata(cleaned.userDefined);
1577
+
1578
+ return cleaned;
1579
+ }
1580
+
1581
+ /** Prompt user for selection when multiple matches */
1582
+ async function promptSelection(matches: ContactMatch[]): Promise<string[]> {
1583
+ console.log('\nMultiple contacts found:');
1584
+ for (let i = 0; i < matches.length; i++) {
1585
+ const m = matches[i];
1586
+ const emailStr = m.emails.length > 0 ? ` <${m.emails[0]}>` : '';
1587
+ const srcStr = m.source === 'deleted' ? ' [deleted]' : '';
1588
+ console.log(` ${i + 1}. ${m.displayName}${emailStr}${srcStr} (${m.id})`);
1589
+ }
1590
+ console.log('\nEnter selection: number(s) separated by commas, * for all, or q to quit');
1591
+
1592
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1593
+
1594
+ return new Promise((resolve) => {
1595
+ rl.question('> ', (answer) => {
1596
+ rl.close();
1597
+ const trimmed = answer.trim().toLowerCase();
1598
+
1599
+ if (trimmed === 'q' || trimmed === '') {
1600
+ resolve([]);
1601
+ return;
1602
+ }
1603
+
1604
+ if (trimmed === '*') {
1605
+ resolve(matches.map(m => m.id));
1606
+ return;
1607
+ }
1608
+
1609
+ // Parse comma-separated numbers
1610
+ const selected: string[] = [];
1611
+ for (const part of trimmed.split(',')) {
1612
+ const num = parseInt(part.trim(), 10);
1613
+ if (num >= 1 && num <= matches.length) {
1614
+ selected.push(matches[num - 1].id);
1615
+ }
1616
+ }
1617
+ resolve(selected);
1618
+ });
1619
+ });
1620
+ }
1621
+
1622
+ /** Export contacts from source user to target user */
1623
+ async function runExport(sourceUser: string, targetPattern: string, contactPattern: string): Promise<void> {
1624
+ // Resolve target user by partial match
1625
+ const targetMatches = matchUsers(targetPattern);
1626
+
1627
+ if (targetMatches.length === 0) {
1628
+ console.error(`No users match pattern: ${targetPattern}`);
1629
+ console.error(`Available users: ${getAllUsers().join(', ')}`);
1630
+ process.exit(1);
1631
+ }
1632
+
1633
+ if (targetMatches.length > 1) {
1634
+ console.error(`Multiple users match pattern "${targetPattern}":`);
1635
+ for (const u of targetMatches) {
1636
+ console.error(` - ${u}`);
1637
+ }
1638
+ console.error('Please be more specific.');
1639
+ process.exit(1);
1640
+ }
1641
+
1642
+ const targetUser = targetMatches[0];
1643
+
1644
+ if (normalizeUser(sourceUser) === normalizeUser(targetUser)) {
1645
+ console.error('Source and target users cannot be the same.');
1646
+ process.exit(1);
1647
+ }
1648
+
1649
+ const sourcePaths = getUserPaths(sourceUser);
1650
+ const targetPaths = getUserPaths(targetUser);
1651
+
1652
+ // Ensure target _add directory exists
1653
+ if (!fs.existsSync(targetPaths.toAddDir)) {
1654
+ fs.mkdirSync(targetPaths.toAddDir, { recursive: true });
1655
+ }
1656
+
1657
+ // Find contacts to export
1658
+ let contactIds: string[] = [];
1659
+
1660
+ // Check if contactPattern looks like an ID (alphanumeric, possibly with .json)
1661
+ const cleanPattern = contactPattern.replace(/\.json$/i, '');
1662
+ const found = findContactById(sourcePaths, cleanPattern);
1663
+
1664
+ if (found) {
1665
+ // Direct ID match
1666
+ contactIds = [cleanPattern];
1667
+ } else {
1668
+ // Search by name/email pattern
1669
+ const matches = searchContacts(sourcePaths, contactPattern);
1670
+
1671
+ if (matches.length === 0) {
1672
+ console.error(`No contacts match pattern: ${contactPattern}`);
1673
+ process.exit(1);
1674
+ }
1675
+
1676
+ if (matches.length === 1) {
1677
+ contactIds = [matches[0].id];
1678
+ } else {
1679
+ contactIds = await promptSelection(matches);
1680
+ if (contactIds.length === 0) {
1681
+ console.log('No contacts selected.');
1682
+ return;
1683
+ }
1684
+ }
1685
+ }
1686
+
1687
+ // Export each selected contact
1688
+ let exported = 0;
1689
+ for (const id of contactIds) {
1690
+ const result = findContactById(sourcePaths, id);
1691
+ if (!result) {
1692
+ console.error(`Contact not found: ${id}`);
1693
+ continue;
1694
+ }
1695
+
1696
+ const { contact, source } = result;
1697
+ const displayName = getContactDisplayName(contact);
1698
+
1699
+ // Clean the contact for export
1700
+ const cleaned = cleanContactForExport(contact);
1701
+
1702
+ // Generate unique filename: x<sourceId>.json
1703
+ const exportFileName = `x${id}.json`;
1704
+ const exportPath = path.join(targetPaths.toAddDir, exportFileName);
1705
+
1706
+ // Check for existing file
1707
+ if (fs.existsSync(exportPath)) {
1708
+ console.log(` [SKIP] ${displayName} - already exists: ${exportFileName}`);
1709
+ continue;
1710
+ }
1711
+
1712
+ fs.writeFileSync(exportPath, JSON.stringify(cleaned, null, 2));
1713
+ console.log(` ${displayName} -> ${targetUser}/_add/${exportFileName}${source === 'deleted' ? ' [from deleted]' : ''}`);
1714
+ exported++;
1715
+ }
1716
+
1717
+ console.log(`\n${'='.repeat(50)}`);
1718
+ console.log(`Exported: ${exported} contact(s) to ${targetUser}/_add/`);
1719
+ if (exported > 0) {
1720
+ console.log(`\nTo upload, run: gcards push -u ${targetUser}`);
1721
+ }
1722
+ }
1723
+
1724
+ /** Reset ALL delete markers: index.json, contact files, _delete.json, _delete/ folder */
1725
+ async function runReset(user: string): Promise<void> {
1726
+ const paths = getUserPaths(user);
1727
+
1728
+ let indexCleared = 0;
1729
+ let filesCleared = 0;
1730
+ let queueCleared = 0;
1731
+ let folderMoved = 0;
1732
+
1733
+ // Clear from index.json
1734
+ if (fs.existsSync(paths.indexFile)) {
1735
+ const index = JSON.parse(fs.readFileSync(paths.indexFile, 'utf-8'));
1736
+ for (const entry of Object.values(index.contacts) as any[]) {
1737
+ if (entry._delete) {
1738
+ delete entry._delete;
1739
+ indexCleared++;
1740
+ }
1741
+ }
1742
+ fs.writeFileSync(paths.indexFile, JSON.stringify(index, null, 2));
1743
+ }
1744
+
1745
+ // Clear from contact JSON files (preserve mtime to avoid false "modified" detection)
1746
+ if (fs.existsSync(paths.contactsDir)) {
1747
+ const files = fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'));
1748
+ for (const file of files) {
1749
+ const filePath = path.join(paths.contactsDir, file);
1750
+ const stat = fs.statSync(filePath);
1751
+ const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
1752
+ if (content._delete || content._deleted) {
1753
+ delete content._delete;
1754
+ delete content._deleted;
1755
+ fs.writeFileSync(filePath, JSON.stringify(content, null, 2));
1756
+ // Restore original mtime so file doesn't appear modified
1757
+ fs.utimesSync(filePath, stat.atime, stat.mtime);
1758
+ filesCleared++;
1759
+ }
1760
+ }
1761
+ }
1762
+
1763
+ // Clear _delete.json queue
1764
+ if (fs.existsSync(paths.deleteQueueFile)) {
1765
+ const queue = JSON.parse(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
1766
+ queueCleared = queue.entries?.length || 0;
1767
+ fs.unlinkSync(paths.deleteQueueFile);
1768
+ }
1769
+
1770
+ // Move files from _delete/ folder back to contacts/
1771
+ if (fs.existsSync(paths.toDeleteDir)) {
1772
+ const files = fs.readdirSync(paths.toDeleteDir).filter(f => f.endsWith('.json'));
1773
+ for (const file of files) {
1774
+ const src = path.join(paths.toDeleteDir, file);
1775
+ const dest = path.join(paths.contactsDir, file);
1776
+ fs.renameSync(src, dest);
1777
+ folderMoved++;
1778
+ }
1779
+ }
1780
+
1781
+ console.log(`Reset complete for user: ${user}`);
1782
+ console.log(` Index entries: ${indexCleared}`);
1783
+ console.log(` Contact files: ${filesCleared}`);
1784
+ console.log(` Queue entries: ${queueCleared}`);
1785
+ if (folderMoved > 0) {
1786
+ console.log(` Moved from _delete/: ${folderMoved}`);
1787
+ }
1788
+ }
1789
+
1364
1790
  function showUsage(): void {
1365
1791
  console.log(`
1366
1792
  gfix - One-time fix routines for gcards contacts
@@ -1375,9 +1801,12 @@ Commands:
1375
1801
  names Parse givenName into first/middle/last, clean fileAs, remove dup phones/emails
1376
1802
  undup Find duplicate contacts (same name + overlapping email) -> merger.json
1377
1803
  merge Merge duplicates locally (then use gcards push)
1804
+ reset Clear all _delete flags from index.json
1805
+ export Transfer contact(s) to another user's _add directory
1378
1806
 
1379
1807
  Options:
1380
- -u, --user <name> User profile to process
1808
+ -u, --user <name> User profile to process (source for export)
1809
+ -to <user> Target user for export (partial match OK)
1381
1810
  --apply For birthday/fileas/names: actually apply changes
1382
1811
  -limit <n> Process only first n contacts (for testing)
1383
1812
 
@@ -1407,6 +1836,16 @@ Duplicate detection and merge:
1407
1836
  - gcards push -u bob # Push changes to Google
1408
1837
  - gcards sync -u bob --full # Resync to get updated contacts
1409
1838
 
1839
+ Export contacts to another user:
1840
+ - gfix export -u bob -to alice "John*" # Export contacts matching "John*" to alice
1841
+ - gfix export -u bob -to ali "*@example.com" # Export by email pattern (ali matches alice)
1842
+ - gfix export -u bob -to alice c1234567890 # Export by contact ID (with or without .json)
1843
+ - Pattern supports * (any chars) and ? (single char) wildcards
1844
+ - Matches on: displayName, givenName, familyName, "Last, First", email
1845
+ - Searches both contacts/ and deleted/ directories
1846
+ - Multiple matches prompt for selection: number(s), * for all, q to quit
1847
+ - Creates cleaned file in target's _add/ directory as x<sourceId>.json
1848
+
1410
1849
  Workflow:
1411
1850
  1. gfix inspect -u bob # Review proposed changes
1412
1851
  2. gfix apply -u bob # Apply changes to local files
@@ -1422,18 +1861,26 @@ async function main(): Promise<void> {
1422
1861
 
1423
1862
  let command = '';
1424
1863
  let user = '';
1864
+ let targetUser = '';
1425
1865
  let applyFlag = false;
1866
+ const positionalArgs: string[] = [];
1426
1867
 
1427
1868
  for (let i = 0; i < args.length; i++) {
1428
1869
  const arg = args[i];
1429
1870
  if ((arg === '-u' || arg === '--user' || arg === '-user') && i + 1 < args.length) {
1430
1871
  user = args[++i];
1872
+ } else if ((arg === '-to' || arg === '--to') && i + 1 < args.length) {
1873
+ targetUser = args[++i];
1431
1874
  } else if (arg === '--apply') {
1432
1875
  applyFlag = true;
1433
1876
  } else if ((arg === '-limit' || arg === '--limit') && i + 1 < args.length) {
1434
1877
  processLimit = parseInt(args[++i], 10) || 0;
1435
- } else if (!arg.startsWith('-') && !command) {
1436
- command = arg;
1878
+ } else if (!arg.startsWith('-')) {
1879
+ if (!command) {
1880
+ command = arg;
1881
+ } else {
1882
+ positionalArgs.push(arg);
1883
+ }
1437
1884
  }
1438
1885
  }
1439
1886
 
@@ -1442,6 +1889,26 @@ async function main(): Promise<void> {
1442
1889
  return;
1443
1890
  }
1444
1891
 
1892
+ // Export command has different argument handling
1893
+ if (command === 'export') {
1894
+ if (!user) {
1895
+ console.error('Export requires -u <sourceUser>');
1896
+ process.exit(1);
1897
+ }
1898
+ if (!targetUser) {
1899
+ console.error('Export requires -to <targetUser>');
1900
+ process.exit(1);
1901
+ }
1902
+ if (positionalArgs.length === 0) {
1903
+ console.error('Export requires a contact pattern or ID');
1904
+ console.error('Usage: gfix export -u <source> -to <target> <pattern>');
1905
+ process.exit(1);
1906
+ }
1907
+ const resolvedSource = resolveUser(user);
1908
+ await runExport(resolvedSource, targetUser, positionalArgs[0]);
1909
+ return;
1910
+ }
1911
+
1445
1912
  const resolvedUser = resolveUser(user);
1446
1913
 
1447
1914
  if (processLimit > 0) {
@@ -1460,6 +1927,8 @@ async function main(): Promise<void> {
1460
1927
  await runUndup(resolvedUser);
1461
1928
  } else if (command === 'merge') {
1462
1929
  await runMerge(resolvedUser, processLimit);
1930
+ } else if (command === 'reset') {
1931
+ await runReset(resolvedUser);
1463
1932
  } else {
1464
1933
  console.error(`Unknown command: ${command}`);
1465
1934
  showUsage();
package/glib/gctypes.ts CHANGED
@@ -28,14 +28,33 @@ export interface ContactIndex {
28
28
  export interface IndexEntry {
29
29
  resourceName: string;
30
30
  displayName: string;
31
- etag?: string;
32
- deleted: boolean; /** Google deleted this contact */
33
- deletedAt?: string;
34
31
  updatedAt: string;
35
- _delete?: boolean; /** Request to delete on next push */
32
+ hasPhoto?: boolean; /** Has non-default photo */
33
+ starred?: boolean; /** In starred/favorites group */
34
+ _delete?: string; /** Deletion reason. *prefix means skipped (e.g., *photo, *starred) */
36
35
  guids?: string[]; /** User-defined GUIDs for cross-account tracking */
37
36
  }
38
37
 
38
+ export interface DeletedEntry extends IndexEntry {
39
+ deletedAt: string;
40
+ }
41
+
42
+ // ============================================================
43
+ // Delete Queue (_delete.json)
44
+ // ============================================================
45
+
46
+ export interface DeleteQueueEntry {
47
+ resourceName: string;
48
+ displayName: string;
49
+ addedAt: string;
50
+ _delete: string; /** Reason for deletion (e.g., 'duplicate') */
51
+ }
52
+
53
+ export interface DeleteQueue {
54
+ updatedAt: string;
55
+ entries: DeleteQueueEntry[];
56
+ }
57
+
39
58
  // ============================================================
40
59
  // Push Status (status.json)
41
60
  // ============================================================
@@ -62,6 +81,7 @@ export interface PendingChange {
62
81
  resourceName: string;
63
82
  displayName: string;
64
83
  filePath: string;
84
+ _delete?: string; /** Deletion reason (for type='delete') */
65
85
  }
66
86
 
67
87
  // ============================================================
@@ -71,12 +91,14 @@ export interface PendingChange {
71
91
  export interface UserPaths {
72
92
  userDir: string;
73
93
  contactsDir: string; /** Active contacts synced from Google */
74
- deletedDir: string; /** Google-deleted tombstones */
75
- toDeleteDir: string; /** User requests to delete */
94
+ deletedDir: string; /** Backup of deleted contact files */
95
+ toDeleteDir: string; /** User requests to delete (move files here) */
76
96
  toAddDir: string; /** User requests to add new contacts */
77
97
  fixLogDir: string; /** Logs from gfix operations */
78
98
  indexFile: string;
79
99
  deletedFile: string; /** Deleted contacts index (deleted.json) */
100
+ deleteQueueFile: string; /** Pending deletions (_delete.json) */
101
+ photosFile: string; /** Photos from deleted contacts (photos.json) */
80
102
  statusFile: string;
81
103
  syncTokenFile: string;
82
104
  tokenFile: string;
package/glib/gutils.ts CHANGED
@@ -58,6 +58,8 @@ export function getUserPaths(user: string): UserPaths {
58
58
  fixLogDir: path.join(userDir, 'fix-logs'),
59
59
  indexFile: path.join(userDir, 'index.json'),
60
60
  deletedFile: path.join(userDir, 'deleted.json'),
61
+ deleteQueueFile: path.join(userDir, '_delete.json'),
62
+ photosFile: path.join(userDir, 'photos.json'),
61
63
  statusFile: path.join(userDir, 'status.json'),
62
64
  syncTokenFile: path.join(userDir, 'sync-token.json'),
63
65
  tokenFile: path.join(userDir, 'token.json'),
package/glib/parsecli.ts CHANGED
@@ -11,6 +11,7 @@ export interface CliOptions {
11
11
  help: boolean;
12
12
  verbose: boolean;
13
13
  limit: number; /** Process only first n entries (0 = no limit) */
14
+ since: string; /** Override lastSync - check files since this datetime */
14
15
  }
15
16
 
16
17
  export function parseArgs(args: string[]): CliOptions {
@@ -22,7 +23,8 @@ export function parseArgs(args: string[]): CliOptions {
22
23
  all: false,
23
24
  help: false,
24
25
  verbose: false,
25
- limit: 0
26
+ limit: 0,
27
+ since: ''
26
28
  };
27
29
 
28
30
  for (let i = 0; i < args.length; i++) {
@@ -41,6 +43,8 @@ export function parseArgs(args: string[]): CliOptions {
41
43
  options.user = args[++i];
42
44
  } else if ((arg === '--limit' || arg === '-limit') && i + 1 < args.length) {
43
45
  options.limit = parseInt(args[++i], 10) || 0;
46
+ } else if ((arg === '--since' || arg === '-since') && i + 1 < args.length) {
47
+ options.since = args[++i];
44
48
  } else if (!arg.startsWith('-') && !options.command) {
45
49
  options.command = arg;
46
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/gcards",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Google Contacts cleanup and management tool",
5
5
  "type": "module",
6
6
  "main": "gcards.ts",