@bobfrankston/gcards 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/gcards.ts CHANGED
@@ -8,6 +8,7 @@ import fs from 'fs';
8
8
  import fp from 'fs/promises';
9
9
  import path from 'path';
10
10
  import crypto from 'crypto';
11
+ import { parse as parseJsonc } from 'jsonc-parser';
11
12
  import { parseArgs, showUsage, showHelp } from './glib/parsecli.ts';
12
13
  import { authenticateOAuth } from '../../../projects/oauth/oauthsupport/index.ts';
13
14
  import type { GooglePerson, GoogleConnectionsResponse } from './glib/types.ts';
@@ -150,7 +151,23 @@ function saveIndex(paths: UserPaths, index: ContactIndex): void {
150
151
  sortedContacts[key] = value;
151
152
  }
152
153
  index.contacts = sortedContacts;
153
- fs.writeFileSync(paths.indexFile, JSON.stringify(index, null, 2));
154
+
155
+ // Add helpful comments to the file
156
+ const comment = `// index.json - Active contacts index
157
+ // This file tracks all contacts synced from Google.
158
+ //
159
+ // Special fields:
160
+ // _delete: Set to 'force' to mark a contact for deletion on next push
161
+ // Set to 'queue' to mark for interactive deletion review
162
+ // hasPhoto: Indicates contact has a non-default photo
163
+ // starred: Indicates contact is starred in Google
164
+ // guids: Array of GUID values from userDefined fields
165
+ //
166
+ // To delete a contact: Add "_delete": "force" to the entry, then run 'gcards push'
167
+ // To queue for review: Add "_delete": "queue" to the entry
168
+ //
169
+ `;
170
+ fs.writeFileSync(paths.indexFile, comment + JSON.stringify(index, null, 2));
154
171
  }
155
172
 
156
173
  /** Deleted contacts index (separate from active index) */
@@ -160,7 +177,11 @@ interface DeletedIndex {
160
177
 
161
178
  function loadDeleted(paths: UserPaths): DeletedIndex {
162
179
  if (fs.existsSync(paths.deletedFile)) {
163
- return JSON.parse(fs.readFileSync(paths.deletedFile, 'utf-8'));
180
+ try {
181
+ return parseJsonc(fs.readFileSync(paths.deletedFile, 'utf-8'));
182
+ } catch (e: any) {
183
+ throw new Error(`Failed to parse ${paths.deletedFile}: ${e.message}`);
184
+ }
164
185
  }
165
186
  return { deleted: {} };
166
187
  }
@@ -174,7 +195,18 @@ function saveDeleted(paths: UserPaths, deletedIndex: DeletedIndex): void {
174
195
  sorted[key] = value;
175
196
  }
176
197
  deletedIndex.deleted = sorted;
177
- fs.writeFileSync(paths.deletedFile, JSON.stringify(deletedIndex, null, 2));
198
+
199
+ // Add helpful comments
200
+ const comment = `// deleted.json - Tombstone index of deleted contacts
201
+ // This file tracks contacts that have been deleted from Google.
202
+ // These entries are kept for historical reference and audit purposes.
203
+ //
204
+ // Fields:
205
+ // deletedAt: ISO timestamp when the contact was deleted
206
+ // _delete: Reason for deletion ('server', 'force', 'queue', etc.)
207
+ //
208
+ `;
209
+ fs.writeFileSync(paths.deletedFile, comment + JSON.stringify(deletedIndex, null, 2));
178
210
  }
179
211
 
