@bobfrankston/gcards 0.1.4 → 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.
@@ -19,7 +19,9 @@
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:*)"
23
25
  ],
24
26
  "deny": [],
25
27
  "ask": []
package/README.md CHANGED
@@ -119,18 +119,19 @@ gcards/ # App directory (%APPDATA%\gcards or ~/.config/gcards)
119
119
  data/ # User data directory
120
120
  <username>/
121
121
  contacts/ # Active contacts (*.json)
122
+ deleted/ # Backup of deleted contact files
122
123
  _delete/ # User requests to delete (move files here)
123
124
  _add/ # User requests to add new contacts
124
125
  fix-logs/ # Logs from gfix operations
125
- index.json # Active contact index
126
- deleted.json # Deleted contacts index (tombstones)
126
+ index.json # Active contact index (hasPhoto, starred flags)
127
+ deleted.json # Deleted contacts index
128
+ notdeleted.json # Contacts skipped due to photo/starred
127
129
  token.json # OAuth token (read-only)
128
130
  token-write.json # OAuth token (read-write)
129
131
  changes.log # Log from gfix names
130
132
  birthdays.csv # Extracted birthdays
131
133
  merger.json # Duplicates to merge (from gfix undup)
132
134
  merged.json # Processed merges (history)
133
- photos.json # Photos from deleted contacts
134
135
  ```
135
136
 
136
137
  ## Fixes Applied by `gfix inspect/apply`
@@ -158,13 +159,26 @@ When a contact has a full name in `givenName` but no `familyName`:
158
159
 
159
160
  ## Deleting Contacts
160
161
 
161
- Three ways to delete contacts:
162
+ Three ways to mark contacts for deletion:
162
163
 
163
164
  1. **In index.json**: Add `"_delete": true` to an entry
164
165
  2. **In contact JSON**: Add `"_delete": true` to the contact file
165
166
  3. **Move to _delete/**: Move the contact file to the `_delete/` folder
166
167
 
167
- Then run `gcards push` to apply deletions. Deleted contacts are moved to `deleted.json`.
168
+ Then run `gcards push` to apply deletions.
169
+
170
+ ### Photo/Starred Protection
171
+
172
+ Contacts with photos or starred status are **not deleted** from Google to prevent data loss:
173
+ - Contacts with non-default photos: `_delete` set to `"photo"`
174
+ - Starred/favorite contacts: `_delete` set to `"starred"`
175
+
176
+ These remain in Google and `index.json` with the skip reason. Review `notdeleted.json` after push.
177
+
178
+ ### Backup
179
+
180
+ All deleted contact files are moved to `deleted/` folder as backup (not permanently deleted).
181
+ Index entries move to `deleted.json`.
168
182
 
169
183
  ## Duplicate Contact Merging
170
184
 
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';
@@ -112,7 +112,7 @@ function saveIndex(paths: UserPaths, index: ContactIndex): void {
112
112
 
113
113
  /** Deleted contacts index (separate from active index) */
114
114
  interface DeletedIndex {
115
- deleted: Record<string, IndexEntry>;
115
+ deleted: Record<string, DeletedEntry>;
116
116
  }
117
117
 
118
118
  function loadDeleted(paths: UserPaths): DeletedIndex {
@@ -124,7 +124,7 @@ function loadDeleted(paths: UserPaths): DeletedIndex {
124
124
 
125
125
  function saveDeleted(paths: UserPaths, deletedIndex: DeletedIndex): void {
126
126
  // Sort by displayName
127
- const sorted: Record<string, IndexEntry> = {};
127
+ const sorted: Record<string, DeletedEntry> = {};
128
128
  const entries = Object.entries(deletedIndex.deleted);
129
129
  entries.sort((a, b) => a[1].displayName.localeCompare(b[1].displayName));
130
130
  for (const [key, value] of entries) {
@@ -134,6 +134,72 @@ function saveDeleted(paths: UserPaths, deletedIndex: DeletedIndex): void {
134
134
  fs.writeFileSync(paths.deletedFile, JSON.stringify(deletedIndex, null, 2));
135
135
  }
136
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
+
137
203
  function generateGuid(): string {
138
204
  return crypto.randomUUID();
139
205
  }
@@ -232,20 +298,37 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
232
298
 
233
299
  const index = loadIndex(paths);
234
300
  const deletedIndex = loadDeleted(paths);
301
+ const photosIndex = loadPhotos(paths);
235
302
  let syncToken = options.full ? null : loadSyncToken(paths);
236
303
 
237
- // Migrate any deleted entries from index.json to deleted.json
304
+ // Migrate any old deleted entries from index.json to deleted.json
305
+ // Also clean up old fields (deleted: false, etag)
238
306
  let migrated = 0;
307
+ let cleaned = 0;
239
308
  for (const [rn, entry] of Object.entries(index.contacts)) {
240
- if (entry.deleted) {
241
- deletedIndex.deleted[rn] = entry;
309
+ const oldEntry = entry as any;
310
+ if (oldEntry.deleted === true) {
311
+ deletedIndex.deleted[rn] = { ...entry, deletedAt: oldEntry.deletedAt || new Date().toISOString() };
242
312
  delete index.contacts[rn];
243
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
+ }
244
324
  }
245
325
  }
246
326
  if (migrated > 0) {
247
327
  console.log(`Migrated ${migrated} deleted entries to deleted.json`);
248
328
  }
329
+ if (cleaned > 0) {
330
+ console.log(`Cleaned ${cleaned} old fields from index entries`);
331
+ }
249
332
 
250
333
  if (syncToken) {
251
334
  console.log('Performing incremental sync...');
@@ -275,12 +358,42 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
275
358
  if (isDeleted) {
276
359
  const entry = index.contacts[person.resourceName];
277
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
+
278
379
  // Move to deleted.json
279
- entry.deleted = true;
280
- entry.deletedAt = new Date().toISOString();
281
- deletedIndex.deleted[person.resourceName] = entry;
380
+ const deletedEntry = { ...entry, deletedAt: new Date().toISOString(), _delete: 'server' };
381
+ deletedIndex.deleted[person.resourceName] = deletedEntry;
282
382
  delete index.contacts[person.resourceName];
283
- deleteContactFile(paths, 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
+
284
397
  deleted++;
285
398
  if (options.verbose) console.log(`\n Deleted: ${displayName}`);
286
399
  }
@@ -307,14 +420,20 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
307
420
 
308
421
  // Extract GUIDs from userDefined fields
309
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;
310
428
 
311
429
  index.contacts[person.resourceName] = {
312
430
  resourceName: person.resourceName,
313
431
  displayName,
314
- etag: person.etag,
315
- deleted: false,
316
432
  updatedAt: new Date().toISOString(),
317
- guids: guids.length > 0 ? guids : undefined
433
+ ...(hasPhoto && { hasPhoto }),
434
+ ...(starred && { starred }),
435
+ ...(guids.length > 0 && { guids }),
436
+ ...(existingDelete && { _delete: existingDelete })
318
437
  };
319
438
 
320
439
  if (existed) {
@@ -340,6 +459,7 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
340
459
  index.lastSync = new Date().toISOString();
341
460
  saveIndex(paths, index);
342
461
  saveDeleted(paths, deletedIndex);
462
+ savePhotos(paths, photosIndex);
343
463
 
344
464
  // Small delay between pages to be nice to the API
345
465
  if (pageToken) {
@@ -383,23 +503,27 @@ interface PendingChangesResult {
383
503
  parseErrors: string[];
384
504
  }
385
505
 
386
- async function findPendingChanges(paths: UserPaths, logger: FileLogger): Promise<PendingChangesResult> {
506
+ async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?: string): Promise<PendingChangesResult> {
387
507
  const changes: PendingChange[] = [];
388
508
  const parseErrors: string[] = [];
389
509
  const index = loadIndex(paths);
390
- const lastSync = index.lastSync ? new Date(index.lastSync).getTime() : 0;
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
+ }
391
515
 
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
- }
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
+ });
403
527
  }
404
528
 
405
529
  // Check contacts/ for modified files and _delete markers
@@ -413,8 +537,8 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger): Promise
413
537
  const stat = await fp.stat(filePath);
414
538
  const modTime = stat.mtimeMs;
415
539
 
416
- // Skip files not modified since last sync
417
- if (modTime <= lastSync + 1000) continue;
540
+ // Skip files not modified since last sync (or -since override)
541
+ if (modTime <= sinceTime + 1000) continue;
418
542
 
419
543
  checked++;
420
544
  let content: GooglePerson & { _delete?: boolean };
@@ -645,7 +769,7 @@ async function createContactOnGoogle(person: GooglePerson, retryCount = 0, token
645
769
  return await response.json();
646
770
  }
647
771
 
648
- async function pushContacts(user: string, options: { yes: boolean; verbose: boolean; limit: number }): Promise<void> {
772
+ async function pushContacts(user: string, options: { yes: boolean; verbose: boolean; limit: number; since: string }): Promise<void> {
649
773
  const paths = getUserPaths(user);
650
774
  ensureUserDir(user);
651
775
 
@@ -654,7 +778,7 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
654
778
  const logger = new FileLogger(problemsFile, true);
655
779
 
656
780
  console.log(`Pushing contacts for user: ${user}`);
657
- let { changes, parseErrors } = await findPendingChanges(paths, logger);
781
+ let { changes, parseErrors } = await findPendingChanges(paths, logger, options.since || undefined);
658
782
 
659
783
  if (parseErrors.length > 0) {
660
784
  console.log(`\n${parseErrors.length} files had parse errors (see pushproblems.txt)`);
@@ -665,9 +789,28 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
665
789
  return;
666
790
  }
667
791
 
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');
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'));
671
814
 
672
815
  console.log('\nPending changes:');
673
816
  if (adds.length > 0) {
@@ -688,6 +831,10 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
688
831
  console.log(` - ${c.displayName} (${c.resourceName})`);
689
832
  }
690
833
  }
834
+ console.log(`\n(List written to ${toPushFile})`);
835
+
836
+ // Reorder changes to process in sorted order
837
+ changes = [...adds, ...updates, ...deletes];
691
838
 
692
839
  if (!options.yes) {
693
840
  const confirmed = await confirm(`\nPush ${changes.length} change(s) to Google?`);
@@ -703,6 +850,8 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
703
850
 
704
851
  const index = loadIndex(paths);
705
852
  const deletedIndex = loadDeleted(paths);
853
+ const deleteQueue = loadDeleteQueue(paths);
854
+ const completedDeletions = new Set<string>(); // Track successful deletions
706
855
  // Apply limit if specified
707
856
  if (options.limit > 0 && changes.length > options.limit) {
708
857
  console.log(`[DEBUG] Limiting to first ${options.limit} of ${changes.length} changes\n`);
@@ -711,9 +860,12 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
711
860
 
712
861
  let successCount = 0;
713
862
  let errorCount = 0;
863
+ let skippedPhoto = 0;
864
+ let skippedStarred = 0;
714
865
  let processed = 0;
715
866
  const total = changes.length;
716
867
  const problems: string[] = [];
868
+ const notDeleted: { resourceName: string; displayName: string; filePath: string; reason: string }[] = [];
717
869
 
718
870
  for (const change of changes) {
719
871
  if (escapePressed) {
@@ -728,17 +880,19 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
728
880
  process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Creating ${change.displayName}...`);