180
212
  interface SavedPhoto {
@@ -194,7 +226,11 @@ interface PhotosIndex {
194
226
 
195
227
  function loadPhotos(paths: UserPaths): PhotosIndex {
196
228
  if (fs.existsSync(paths.photosFile)) {
197
- return JSON.parse(fs.readFileSync(paths.photosFile, 'utf-8'));
229
+ try {
230
+ return parseJsonc(fs.readFileSync(paths.photosFile, 'utf-8'));
231
+ } catch (e: any) {
232
+ throw new Error(`Failed to parse ${paths.photosFile}: ${e.message}`);
233
+ }
198
234
  }
199
235
  return { photos: {} };
200
236
  }
@@ -208,19 +244,90 @@ function savePhotos(paths: UserPaths, photosIndex: PhotosIndex): void {
208
244
  sorted[key] = value;
209
245
  }
210
246
  photosIndex.photos = sorted;
211
- fs.writeFileSync(paths.photosFile, JSON.stringify(photosIndex, null, 2));
247
+
248
+ // Add helpful comments
249
+ const comment = `// _photos.json - Saved photos from deleted or merged contacts
250
+ // This file preserves photo URLs when contacts are deleted or merged.
251
+ // Photos are saved automatically during sync and merge operations.
252
+ //
253
+ // Fields:
254
+ // displayName: Contact's display name when photo was saved
255
+ // photos: Array of photo objects with url and sourceType
256
+ // deletedAt: ISO timestamp when the photo was saved
257
+ //
258
+ `;
259
+ // Save JSON data file
260
+ fs.writeFileSync(paths.photosFile, comment + JSON.stringify(photosIndex, null, 2));
261
+
262
+ // Generate HTML view
263
+ const html = `<!DOCTYPE html>
264
+ <html>
265
+ <head>
266
+ <meta charset="UTF-8">
267
+ <title>Saved Photos from Deleted Contacts</title>
268
+ <style>
269
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; }
270
+ h1 { color: #333; }
271
+ table { border-collapse: collapse; width: 100%; }
272
+ th, td { border: 1px solid #ddd; padding: 8px; text-align: left; vertical-align: middle; }
273
+ th { background: #f5f5f5; }
274
+ tr:hover { background: #f9f9f9; }
275
+ img { width: 100px; height: 100px; object-fit: cover; border-radius: 4px; }
276
+ a { color: #1a73e8; text-decoration: none; }
277
+ a:hover { text-decoration: underline; }
278
+ .deleted { font-size: 12px; color: #666; }
279
+ </style>
280
+ </head>
281
+ <body>
282
+ <h1>Saved Photos from Deleted Contacts (${entries.length})</h1>
283
+ <table>
284
+ <tr><th>Photo</th><th>Name</th></tr>
285
+ ${entries.map(([resourceName, entry]) => {
286
+ const id = resourceName.replace('people/', '');
287
+ const contactUrl = `https://contacts.google.com/person/${id}`;
288
+ const photoUrl = entry.photos[0]?.url || '';
289
+ return ` <tr>
290
+ <td>${photoUrl ? `<img src="${photoUrl}" alt="${entry.displayName}" loading="lazy">` : '(no photo)'}</td>
291
+ <td><a href="${contactUrl}" target="_blank">${entry.displayName}</a><br><span class="deleted">Deleted: ${entry.deletedAt?.split('T')[0] || 'unknown'}</span></td>
292
+ </tr>`;
293
+ }).join('\n')}
294
+ </table>
295
+ </body>
296
+ </html>`;
297
+ fs.writeFileSync(paths.photosHtmlFile, html);
212
298
  }
213
299
 
214
300
  function loadDeleteQueue(paths: UserPaths): DeleteQueue {
215
301
  if (fs.existsSync(paths.deleteQueueFile)) {
216
- return JSON.parse(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
302
+ try {
303
+ return parseJsonc(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
304
+ } catch (e: any) {
305
+ throw new Error(`Failed to parse ${paths.deleteQueueFile}: ${e.message}`);
306
+ }
217
307
  }
218
308
  return { updatedAt: '', entries: [] };
219
309
  }
220
310
 
221
311
  function saveDeleteQueue(paths: UserPaths, queue: DeleteQueue): void {
222
312
  queue.updatedAt = new Date().toISOString();
223
- fs.writeFileSync(paths.deleteQueueFile, JSON.stringify(queue, null, 2));
313
+
314
+ // Add helpful comments
315
+ const comment = `// _delete.json - Pending deletion queue
316
+ // This file contains contacts marked for deletion that require review.
317
+ // Run 'gcards push' to process deletions (will prompt for confirmation unless --yes flag is used).
318
+ //
319
+ // Fields:
320
+ // resourceName: Google resource name (people/xxxxx)
321
+ // displayName: Contact's display name
322
+ // reason: Reason for deletion (e.g., '*photo', '*starred')
323
+ // _delete: Deletion mode ('queue', 'force', etc.)
324
+ // queuedAt: ISO timestamp when added to queue
325
+ //
326
+ // To skip deletion: Remove the entry from this file
327
+ // To force deletion: Change _delete value to 'force'
328
+ //
329
+ `;
330
+ fs.writeFileSync(paths.deleteQueueFile, comment + JSON.stringify(queue, null, 2));
224
331
  }
225
332
 
226
333
  function extractNonDefaultPhotos(contact: GooglePerson): SavedPhoto[] {
@@ -249,14 +356,23 @@ function generateGuid(): string {
249
356
 
250
357
  function loadSyncToken(paths: UserPaths): string {
251
358
  if (fs.existsSync(paths.syncTokenFile)) {
252
- const data = JSON.parse(fs.readFileSync(paths.syncTokenFile, 'utf-8'));
253
- return data.syncToken || null;
359
+ try {
360
+ const data = parseJsonc(fs.readFileSync(paths.syncTokenFile, 'utf-8'));
361
+ return data.syncToken || null;
362
+ } catch (e: any) {
363
+ throw new Error(`Failed to parse ${paths.syncTokenFile}: ${e.message}`);
364
+ }
254
365
  }
255
366
  return null;
256
367
  }
257
368
 
258
369
  function saveSyncToken(paths: UserPaths, token: string): void {
259
- fs.writeFileSync(paths.syncTokenFile, JSON.stringify({ syncToken: token, savedAt: new Date().toISOString() }, null, 2));
370
+ const comment = `// sync-token.json - Google Contacts API sync token
371
+ // This token enables incremental sync, fetching only changes since last sync.
372
+ // Delete this file to force a full sync on next run.
373
+ //
374
+ `;
375
+ fs.writeFileSync(paths.syncTokenFile, comment + JSON.stringify({ syncToken: token, savedAt: new Date().toISOString() }, null, 2));
260
376
  }
261
377
 
262
378
  async function sleep(ms: number): Promise<void> {
@@ -334,7 +450,14 @@ function saveContact(paths: UserPaths, person: GooglePerson): void {
334
450
 
335
451
  const id = person.resourceName.replace('people/', '');
336
452
  const filePath = path.join(paths.contactsDir, `${id}.json`);
337
- fs.writeFileSync(filePath, JSON.stringify(person, null, 2));
453
+
454
+ // Add helpful comment for contact files
455
+ const comment = `// Contact file from Google People API
456
+ // To delete this contact: Add "_delete": "force" field at root level, then run 'gcards push'
457
+ // To modify: Edit this file, then run 'gcards push' to sync changes to Google
458
+ //
459
+ `;
460
+ fs.writeFileSync(filePath, comment + JSON.stringify(person, null, 2));
338
461
  }
339
462
 
340
463
  function deleteContactFile(paths: UserPaths, resourceName: string): void {
@@ -439,7 +562,7 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
439
562
  // Save photos before deleting
440
563
  if (fs.existsSync(contactFile)) {
441
564
  try {
442
- const contact = JSON.parse(fs.readFileSync(contactFile, 'utf-8')) as GooglePerson;
565
+ const contact = parseJsonc(fs.readFileSync(contactFile, 'utf-8')) as GooglePerson;
443
566
  const photos = extractNonDefaultPhotos(contact);
444
567
  if (photos.length > 0) {
445
568
  photosIndex.photos[person.resourceName] = {
@@ -461,12 +584,16 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
461
584
  if (!fs.existsSync(paths.deletedDir)) {
462
585
  fs.mkdirSync(paths.deletedDir, { recursive: true });
463
586
  }
464
- const contactData = JSON.parse(fs.readFileSync(contactFile, 'utf-8'));
465
- contactData._deletedAt = new Date().toISOString();
466
- contactData._delete = 'server';
467
- const destPath = path.join(paths.deletedDir, `${id}.json`);
468
- fs.writeFileSync(destPath, JSON.stringify(contactData, null, 2));
469
- fs.unlinkSync(contactFile);
587
+ try {
588
+ const contactData = parseJsonc(fs.readFileSync(contactFile, 'utf-8'));
589
+ contactData._deletedAt = new Date().toISOString();
590
+ contactData._delete = 'server';
591
+ const destPath = path.join(paths.deletedDir, `${id}.json`);
592
+ fs.writeFileSync(destPath, JSON.stringify(contactData, null, 2));
593
+ fs.unlinkSync(contactFile);
594
+ } catch (e: any) {
595
+ console.error(`\n[PARSE ERROR] ${id}.json: ${e.message}`);
596
+ }
470
597
  }
471
598
 
472
599
  deleted++;
@@ -554,6 +681,30 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
554
681
  const activeContacts = Object.keys(index.contacts).length;
555
682
  const tombstones = Object.keys(deletedIndex.deleted).length;
556
683
 
684
+ // Move orphaned files if doing full sync
685
+ let orphaned = 0;
686
+ if (options.full && fs.existsSync(paths.contactsDir)) {
687
+ const contactFiles = fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'));
688
+ const indexedResourceNames = new Set(
689
+ Object.keys(index.contacts).map(rn => rn.replace('people/', '') + '.json')
690
+ );
691
+
692
+ for (const file of contactFiles) {
693
+ if (!indexedResourceNames.has(file)) {
694
+ if (!fs.existsSync(paths.orphansDir)) {
695
+ fs.mkdirSync(paths.orphansDir, { recursive: true });
696
+ }
697
+ const src = path.join(paths.contactsDir, file);
698
+ const dst = path.join(paths.orphansDir, file);
699
+ fs.renameSync(src, dst);
700
+ orphaned++;
701
+ if (options.verbose) {
702
+ console.log(`\n Orphaned: ${file}`);
703
+ }
704
+ }
705
+ }
706
+ }
707
+
557
708
  console.log(`\n\nSync complete:`);
558
709
  console.log(` Processed: ${totalProcessed}`);
559
710
  console.log(` Added: ${added}`);
@@ -562,13 +713,20 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
562
713
  if (conflicts > 0) {
563
714
  console.log(` Conflicts: ${conflicts} (delete local file and sync to resolve)`);
564
715
  }
716
+ if (orphaned > 0) {
717
+ console.log(` Orphaned: ${orphaned} (moved to orphans/)`);
718
+ }
565
719
  console.log(` Active contacts: ${activeContacts}`);
566
720
  console.log(` Tombstones: ${tombstones}`);
567
721
  }
568
722
 
569
723
  function loadStatus(paths: UserPaths): PushStatus {
570
724
  if (fs.existsSync(paths.statusFile)) {
571
- return JSON.parse(fs.readFileSync(paths.statusFile, 'utf-8'));
725
+ try {
726
+ return parseJsonc(fs.readFileSync(paths.statusFile, 'utf-8'));
727
+ } catch (e: any) {
728
+ throw new Error(`Failed to parse ${paths.statusFile}: ${e.message}`);
729
+ }
572
730
  }
573
731
  return { lastPush: '' };
574
732
  }
@@ -628,7 +786,7 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?:
628
786
  checked++;
629
787
  let content: GooglePerson & { _delete?: boolean };
630
788
  try {
631
- content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
789
+ content = parseJsonc(await fp.readFile(filePath, 'utf-8'));
632
790
  } catch (e: any) {
633
791
  const errMsg = `[PARSE ERROR] ${file}: ${e.message}`;
634
792
  parseErrors.push(errMsg);
@@ -666,7 +824,7 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?:
666
824
  const filePath = path.join(paths.toDeleteDir, file);
667
825
  let content: GooglePerson & { _delete?: string | boolean };
668
826
  try {
669
- content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
827
+ content = parseJsonc(await fp.readFile(filePath, 'utf-8'));
670
828
  } catch (e: any) {
671
829
  const errMsg = `[PARSE ERROR] ${file}: ${e.message}`;
672
830
  parseErrors.push(errMsg);
@@ -692,7 +850,7 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?:
692
850
  const filePath = path.join(paths.toAddDir, file);
693
851
  let content: GooglePerson;
694
852
  try {
695
- content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
853
+ content = parseJsonc(await fp.readFile(filePath, 'utf-8'));
696
854
  } catch (e: any) {
697
855
  const errMsg = `[PARSE ERROR] ${file}: ${e.message}`;
698
856
  parseErrors.push(errMsg);
@@ -962,7 +1120,7 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
962
1120
  processed++;
963
1121
  try {
964
1122
  if (change.type === 'add') {
965
- const content = JSON.parse(fs.readFileSync(change.filePath, 'utf-8')) as GooglePerson;
1123
+ const content = parseJsonc(fs.readFileSync(change.filePath, 'utf-8')) as GooglePerson;
966
1124
  process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Creating ${change.displayName}...`);