729
881
  const created = await createContactOnGoogle(content);
730
882
 
731
- // Extract GUIDs from userDefined
883
+ // Extract GUIDs and flags from created contact
732
884
  const guids = extractGuids(created);
885
+ const hasPhoto = hasNonDefaultPhoto(created);
886
+ const starred = isStarred(created);
733
887
 
734
888
  // Add to index
735
889
  index.contacts[created.resourceName] = {
736
890
  resourceName: created.resourceName,
737
891
  displayName: change.displayName,
738
- etag: created.etag,
739
- deleted: false,
740
892
  updatedAt: new Date().toISOString(),
741
- guids: guids.length > 0 ? guids : undefined
893
+ ...(hasPhoto && { hasPhoto }),
894
+ ...(starred && { starred }),
895
+ ...(guids.length > 0 && { guids })
742
896
  };
743
897
 
744
898
  // Save new contact to contacts/ folder
@@ -766,32 +920,85 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
766
920
  console.log(' done');
767
921
  successCount++;
768
922
  } 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
923
  const contactFile = path.join(paths.contactsDir, `${change.resourceName.replace('people/', '')}.json`);
784
- if (fs.existsSync(contactFile)) {
785
- fs.unlinkSync(contactFile);
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 */ }
786
935
  }
787
936
 
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
- }
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
+ }
792
959
 
793
- console.log(' done');
794
- successCount++;
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
+ }
795
1002
  }
796
1003
 
797
1004
  await sleep(700); // 90 writes/min limit = ~700ms between ops
@@ -811,12 +1018,27 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
811
1018
  console.log(`\nErrors written to: ${problemsFile}`);
812
1019
  }
813
1020
 
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)`);
1026
+ }
1027
+
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);
1032
+ }
1033
+
814
1034
  // Save status with new timestamp
815
1035
  saveStatus(paths, { lastPush: new Date().toISOString() });
816
1036
  saveIndex(paths, index);
817
1037
  saveDeleted(paths, deletedIndex);
818
1038
 
819
- console.log(`\nPush complete: ${successCount} succeeded, ${errorCount} failed`);
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}`);
820
1042
  }
821
1043
 
822
1044
  async function main(): Promise<void> {
@@ -861,7 +1083,7 @@ async function main(): Promise<void> {
861
1083
  await syncContacts(user, { full: options.full, verbose: options.verbose });
862
1084
  break;
863
1085
  case 'push':
864
- await pushContacts(user, { yes: options.yes, verbose: options.verbose, limit: options.limit });
1086
+ await pushContacts(user, { yes: options.yes, verbose: options.verbose, limit: options.limit, since: options.since });
865
1087
  break;
866
1088
  default:
867
1089
  console.error(`Unknown command: ${options.command}`);
package/gfix.ts CHANGED
@@ -11,9 +11,22 @@
11
11
  import fs from 'fs';
12
12
  import path from 'path';
13
13
  import type { GooglePerson, GoogleName, GooglePhoneNumber, GoogleEmailAddress, GoogleBirthday } from './glib/types.ts';
14
- import { DATA_DIR, resolveUser, getUserPaths } from './glib/gutils.ts';
14
+ import type { DeleteQueue, DeleteQueueEntry, UserPaths } from './glib/gctypes.ts';
15
+ import { DATA_DIR, resolveUser, getUserPaths, loadIndex } from './glib/gutils.ts';
15
16
  import { mergeContacts as mergeContactData, collectSourcePhotos, type MergeEntry, type PhotoEntry } from './glib/gmerge.ts';
16
17
 
18
+ function loadDeleteQueue(paths: UserPaths): DeleteQueue {
19
+ if (fs.existsSync(paths.deleteQueueFile)) {
20
+ return JSON.parse(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
21
+ }
22
+ return { updatedAt: '', entries: [] };
23
+ }
24
+
25
+ function saveDeleteQueue(paths: UserPaths, queue: DeleteQueue): void {
26
+ queue.updatedAt = new Date().toISOString();
27
+ fs.writeFileSync(paths.deleteQueueFile, JSON.stringify(queue, null, 2));
28
+ }
29
+
17
30
  interface BirthdayEntry {
18
31
  name: string;
19
32
  date: string;
@@ -1243,15 +1256,23 @@ async function runMerge(user: string, limit: number): Promise<void> {
1243
1256
  }
1244
1257
  }
1245
1258
 
1246
- // Load existing photos.json and merged.json
1259
+ // Load existing files
1247
1260
  let savedPhotos: PhotoEntry[] = [];
1248
1261
  if (fs.existsSync(photosPath)) {
1249
1262
  savedPhotos = JSON.parse(fs.readFileSync(photosPath, 'utf-8'));
1250
1263
  }
1251
1264
 
1265
+ const deleteQueue = loadDeleteQueue(paths);
1266
+ const index = loadIndex(paths);
1267
+
1252
1268
  let mergedEntries: MergeEntry[] = [];
1253
1269
  if (fs.existsSync(mergedPath)) {
1254
- mergedEntries = JSON.parse(fs.readFileSync(mergedPath, 'utf-8'));
1270
+ const content = fs.readFileSync(mergedPath, 'utf-8').trim();
1271
+ if (content) {
1272
+ try {
1273
+ mergedEntries = JSON.parse(content);
1274
+ } catch { /* ignore parse errors, start fresh */ }
1275
+ }
1255
1276
  }
1256
1277
 
1257
1278
  let mergeSuccess = 0;
@@ -1304,12 +1325,20 @@ async function runMerge(user: string, limit: number): Promise<void> {
1304
1325
  fs.writeFileSync(targetPath, JSON.stringify(updatedTarget, null, 2));
1305
1326
  console.log(` Updated ${targetPath}`);
1306
1327
 
1307
- // Mark sources for deletion
1328
+ // Add sources to delete queue and set _delete reason in index
1308
1329
  for (const source of sources) {
1309
1330
  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));
1331
+ const displayName = source.names?.[0]?.displayName || 'Unknown';
1332
+ deleteQueue.entries.push({
1333
+ resourceName: source.resourceName,
1334
+ displayName,
1335
+ addedAt: new Date().toISOString(),
1336
+ _delete: 'duplicate'
1337
+ });
1338
+ // Set _delete reason in index.json
1339
+ if (index.contacts[source.resourceName]) {
1340
+ index.contacts[source.resourceName]._delete = 'duplicate';
1341
+ }
1313
1342
  console.log(` Marked ${sourceId} for deletion`);
1314
1343
  }
1315
1344
 
@@ -1321,7 +1350,7 @@ async function runMerge(user: string, limit: number): Promise<void> {
1321
1350
  }
1322
1351
  }
1323
1352
 
1324
- // Process deletes - just mark files
1353
+ // Process deletes - add to delete queue and set _delete reason
1325
1354
  for (const entry of toDelete) {
1326
1355
  console.log(`\nMarking for deletion: ${entry.name}...`);
1327
1356
 
@@ -1329,9 +1358,18 @@ async function runMerge(user: string, limit: number): Promise<void> {
1329
1358
  const id = rn.replace('people/', '');
1330
1359
  const filePath = path.join(paths.contactsDir, `${id}.json`);
1331
1360
  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));
1361
+ const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1362
+ const displayName = contact.names?.[0]?.displayName || entry.name;
1363
+ deleteQueue.entries.push({
1364
+ resourceName: rn,
1365
+ displayName,
1366
+ addedAt: new Date().toISOString(),
1367
+ _delete: 'duplicate'
1368
+ });
1369
+ // Set _delete reason in index.json
1370
+ if (index.contacts[rn]) {
1371
+ index.contacts[rn]._delete = 'duplicate';
1372
+ }
1335
1373
  console.log(` Marked ${id} for deletion`);
1336
1374
  deleteSuccess++;
1337
1375
  }