967
1125
  const created = await createContactOnGoogle(content);
968
1126
 
@@ -996,7 +1154,7 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
996
1154
  console.log(` done (${created.resourceName})`);
997
1155
  successCount++;
998
1156
  } else if (change.type === 'update') {
999
- const content = JSON.parse(fs.readFileSync(change.filePath, 'utf-8')) as GooglePerson;
1157
+ const content = parseJsonc(fs.readFileSync(change.filePath, 'utf-8')) as GooglePerson;
1000
1158
  process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Updating ${change.displayName}...`);
1001
1159
  await updateContactOnGoogle(content);
1002
1160
 
@@ -1017,7 +1175,7 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
1017
1175
  let isForced = change._delete === 'force' || index.contacts[change.resourceName]?._delete === 'force';
1018
1176
  if (fs.existsSync(fileToRead)) {
1019
1177
  try {
1020
- const contact = JSON.parse(fs.readFileSync(fileToRead, 'utf-8')) as GooglePerson;
1178
+ const contact = parseJsonc(fs.readFileSync(fileToRead, 'utf-8')) as GooglePerson;
1021
1179
  contactHasPhoto = hasNonDefaultPhoto(contact);
1022
1180
  contactIsStarred = isStarred(contact);
1023
1181
  if ((contact as any)._delete === 'force') isForced = true;
@@ -1071,7 +1229,7 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
1071
1229
  }
1072
1230
  if (fs.existsSync(contactFile)) {
1073
1231
  // Add deletion metadata to the JSON file
1074
- const contactData = JSON.parse(fs.readFileSync(contactFile, 'utf-8'));
1232
+ const contactData = parseJsonc(fs.readFileSync(contactFile, 'utf-8'));
1075
1233
  contactData._deletedAt = new Date().toISOString();
1076
1234
  contactData._delete = change._delete || entry?._delete || 'unknown';
1077
1235
  const destPath = path.join(paths.deletedDir, path.basename(contactFile));
@@ -1090,7 +1248,7 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
1090
1248
  }
1091
1249
  }
1092
1250
 
1093
- await sleep(700); // 90 writes/min limit = ~700ms between ops
1251
+ await sleep(1500); // Google tightened limits - need ~1.5s between ops
1094
1252
  } catch (error: any) {
1095
1253
  const fileName = path.basename(change.filePath);
1096
1254
  const errorMsg = `${fileName}: ${error.message}`;
@@ -1104,6 +1262,26 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
1104
1262
  continue;
1105
1263
  }
1106
1264
 
1265
+ // Check for 404 - contact deleted on server, remove locally
1266
+ if (error.message?.includes('404') || error.message?.includes('NOT_FOUND')) {
1267
+ console.log(` → Contact deleted on Google. Removing local copy.`);
1268
+ // Move to deleted folder
1269
+ if (fs.existsSync(change.filePath)) {
1270
+ const id = path.basename(change.filePath, '.json');
1271
+ if (!fs.existsSync(paths.deletedDir)) {
1272
+ fs.mkdirSync(paths.deletedDir, { recursive: true });
1273
+ }
1274
+ const destPath = path.join(paths.deletedDir, `${id}.json`);
1275
+ fs.renameSync(change.filePath, destPath);
1276
+ }
1277
+ // Remove from index
1278
+ if (index.contacts[change.resourceName]) {
1279
+ delete index.contacts[change.resourceName];
1280
+ saveIndex(paths, index);
1281
+ }
1282
+ continue;
1283
+ }
1284
+
1107
1285
  // Get AI explanation if available and error is from API
1108
1286
  if (error instanceof ApiError && error.payload) {
1109
1287
  if (isAIAvailable()) {
@@ -1169,7 +1347,7 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
1169
1347
  let email = '';
1170
1348
  if (nd.filePath && fs.existsSync(nd.filePath)) {
1171
1349
  try {
1172
- const contact = JSON.parse(fs.readFileSync(nd.filePath, 'utf-8'));
1350
+ const contact = parseJsonc(fs.readFileSync(nd.filePath, 'utf-8'));
1173
1351
  email = contact.emailAddresses?.[0]?.value || '';
1174
1352
  } catch { /* ignore */ }
1175
1353
  }
package/gfix.ts CHANGED
@@ -19,14 +19,17 @@ import { mergeContacts as mergeContactData, collectSourcePhotos, type MergeEntry
19
19
 
20
20
  function loadDeleteQueue(paths: UserPaths): DeleteQueue {
21
21
  if (fs.existsSync(paths.deleteQueueFile)) {
22
- return JSON.parse(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
22
+ return parseJsonc(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
23
23
  }
24
24
  return { updatedAt: '', entries: [] };
25
25
  }
26
26
 
27
27
  function saveDeleteQueue(paths: UserPaths, queue: DeleteQueue): void {
28
28
  queue.updatedAt = new Date().toISOString();
29
- fs.writeFileSync(paths.deleteQueueFile, JSON.stringify(queue, null, 2));
29
+
30
+ // Add helpful comments
31
+ const comment = `// _delete.json - Pending deletion queue\n// This file contains contacts marked for deletion that require review.\n// Run 'gcards push' to process deletions (will prompt for confirmation unless --yes flag is used).\n//\n// Fields:\n// resourceName: Google resource name (people/xxxxx)\n// displayName: Contact's display name\n// reason: Reason for deletion (e.g., '*photo', '*starred')\n// _delete: Deletion mode ('queue', 'force', etc.)\n// queuedAt: ISO timestamp when added to queue\n//\n// To skip deletion: Remove the entry from this file\n// To force deletion: Change _delete value to 'force'\n//\n`;
32
+ fs.writeFileSync(paths.deleteQueueFile, comment + JSON.stringify(queue, null, 2));
30
33
  }