@@ -1347,6 +1385,12 @@ async function runMerge(user: string, limit: number): Promise<void> {
1347
1385
  // Save merged.json (history of processed entries)
1348
1386
  fs.writeFileSync(mergedPath, JSON.stringify(mergedEntries, null, 2));
1349
1387
 
1388
+ // Save delete queue
1389
+ saveDeleteQueue(paths, deleteQueue);
1390
+
1391
+ // Save index.json with _delete reasons
1392
+ fs.writeFileSync(paths.indexFile, JSON.stringify(index, null, 2));
1393
+
1350
1394
  // Remove processed entries from merger.json
1351
1395
  const remainingEntries = JSON.parse(fs.readFileSync(mergerPath, 'utf-8')) as MergeEntry[];
1352
1396
  const processedNames = new Set(entries.map(e => e.name));
@@ -1361,6 +1405,72 @@ async function runMerge(user: string, limit: number): Promise<void> {
1361
1405
  console.log(`\nRun 'gcards push -u ${user}' to apply changes to Google.`);
1362
1406
  }
1363
1407
 
1408
+ /** Reset ALL delete markers: index.json, contact files, _delete.json, _delete/ folder */
1409
+ async function runReset(user: string): Promise<void> {
1410
+ const paths = getUserPaths(user);
1411
+
1412
+ let indexCleared = 0;
1413
+ let filesCleared = 0;
1414
+ let queueCleared = 0;
1415
+ let folderMoved = 0;
1416
+
1417
+ // Clear from index.json
1418
+ if (fs.existsSync(paths.indexFile)) {
1419
+ const index = JSON.parse(fs.readFileSync(paths.indexFile, 'utf-8'));
1420
+ for (const entry of Object.values(index.contacts) as any[]) {
1421
+ if (entry._delete) {
1422
+ delete entry._delete;
1423
+ indexCleared++;
1424
+ }
1425
+ }
1426
+ fs.writeFileSync(paths.indexFile, JSON.stringify(index, null, 2));
1427
+ }
1428
+
1429
+ // Clear from contact JSON files (preserve mtime to avoid false "modified" detection)
1430
+ if (fs.existsSync(paths.contactsDir)) {
1431
+ const files = fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'));
1432
+ for (const file of files) {
1433
+ const filePath = path.join(paths.contactsDir, file);
1434
+ const stat = fs.statSync(filePath);
1435
+ const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
1436
+ if (content._delete || content._deleted) {
1437
+ delete content._delete;
1438
+ delete content._deleted;
1439
+ fs.writeFileSync(filePath, JSON.stringify(content, null, 2));
1440
+ // Restore original mtime so file doesn't appear modified
1441
+ fs.utimesSync(filePath, stat.atime, stat.mtime);
1442
+ filesCleared++;
1443
+ }
1444
+ }
1445
+ }
1446
+
1447
+ // Clear _delete.json queue
1448
+ if (fs.existsSync(paths.deleteQueueFile)) {
1449
+ const queue = JSON.parse(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
1450
+ queueCleared = queue.entries?.length || 0;
1451
+ fs.unlinkSync(paths.deleteQueueFile);
1452
+ }
1453
+
1454
+ // Move files from _delete/ folder back to contacts/
1455
+ if (fs.existsSync(paths.toDeleteDir)) {
1456
+ const files = fs.readdirSync(paths.toDeleteDir).filter(f => f.endsWith('.json'));
1457
+ for (const file of files) {
1458
+ const src = path.join(paths.toDeleteDir, file);
1459
+ const dest = path.join(paths.contactsDir, file);
1460
+ fs.renameSync(src, dest);
1461
+ folderMoved++;
1462
+ }
1463
+ }
1464
+
1465
+ console.log(`Reset complete for user: ${user}`);
1466
+ console.log(` Index entries: ${indexCleared}`);
1467
+ console.log(` Contact files: ${filesCleared}`);
1468
+ console.log(` Queue entries: ${queueCleared}`);
1469
+ if (folderMoved > 0) {
1470
+ console.log(` Moved from _delete/: ${folderMoved}`);
1471
+ }
1472
+ }
1473
+
1364
1474
  function showUsage(): void {
1365
1475
  console.log(`
1366
1476
  gfix - One-time fix routines for gcards contacts
@@ -1375,6 +1485,7 @@ Commands:
1375
1485
  names Parse givenName into first/middle/last, clean fileAs, remove dup phones/emails
1376
1486
  undup Find duplicate contacts (same name + overlapping email) -> merger.json
1377
1487
  merge Merge duplicates locally (then use gcards push)
1488
+ reset Clear all _delete flags from index.json
1378
1489
 
1379
1490
  Options:
1380
1491
  -u, --user <name> User profile to process
@@ -1460,6 +1571,8 @@ async function main(): Promise<void> {
1460
1571
  await runUndup(resolvedUser);
1461
1572
  } else if (command === 'merge') {
1462
1573
  await runMerge(resolvedUser, processLimit);
1574
+ } else if (command === 'reset') {
1575
+ await runReset(resolvedUser);
1463
1576
  } else {
1464
1577
  console.error(`Unknown command: ${command}`);
1465
1578
  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.5",
4
4
  "description": "Google Contacts cleanup and management tool",
5
5
  "type": "module",
6
6
  "main": "gcards.ts",