31
34
 
32
35
  interface BirthdayEntry {
@@ -323,7 +326,7 @@ async function runBirthdayExtract(user: string, mode: 'inspect' | 'apply'): Prom
323
326
 
324
327
  for (const file of files) {
325
328
  const filePath = path.join(paths.contactsDir, file);
326
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
329
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
327
330
 
328
331
  if (!contact.birthdays?.length) continue;
329
332
 
@@ -413,7 +416,7 @@ async function runFileAsFix(user: string, mode: 'inspect' | 'apply'): Promise<vo
413
416
 
414
417
  for (const file of files) {
415
418
  const filePath = path.join(paths.contactsDir, file);
416
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
419
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
417
420
 
418
421
  const name = contact.names?.[0];
419
422
  const givenName = name?.givenName || '';
@@ -1132,7 +1135,7 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
1132
1135
  const dst = path.join(paths.toDeleteDir, entry.file);
1133
1136
  fs.renameSync(src, dst);
1134
1137
  }
1135
- const existing = fs.existsSync(emailOnlyFile) ? JSON.parse(fs.readFileSync(emailOnlyFile, 'utf-8')) : [];
1138
+ const existing = fs.existsSync(emailOnlyFile) ? parseJsonc(fs.readFileSync(emailOnlyFile, 'utf-8')) : [];
1136
1139
  existing.push(...emailOnly.map(e => ({ file: e.file, resourceName: e.resourceName, email: e.email, movedAt: new Date().toISOString() })));
1137
1140
  fs.writeFileSync(emailOnlyFile, JSON.stringify(existing, null, 2));
1138
1141
  }
@@ -1143,7 +1146,7 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
1143
1146
  const dst = path.join(paths.toDeleteDir, entry.file);
1144
1147
  fs.renameSync(src, dst);
1145
1148
  }
1146
- const existing = fs.existsSync(phoneOnlyFile) ? JSON.parse(fs.readFileSync(phoneOnlyFile, 'utf-8')) : [];
1149
+ const existing = fs.existsSync(phoneOnlyFile) ? parseJsonc(fs.readFileSync(phoneOnlyFile, 'utf-8')) : [];
1147
1150
  existing.push(...phoneOnly.map(e => ({ file: e.file, resourceName: e.resourceName, phone: e.phone, movedAt: new Date().toISOString() })));
1148
1151
  fs.writeFileSync(phoneOnlyFile, JSON.stringify(existing, null, 2));
1149
1152
  }
@@ -1216,7 +1219,7 @@ async function runFix(user: string, mode: 'inspect' | 'apply'): Promise<void> {
1216
1219
 
1217
1220
  for (const file of files) {
1218
1221
  const filePath = path.join(paths.contactsDir, file);
1219
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1222
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1220
1223
  const changes = processContact(contact);
1221
1224
 
1222
1225
  if (changes.length > 0) {
@@ -1303,7 +1306,13 @@ async function runUndup(user: string): Promise<void> {
1303
1306
 
1304
1307
  for (const file of files) {
1305
1308
  const filePath = path.join(paths.contactsDir, file);
1306
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1309
+ let contact: GooglePerson;
1310
+ try {
1311
+ contact = parseJsonc(fs.readFileSync(filePath, 'utf-8'));
1312
+ } catch (e: any) {
1313
+ console.error(`[PARSE ERROR] ${file}: ${e.message}`);
1314
+ continue;
1315
+ }
1307
1316
 
1308
1317
  const name = contact.names?.[0];
1309
1318
  if (!name) continue;
@@ -1438,14 +1447,14 @@ async function runMerge(user: string, limit: number): Promise<void> {
1438
1447
  const paths = getUserPaths(user);
1439
1448
  const mergerPath = path.join(paths.userDir, 'merger.json');
1440
1449
  const mergedPath = path.join(paths.userDir, 'merged.json');
1441
- const photosPath = path.join(paths.userDir, 'photos.json');
1450
+ const photosPath = paths.photosFile; // _photos.json
1442
1451
 
1443
1452
  if (!fs.existsSync(mergerPath)) {
1444
1453
  console.error(`No merger.json found. Run 'gfix undup -u ${user}' first.`);
1445
1454
  process.exit(1);
1446
1455
  }
1447
1456
 
1448
- let entries: MergeEntry[] = JSON.parse(fs.readFileSync(mergerPath, 'utf-8'));
1457
+ let entries: MergeEntry[] = parseJsonc(fs.readFileSync(mergerPath, 'utf-8'));
1449
1458
 
1450
1459
  if (entries.length === 0) {
1451
1460
  console.log('No entries in merger.json');
@@ -1478,7 +1487,8 @@ async function runMerge(user: string, limit: number): Promise<void> {
1478
1487
  // Load existing files
1479
1488
  let savedPhotos: PhotoEntry[] = [];
1480
1489
  if (fs.existsSync(photosPath)) {
1481
- savedPhotos = JSON.parse(fs.readFileSync(photosPath, 'utf-8'));
1490
+ const parsed = parseJsonc(fs.readFileSync(photosPath, 'utf-8'));
1491
+ savedPhotos = Array.isArray(parsed) ? parsed : [];
1482
1492
  }
1483
1493
 
1484
1494
  const deleteQueue = loadDeleteQueue(paths);
@@ -1489,7 +1499,8 @@ async function runMerge(user: string, limit: number): Promise<void> {
1489
1499
  const content = fs.readFileSync(mergedPath, 'utf-8').trim();
1490
1500
  if (content) {
1491
1501
  try {
1492
- mergedEntries = JSON.parse(content);
1502
+ const parsed = parseJsonc(content);
1503
+ mergedEntries = Array.isArray(parsed) ? parsed : [];
1493
1504
  } catch { /* ignore parse errors, start fresh */ }
1494
1505
  }
1495
1506
  }
@@ -1509,7 +1520,7 @@ async function runMerge(user: string, limit: number): Promise<void> {
1509
1520
  const id = rn.replace('people/', '');
1510
1521
  const filePath = path.join(paths.contactsDir, `${id}.json`);
1511
1522
  if (fs.existsSync(filePath)) {
1512
- contacts.push(JSON.parse(fs.readFileSync(filePath, 'utf-8')));
1523
+ contacts.push(parseJsonc(fs.readFileSync(filePath, 'utf-8')));
1513
1524
  } else {
1514
1525
  throw new Error(`Contact file not found: ${filePath}`);
1515
1526
  }
@@ -1586,7 +1597,7 @@ async function runMerge(user: string, limit: number): Promise<void> {
1586
1597
  const id = rn.replace('people/', '');
1587
1598
  const filePath = path.join(paths.contactsDir, `${id}.json`);
1588
1599
  if (fs.existsSync(filePath)) {
1589
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1600
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1590
1601
  const displayName = contact.names?.[0]?.displayName || entry.name;
1591
1602
  deleteQueue.entries.push({
1592
1603
  resourceName: rn,
@@ -1605,9 +1616,47 @@ async function runMerge(user: string, limit: number): Promise<void> {
1605
1616
  mergedEntries.push(entry);
1606
1617
  }
1607
1618
 
1608
- // Save photos.json
1619
+ // Save photos (JSON data + HTML view)
1609
1620
  if (savedPhotos.length > 0) {
1610
1621
  fs.writeFileSync(photosPath, JSON.stringify(savedPhotos, null, 2));
1622
+
1623
+ // Generate HTML view
1624
+ const htmlPath = paths.photosHtmlFile;
1625
+ const html = `<!DOCTYPE html>
1626
+ <html>
1627
+ <head>
1628
+ <meta charset="UTF-8">
1629
+ <title>Saved Photos from Merged Contacts</title>
1630
+ <style>
1631
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; }
1632
+ h1 { color: #333; }
1633
+ table { border-collapse: collapse; width: 100%; }
1634
+ th, td { border: 1px solid #ddd; padding: 8px; text-align: left; vertical-align: middle; }
1635
+ th { background: #f5f5f5; }
1636
+ tr:hover { background: #f9f9f9; }
1637
+ img { width: 100px; height: 100px; object-fit: cover; border-radius: 4px; margin: 2px; }
1638
+ a { color: #1a73e8; text-decoration: none; }
1639
+ a:hover { text-decoration: underline; }
1640
+ </style>
1641
+ </head>
1642
+ <body>
1643
+ <h1>Saved Photos from Merged Contacts (${savedPhotos.length})</h1>
1644
+ <table>
1645
+ <tr><th>Photos</th><th>Name</th></tr>
1646
+ ${savedPhotos.map(entry => {
1647
+ const id = entry.contactId.replace('people/', '');
1648
+ const contactUrl = `https://contacts.google.com/person/${id}`;
1649
+ const photoImgs = entry.photos.map(url => `<img src="${url}" alt="${entry.name}" loading="lazy">`).join('');
1650
+ return ` <tr>
1651
+ <td>${photoImgs || '(no photo)'}</td>
1652
+ <td><a href="${contactUrl}" target="_blank">${entry.name}</a></td>
1653
+ </tr>`;
1654
+ }).join('\n')}
1655
+ </table>
1656
+ </body>
1657
+ </html>`;
1658
+ fs.writeFileSync(htmlPath, html);
1659
+ console.log(`\nSaved ${savedPhotos.length} photo entries to ${htmlPath}`);
1611
1660
  }
1612
1661
 
1613
1662
  // Save merged.json (history of processed entries)
@@ -1620,7 +1669,7 @@ async function runMerge(user: string, limit: number): Promise<void> {
1620
1669
  fs.writeFileSync(paths.indexFile, JSON.stringify(index, null, 2));
1621
1670
 
1622
1671
  // Remove processed entries from merger.json
1623
- const remainingEntries = JSON.parse(fs.readFileSync(mergerPath, 'utf-8')) as MergeEntry[];
1672
+ const remainingEntries = parseJsonc(fs.readFileSync(mergerPath, 'utf-8')) as MergeEntry[];
1624
1673
  const processedNames = new Set(entries.map(e => e.name));
1625
1674
  const newMerger = remainingEntries.filter(e => !processedNames.has(e.name));
1626
1675
  fs.writeFileSync(mergerPath, JSON.stringify(newMerger, null, 2));
@@ -1679,7 +1728,7 @@ async function runPhotos(user: string): Promise<void> {
1679
1728
 
1680
1729
  for (const file of files) {
1681
1730
  const filePath = path.join(paths.contactsDir, file);
1682
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1731
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1683
1732
 
1684
1733
  const photoUrl = getPhotoUrl(contact);
1685
1734
  if (!photoUrl) continue;
@@ -1766,7 +1815,7 @@ async function runPhotoCompare(user: string): Promise<void> {
1766
1815
  // First collect all contacts with photos
1767
1816
  for (const file of files) {
1768
1817
  const filePath = path.join(paths.contactsDir, file);
1769
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1818
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1770
1819
 
1771
1820
  const photoUrl = getPhotoUrl(contact);
1772
1821
  if (!photoUrl) continue;
@@ -1867,7 +1916,7 @@ function findContactById(paths: UserPaths, id: string): { contact: GooglePerson;
1867
1916
  const contactPath = path.join(paths.contactsDir, `${cleanId}.json`);
1868
1917
  if (fs.existsSync(contactPath)) {
1869
1918
  return {
1870
- contact: JSON.parse(fs.readFileSync(contactPath, 'utf-8')),
1919
+ contact: parseJsonc(fs.readFileSync(contactPath, 'utf-8')),
1871
1920
  filePath: contactPath,
1872
1921
  source: 'contacts'
1873
1922
  };
@@ -1877,7 +1926,7 @@ function findContactById(paths: UserPaths, id: string): { contact: GooglePerson;
1877
1926
  const deletedPath = path.join(paths.deletedDir, `${cleanId}.json`);
1878
1927
  if (fs.existsSync(deletedPath)) {
1879
1928
  return {
1880
- contact: JSON.parse(fs.readFileSync(deletedPath, 'utf-8')),
1929
+ contact: parseJsonc(fs.readFileSync(deletedPath, 'utf-8')),
1881
1930
  filePath: deletedPath,
1882
1931
  source: 'deleted'
1883
1932
  };
@@ -1906,7 +1955,7 @@ function searchContacts(paths: UserPaths, pattern: string): ContactMatch[] {
1906
1955
  if (fs.existsSync(paths.contactsDir)) {
1907
1956
  for (const file of fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'))) {
1908
1957
  const filePath = path.join(paths.contactsDir, file);
1909
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1958
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1910
1959
  if (matchesContact(contact, regex)) {
1911
1960
  matches.push({
1912
1961
  id: file.replace(/\.json$/, ''),
@@ -1923,7 +1972,7 @@ function searchContacts(paths: UserPaths, pattern: string): ContactMatch[] {
1923
1972
  if (fs.existsSync(paths.deletedDir)) {
1924
1973
  for (const file of fs.readdirSync(paths.deletedDir).filter(f => f.endsWith('.json'))) {
1925
1974
  const filePath = path.join(paths.deletedDir, file);
1926
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1975
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1927
1976
  if (matchesContact(contact, regex)) {
1928
1977
  matches.push({
1929
1978
  id: file.replace(/\.json$/, ''),
@@ -2167,7 +2216,7 @@ async function runReset(user: string): Promise<void> {
2167
2216
 
2168
2217
  // Clear from index.json
2169
2218
  if (fs.existsSync(paths.indexFile)) {
2170
- const index = JSON.parse(fs.readFileSync(paths.indexFile, 'utf-8'));
2219
+ const index = parseJsonc(fs.readFileSync(paths.indexFile, 'utf-8'));
2171
2220
  for (const entry of Object.values(index.contacts) as any[]) {
2172
2221
  if (entry._delete) {
2173
2222
  delete entry._delete;
@@ -2183,7 +2232,7 @@ async function runReset(user: string): Promise<void> {
2183
2232
  for (const file of files) {
2184
2233
  const filePath = path.join(paths.contactsDir, file);
2185
2234
  const stat = fs.statSync(filePath);
2186
- const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
2235
+ const content = parseJsonc(fs.readFileSync(filePath, 'utf-8'));
2187
2236
  if (content._delete || content._deleted) {
2188
2237
  delete content._delete;
2189
2238
  delete content._deleted;
@@ -2197,7 +2246,7 @@ async function runReset(user: string): Promise<void> {
2197
2246
 
2198
2247
  // Clear _delete.json queue
2199
2248
  if (fs.existsSync(paths.deleteQueueFile)) {
2200
- const queue = JSON.parse(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
2249
+ const queue = parseJsonc(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
2201
2250
  queueCleared = queue.entries?.length || 0;
2202
2251
  fs.unlinkSync(paths.deleteQueueFile);
2203
2252
  }
package/glib/gctypes.ts CHANGED
@@ -93,13 +93,15 @@ export interface UserPaths {
93
93
  userDir: string;
94
94
  contactsDir: string; /** Active contacts synced from Google */
95
95
  deletedDir: string; /** Backup of deleted contact files */
96
+ orphansDir: string; /** Dead files not in index (moved during sync --full) */
96
97
  toDeleteDir: string; /** User requests to delete (move files here) */
97
98
  toAddDir: string; /** User requests to add new contacts */
98
99
  fixLogDir: string; /** Logs from gfix operations */
99
100
  indexFile: string;
100
101
  deletedFile: string; /** Deleted contacts index (deleted.json) */
101
102
  deleteQueueFile: string; /** Pending deletions (_delete.json) */
102
- photosFile: string; /** Photos from deleted contacts (photos.json) */
103
+ photosFile: string; /** Photos data store (_photos.json) */
104
+ photosHtmlFile: string; /** Photos HTML view (photos.html) */
103
105
  statusFile: string;
104
106
  syncTokenFile: string;
105
107
  tokenFile: string;
package/glib/gmerge.ts CHANGED
@@ -261,8 +261,8 @@ export function collectSourcePhotos(sources: GooglePerson[]): string[] {
261
261
  const urls: string[] = [];
262
262
  for (const source of sources) {
263
263
  for (const photo of source.photos || []) {
264
- // Only collect custom photos (default: false means custom)
265
- if (photo.url && photo.default === false) {
264
+ // Only collect custom photos (!default means custom - undefined or false)
265
+ if (photo.url && !photo.default) {
266
266
  urls.push(photo.url);
267
267
  }
268
268
  }
package/glib/gutils.ts CHANGED
@@ -58,13 +58,15 @@ export function getUserPaths(user: string): UserPaths {
58
58
  userDir,
59
59
  contactsDir: path.join(userDir, 'contacts'),
60
60
  deletedDir: path.join(userDir, 'deleted'),
61
+ orphansDir: path.join(userDir, 'orphans'),
61
62
  toDeleteDir: path.join(userDir, '_delete'),
62
63
  toAddDir: path.join(userDir, '_add'),
63
64
  fixLogDir: path.join(userDir, 'fix-logs'),
64
65
  indexFile: path.join(userDir, 'index.json'),
65
66
  deletedFile: path.join(userDir, 'deleted.json'),
66
67
  deleteQueueFile: path.join(userDir, '_delete.json'),
67
- photosFile: path.join(userDir, 'photos.json'),
68
+ photosFile: path.join(userDir, '_photos.json'),
69
+ photosHtmlFile: path.join(userDir, 'photos.html'),
68
70
  statusFile: path.join(userDir, 'status.json'),
69
71
  syncTokenFile: path.join(userDir, 'sync-token.json'),
70
72
  tokenFile: path.join(userDir, 'token.json'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/gcards",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Google Contacts cleanup and management tool",
5
5
  "type": "module",
6
6
  "main": "gcards.ts",