@bobfrankston/gcards 0.1.20 → 0.1.22

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';
@@ -160,7 +161,11 @@ interface DeletedIndex {
160
161
 
161
162
  function loadDeleted(paths: UserPaths): DeletedIndex {
162
163
  if (fs.existsSync(paths.deletedFile)) {
163
- return JSON.parse(fs.readFileSync(paths.deletedFile, 'utf-8'));
164
+ try {
165
+ return parseJsonc(fs.readFileSync(paths.deletedFile, 'utf-8'));
166
+ } catch (e: any) {
167
+ throw new Error(`Failed to parse ${paths.deletedFile}: ${e.message}`);
168
+ }
164
169
  }
165
170
  return { deleted: {} };
166
171
  }
@@ -194,7 +199,11 @@ interface PhotosIndex {
194
199
 
195
200
  function loadPhotos(paths: UserPaths): PhotosIndex {
196
201
  if (fs.existsSync(paths.photosFile)) {
197
- return JSON.parse(fs.readFileSync(paths.photosFile, 'utf-8'));
202
+ try {
203
+ return parseJsonc(fs.readFileSync(paths.photosFile, 'utf-8'));
204
+ } catch (e: any) {
205
+ throw new Error(`Failed to parse ${paths.photosFile}: ${e.message}`);
206
+ }
198
207
  }
199
208
  return { photos: {} };
200
209
  }
@@ -208,12 +217,55 @@ function savePhotos(paths: UserPaths, photosIndex: PhotosIndex): void {
208
217
  sorted[key] = value;
209
218
  }
210
219
  photosIndex.photos = sorted;
220
+
221
+ // Save JSON data file
211
222
  fs.writeFileSync(paths.photosFile, JSON.stringify(photosIndex, null, 2));
223
+
224
+ // Generate HTML view
225
+ const html = `<!DOCTYPE html>
226
+ <html>
227
+ <head>
228
+ <meta charset="UTF-8">
229
+ <title>Saved Photos from Deleted Contacts</title>
230
+ <style>
231
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; }
232
+ h1 { color: #333; }
233
+ table { border-collapse: collapse; width: 100%; }
234
+ th, td { border: 1px solid #ddd; padding: 8px; text-align: left; vertical-align: middle; }
235
+ th { background: #f5f5f5; }
236
+ tr:hover { background: #f9f9f9; }
237
+ img { width: 100px; height: 100px; object-fit: cover; border-radius: 4px; }
238
+ a { color: #1a73e8; text-decoration: none; }
239
+ a:hover { text-decoration: underline; }
240
+ .deleted { font-size: 12px; color: #666; }
241
+ </style>
242
+ </head>
243
+ <body>
244
+ <h1>Saved Photos from Deleted Contacts (${entries.length})</h1>
245
+ <table>
246
+ <tr><th>Photo</th><th>Name</th></tr>
247
+ ${entries.map(([resourceName, entry]) => {
248
+ const id = resourceName.replace('people/', '');
249
+ const contactUrl = `https://contacts.google.com/person/${id}`;
250
+ const photoUrl = entry.photos[0]?.url || '';
251
+ return ` <tr>
252
+ <td>${photoUrl ? `<img src="${photoUrl}" alt="${entry.displayName}" loading="lazy">` : '(no photo)'}</td>
253
+ <td><a href="${contactUrl}" target="_blank">${entry.displayName}</a><br><span class="deleted">Deleted: ${entry.deletedAt?.split('T')[0] || 'unknown'}</span></td>
254
+ </tr>`;
255
+ }).join('\n')}
256
+ </table>
257
+ </body>
258
+ </html>`;
259
+ fs.writeFileSync(paths.photosHtmlFile, html);
212
260
  }
213
261
 
214
262
  function loadDeleteQueue(paths: UserPaths): DeleteQueue {
215
263
  if (fs.existsSync(paths.deleteQueueFile)) {
216
- return JSON.parse(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
264
+ try {
265
+ return parseJsonc(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
266
+ } catch (e: any) {
267
+ throw new Error(`Failed to parse ${paths.deleteQueueFile}: ${e.message}`);
268
+ }
217
269
  }
218
270
  return { updatedAt: '', entries: [] };
219
271
  }
@@ -249,8 +301,12 @@ function generateGuid(): string {
249
301
 
250
302
  function loadSyncToken(paths: UserPaths): string {
251
303
  if (fs.existsSync(paths.syncTokenFile)) {
252
- const data = JSON.parse(fs.readFileSync(paths.syncTokenFile, 'utf-8'));
253
- return data.syncToken || null;
304
+ try {
305
+ const data = parseJsonc(fs.readFileSync(paths.syncTokenFile, 'utf-8'));
306
+ return data.syncToken || null;
307
+ } catch (e: any) {
308
+ throw new Error(`Failed to parse ${paths.syncTokenFile}: ${e.message}`);
309
+ }
254
310
  }
255
311
  return null;
256
312
  }
@@ -439,7 +495,7 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
439
495
  // Save photos before deleting
440
496
  if (fs.existsSync(contactFile)) {
441
497
  try {
442
- const contact = JSON.parse(fs.readFileSync(contactFile, 'utf-8')) as GooglePerson;
498
+ const contact = parseJsonc(fs.readFileSync(contactFile, 'utf-8')) as GooglePerson;
443
499
  const photos = extractNonDefaultPhotos(contact);
444
500
  if (photos.length > 0) {
445
501
  photosIndex.photos[person.resourceName] = {
@@ -461,12 +517,16 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
461
517
  if (!fs.existsSync(paths.deletedDir)) {
462
518
  fs.mkdirSync(paths.deletedDir, { recursive: true });
463
519
  }
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);
520
+ try {
521
+ const contactData = parseJsonc(fs.readFileSync(contactFile, 'utf-8'));
522
+ contactData._deletedAt = new Date().toISOString();
523
+ contactData._delete = 'server';
524
+ const destPath = path.join(paths.deletedDir, `${id}.json`);
525
+ fs.writeFileSync(destPath, JSON.stringify(contactData, null, 2));
526
+ fs.unlinkSync(contactFile);
527
+ } catch (e: any) {
528
+ console.error(`\n[PARSE ERROR] ${id}.json: ${e.message}`);
529
+ }
470
530
  }
471
531
 
472
532
  deleted++;
@@ -568,7 +628,11 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
568
628
 
569
629
  function loadStatus(paths: UserPaths): PushStatus {
570
630
  if (fs.existsSync(paths.statusFile)) {
571
- return JSON.parse(fs.readFileSync(paths.statusFile, 'utf-8'));
631
+ try {
632
+ return parseJsonc(fs.readFileSync(paths.statusFile, 'utf-8'));
633
+ } catch (e: any) {
634
+ throw new Error(`Failed to parse ${paths.statusFile}: ${e.message}`);
635
+ }
572
636
  }
573
637
  return { lastPush: '' };
574
638
  }
@@ -628,7 +692,7 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?:
628
692
  checked++;
629
693
  let content: GooglePerson & { _delete?: boolean };
630
694
  try {
631
- content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
695
+ content = parseJsonc(await fp.readFile(filePath, 'utf-8'));
632
696
  } catch (e: any) {
633
697
  const errMsg = `[PARSE ERROR] ${file}: ${e.message}`;
634
698
  parseErrors.push(errMsg);
@@ -666,7 +730,7 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?:
666
730
  const filePath = path.join(paths.toDeleteDir, file);
667
731
  let content: GooglePerson & { _delete?: string | boolean };
668
732
  try {
669
- content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
733
+ content = parseJsonc(await fp.readFile(filePath, 'utf-8'));
670
734
  } catch (e: any) {
671
735
  const errMsg = `[PARSE ERROR] ${file}: ${e.message}`;
672
736
  parseErrors.push(errMsg);
@@ -692,7 +756,7 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?:
692
756
  const filePath = path.join(paths.toAddDir, file);
693
757
  let content: GooglePerson;
694
758
  try {
695
- content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
759
+ content = parseJsonc(await fp.readFile(filePath, 'utf-8'));
696
760
  } catch (e: any) {
697
761
  const errMsg = `[PARSE ERROR] ${file}: ${e.message}`;
698
762
  parseErrors.push(errMsg);
@@ -962,7 +1026,7 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
962
1026
  processed++;
963
1027
  try {
964
1028
  if (change.type === 'add') {
965
- const content = JSON.parse(fs.readFileSync(change.filePath, 'utf-8')) as GooglePerson;
1029
+ const content = parseJsonc(fs.readFileSync(change.filePath, 'utf-8')) as GooglePerson;
966
1030
  process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Creating ${change.displayName}...`);
967
1031
  const created = await createContactOnGoogle(content);
968
1032
 
@@ -996,7 +1060,7 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
996
1060
  console.log(` done (${created.resourceName})`);
997
1061
  successCount++;
998
1062
  } else if (change.type === 'update') {
999
- const content = JSON.parse(fs.readFileSync(change.filePath, 'utf-8')) as GooglePerson;
1063
+ const content = parseJsonc(fs.readFileSync(change.filePath, 'utf-8')) as GooglePerson;
1000
1064
  process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Updating ${change.displayName}...`);
1001
1065
  await updateContactOnGoogle(content);
1002
1066
 
@@ -1017,7 +1081,7 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
1017
1081
  let isForced = change._delete === 'force' || index.contacts[change.resourceName]?._delete === 'force';
1018
1082
  if (fs.existsSync(fileToRead)) {
1019
1083
  try {
1020
- const contact = JSON.parse(fs.readFileSync(fileToRead, 'utf-8')) as GooglePerson;
1084
+ const contact = parseJsonc(fs.readFileSync(fileToRead, 'utf-8')) as GooglePerson;
1021
1085
  contactHasPhoto = hasNonDefaultPhoto(contact);
1022
1086
  contactIsStarred = isStarred(contact);
1023
1087
  if ((contact as any)._delete === 'force') isForced = true;
@@ -1071,7 +1135,7 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
1071
1135
  }
1072
1136
  if (fs.existsSync(contactFile)) {
1073
1137
  // Add deletion metadata to the JSON file
1074
- const contactData = JSON.parse(fs.readFileSync(contactFile, 'utf-8'));
1138
+ const contactData = parseJsonc(fs.readFileSync(contactFile, 'utf-8'));
1075
1139
  contactData._deletedAt = new Date().toISOString();
1076
1140
  contactData._delete = change._delete || entry?._delete || 'unknown';
1077
1141
  const destPath = path.join(paths.deletedDir, path.basename(contactFile));
@@ -1090,7 +1154,7 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
1090
1154
  }
1091
1155
  }
1092
1156
 
1093
- await sleep(700); // 90 writes/min limit = ~700ms between ops
1157
+ await sleep(1500); // Google tightened limits - need ~1.5s between ops
1094
1158
  } catch (error: any) {
1095
1159
  const fileName = path.basename(change.filePath);
1096
1160
  const errorMsg = `${fileName}: ${error.message}`;
@@ -1104,6 +1168,26 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
1104
1168
  continue;
1105
1169
  }
1106
1170
 
1171
+ // Check for 404 - contact deleted on server, remove locally
1172
+ if (error.message?.includes('404') || error.message?.includes('NOT_FOUND')) {
1173
+ console.log(` → Contact deleted on Google. Removing local copy.`);
1174
+ // Move to deleted folder
1175
+ if (fs.existsSync(change.filePath)) {
1176
+ const id = path.basename(change.filePath, '.json');
1177
+ if (!fs.existsSync(paths.deletedDir)) {
1178
+ fs.mkdirSync(paths.deletedDir, { recursive: true });
1179
+ }
1180
+ const destPath = path.join(paths.deletedDir, `${id}.json`);
1181
+ fs.renameSync(change.filePath, destPath);
1182
+ }
1183
+ // Remove from index
1184
+ if (index.contacts[change.resourceName]) {
1185
+ delete index.contacts[change.resourceName];
1186
+ saveIndex(paths, index);
1187
+ }
1188
+ continue;
1189
+ }
1190
+
1107
1191
  // Get AI explanation if available and error is from API
1108
1192
  if (error instanceof ApiError && error.payload) {
1109
1193
  if (isAIAvailable()) {
@@ -1169,7 +1253,7 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
1169
1253
  let email = '';
1170
1254
  if (nd.filePath && fs.existsSync(nd.filePath)) {
1171
1255
  try {
1172
- const contact = JSON.parse(fs.readFileSync(nd.filePath, 'utf-8'));
1256
+ const contact = parseJsonc(fs.readFileSync(nd.filePath, 'utf-8'));
1173
1257
  email = contact.emailAddresses?.[0]?.value || '';
1174
1258
  } catch { /* ignore */ }
1175
1259
  }
package/gfix.ts CHANGED
@@ -19,7 +19,7 @@ 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
  }
@@ -323,7 +323,7 @@ async function runBirthdayExtract(user: string, mode: 'inspect' | 'apply'): Prom
323
323
 
324
324
  for (const file of files) {
325
325
  const filePath = path.join(paths.contactsDir, file);
326
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
326
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
327
327
 
328
328
  if (!contact.birthdays?.length) continue;
329
329
 
@@ -413,7 +413,7 @@ async function runFileAsFix(user: string, mode: 'inspect' | 'apply'): Promise<vo
413
413
 
414
414
  for (const file of files) {
415
415
  const filePath = path.join(paths.contactsDir, file);
416
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
416
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
417
417
 
418
418
  const name = contact.names?.[0];
419
419
  const givenName = name?.givenName || '';
@@ -1132,7 +1132,7 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
1132
1132
  const dst = path.join(paths.toDeleteDir, entry.file);
1133
1133
  fs.renameSync(src, dst);
1134
1134
  }
1135
- const existing = fs.existsSync(emailOnlyFile) ? JSON.parse(fs.readFileSync(emailOnlyFile, 'utf-8')) : [];
1135
+ const existing = fs.existsSync(emailOnlyFile) ? parseJsonc(fs.readFileSync(emailOnlyFile, 'utf-8')) : [];
1136
1136
  existing.push(...emailOnly.map(e => ({ file: e.file, resourceName: e.resourceName, email: e.email, movedAt: new Date().toISOString() })));
1137
1137
  fs.writeFileSync(emailOnlyFile, JSON.stringify(existing, null, 2));
1138
1138
  }
@@ -1143,7 +1143,7 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
1143
1143
  const dst = path.join(paths.toDeleteDir, entry.file);
1144
1144
  fs.renameSync(src, dst);
1145
1145
  }
1146
- const existing = fs.existsSync(phoneOnlyFile) ? JSON.parse(fs.readFileSync(phoneOnlyFile, 'utf-8')) : [];
1146
+ const existing = fs.existsSync(phoneOnlyFile) ? parseJsonc(fs.readFileSync(phoneOnlyFile, 'utf-8')) : [];
1147
1147
  existing.push(...phoneOnly.map(e => ({ file: e.file, resourceName: e.resourceName, phone: e.phone, movedAt: new Date().toISOString() })));
1148
1148
  fs.writeFileSync(phoneOnlyFile, JSON.stringify(existing, null, 2));
1149
1149
  }
@@ -1216,7 +1216,7 @@ async function runFix(user: string, mode: 'inspect' | 'apply'): Promise<void> {
1216
1216
 
1217
1217
  for (const file of files) {
1218
1218
  const filePath = path.join(paths.contactsDir, file);
1219
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1219
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1220
1220
  const changes = processContact(contact);
1221
1221
 
1222
1222
  if (changes.length > 0) {
@@ -1303,7 +1303,13 @@ async function runUndup(user: string): Promise<void> {
1303
1303
 
1304
1304
  for (const file of files) {
1305
1305
  const filePath = path.join(paths.contactsDir, file);
1306
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1306
+ let contact: GooglePerson;
1307
+ try {
1308
+ contact = parseJsonc(fs.readFileSync(filePath, 'utf-8'));
1309
+ } catch (e: any) {
1310
+ console.error(`[PARSE ERROR] ${file}: ${e.message}`);
1311
+ continue;
1312
+ }
1307
1313
 
1308
1314
  const name = contact.names?.[0];
1309
1315
  if (!name) continue;
@@ -1401,19 +1407,51 @@ async function runUndup(user: string): Promise<void> {
1401
1407
  console.log(`Then run: gfix merge -u ${user}`);
1402
1408
  }
1403
1409
 
1410
+ /** Check if contact has a non-default (custom) photo */
1411
+ function hasNonDefaultPhoto(contact: GooglePerson): boolean {
1412
+ return contact.photos?.some(p => !p.default) || false;
1413
+ }
1414
+
1415
+ /** Get non-default photo URL from contact */
1416
+ function getPhotoUrl(contact: GooglePerson): string {
1417
+ const photo = contact.photos?.find(p => !p.default);
1418
+ return photo?.url || '';
1419
+ }
1420
+
1421
+ /** Choose best target from contacts - prefer one with custom photo */
1422
+ function chooseBestTarget(contacts: GooglePerson[]): { target: GooglePerson; sources: GooglePerson[] } {
1423
+ // Sort: contacts with photos first
1424
+ const sorted = [...contacts].sort((a, b) => {
1425
+ const aHasPhoto = hasNonDefaultPhoto(a);
1426
+ const bHasPhoto = hasNonDefaultPhoto(b);
1427
+ if (aHasPhoto && !bHasPhoto) return -1;
1428
+ if (!aHasPhoto && bHasPhoto) return 1;
1429
+ return 0;
1430
+ });
1431
+ return { target: sorted[0], sources: sorted.slice(1) };
1432
+ }
1433
+
1434
+ /** Check if two contacts have the same photo (by URL) */
1435
+ function haveSamePhoto(a: GooglePerson, b: GooglePerson): boolean {
1436
+ const urlA = getPhotoUrl(a);
1437
+ const urlB = getPhotoUrl(b);
1438
+ if (!urlA || !urlB) return false;
1439
+ return urlA === urlB;
1440
+ }
1441
+
1404
1442
  /** Merge duplicate contacts locally (prepares for gcards push) */
1405
1443
  async function runMerge(user: string, limit: number): Promise<void> {
1406
1444
  const paths = getUserPaths(user);
1407
1445
  const mergerPath = path.join(paths.userDir, 'merger.json');
1408
1446
  const mergedPath = path.join(paths.userDir, 'merged.json');
1409
- const photosPath = path.join(paths.userDir, 'photos.json');
1447
+ const photosPath = paths.photosFile; // _photos.json
1410
1448
 
1411
1449
  if (!fs.existsSync(mergerPath)) {
1412
1450
  console.error(`No merger.json found. Run 'gfix undup -u ${user}' first.`);
1413
1451
  process.exit(1);
1414
1452
  }
1415
1453
 
1416
- let entries: MergeEntry[] = JSON.parse(fs.readFileSync(mergerPath, 'utf-8'));
1454
+ let entries: MergeEntry[] = parseJsonc(fs.readFileSync(mergerPath, 'utf-8'));
1417
1455
 
1418
1456
  if (entries.length === 0) {
1419
1457
  console.log('No entries in merger.json');
@@ -1446,7 +1484,8 @@ async function runMerge(user: string, limit: number): Promise<void> {
1446
1484
  // Load existing files
1447
1485
  let savedPhotos: PhotoEntry[] = [];
1448
1486
  if (fs.existsSync(photosPath)) {
1449
- savedPhotos = JSON.parse(fs.readFileSync(photosPath, 'utf-8'));
1487
+ const parsed = parseJsonc(fs.readFileSync(photosPath, 'utf-8'));
1488
+ savedPhotos = Array.isArray(parsed) ? parsed : [];
1450
1489
  }
1451
1490
 
1452
1491
  const deleteQueue = loadDeleteQueue(paths);
@@ -1457,13 +1496,15 @@ async function runMerge(user: string, limit: number): Promise<void> {
1457
1496
  const content = fs.readFileSync(mergedPath, 'utf-8').trim();
1458
1497
  if (content) {
1459
1498
  try {
1460
- mergedEntries = JSON.parse(content);
1499
+ const parsed = parseJsonc(content);
1500
+ mergedEntries = Array.isArray(parsed) ? parsed : [];
1461
1501
  } catch { /* ignore parse errors, start fresh */ }
1462
1502
  }
1463
1503
  }
1464
1504
 
1465
1505
  let mergeSuccess = 0;
1466
1506
  let deleteSuccess = 0;
1507
+ let samePhotoSkipped = 0;
1467
1508
 
1468
1509
  // Process merges - update local files only
1469
1510
  for (const entry of toMerge) {
@@ -1476,7 +1517,7 @@ async function runMerge(user: string, limit: number): Promise<void> {
1476
1517
  const id = rn.replace('people/', '');
1477
1518
  const filePath = path.join(paths.contactsDir, `${id}.json`);
1478
1519
  if (fs.existsSync(filePath)) {
1479
- contacts.push(JSON.parse(fs.readFileSync(filePath, 'utf-8')));
1520
+ contacts.push(parseJsonc(fs.readFileSync(filePath, 'utf-8')));
1480
1521
  } else {
1481
1522
  throw new Error(`Contact file not found: ${filePath}`);
1482
1523
  }
@@ -1487,8 +1528,16 @@ async function runMerge(user: string, limit: number): Promise<void> {
1487
1528
  continue;
1488
1529
  }
1489
1530
 
1490
- // Target is first, sources are rest
1491
- const [target, ...sources] = contacts;
1531
+ // Check if all contacts have the same photo URL - if so, safe to merge
1532
+ const photoUrls = contacts.map(c => getPhotoUrl(c)).filter(u => u);
1533
+ const uniquePhotoUrls = [...new Set(photoUrls)];
1534
+ if (uniquePhotoUrls.length === 1) {
1535
+ console.log(` Same photo URL detected - safe to merge`);
1536
+ samePhotoSkipped++;
1537
+ }
1538
+
1539
+ // Choose best target (one with photo preferred)
1540
+ const { target, sources } = chooseBestTarget(contacts);
1492
1541
 
1493
1542
  // Collect photos from sources before marking for deletion
1494
1543
  const sourcePhotoUrls = collectSourcePhotos(sources);
@@ -1545,7 +1594,7 @@ async function runMerge(user: string, limit: number): Promise<void> {
1545
1594
  const id = rn.replace('people/', '');
1546
1595
  const filePath = path.join(paths.contactsDir, `${id}.json`);
1547
1596
  if (fs.existsSync(filePath)) {
1548
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1597
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1549
1598
  const displayName = contact.names?.[0]?.displayName || entry.name;
1550
1599
  deleteQueue.entries.push({
1551
1600
  resourceName: rn,
@@ -1564,9 +1613,47 @@ async function runMerge(user: string, limit: number): Promise<void> {
1564
1613
  mergedEntries.push(entry);
1565
1614
  }
1566
1615
 
1567
- // Save photos.json
1616
+ // Save photos (JSON data + HTML view)
1568
1617
  if (savedPhotos.length > 0) {
1569
1618
  fs.writeFileSync(photosPath, JSON.stringify(savedPhotos, null, 2));
1619
+
1620
+ // Generate HTML view
1621
+ const htmlPath = paths.photosHtmlFile;
1622
+ const html = `<!DOCTYPE html>
1623
+ <html>
1624
+ <head>
1625
+ <meta charset="UTF-8">
1626
+ <title>Saved Photos from Merged Contacts</title>
1627
+ <style>
1628
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; }
1629
+ h1 { color: #333; }
1630
+ table { border-collapse: collapse; width: 100%; }
1631
+ th, td { border: 1px solid #ddd; padding: 8px; text-align: left; vertical-align: middle; }
1632
+ th { background: #f5f5f5; }
1633
+ tr:hover { background: #f9f9f9; }
1634
+ img { width: 100px; height: 100px; object-fit: cover; border-radius: 4px; margin: 2px; }
1635
+ a { color: #1a73e8; text-decoration: none; }
1636
+ a:hover { text-decoration: underline; }
1637
+ </style>
1638
+ </head>
1639
+ <body>
1640
+ <h1>Saved Photos from Merged Contacts (${savedPhotos.length})</h1>
1641
+ <table>
1642
+ <tr><th>Photos</th><th>Name</th></tr>
1643
+ ${savedPhotos.map(entry => {
1644
+ const id = entry.contactId.replace('people/', '');
1645
+ const contactUrl = `https://contacts.google.com/person/${id}`;
1646
+ const photoImgs = entry.photos.map(url => `<img src="${url}" alt="${entry.name}" loading="lazy">`).join('');
1647
+ return ` <tr>
1648
+ <td>${photoImgs || '(no photo)'}</td>
1649
+ <td><a href="${contactUrl}" target="_blank">${entry.name}</a></td>
1650
+ </tr>`;
1651
+ }).join('\n')}
1652
+ </table>
1653
+ </body>
1654
+ </html>`;
1655
+ fs.writeFileSync(htmlPath, html);
1656
+ console.log(`\nSaved ${savedPhotos.length} photo entries to ${htmlPath}`);
1570
1657
  }
1571
1658
 
1572
1659
  // Save merged.json (history of processed entries)
@@ -1579,19 +1666,233 @@ async function runMerge(user: string, limit: number): Promise<void> {
1579
1666
  fs.writeFileSync(paths.indexFile, JSON.stringify(index, null, 2));
1580
1667
 
1581
1668
  // Remove processed entries from merger.json
1582
- const remainingEntries = JSON.parse(fs.readFileSync(mergerPath, 'utf-8')) as MergeEntry[];
1669
+ const remainingEntries = parseJsonc(fs.readFileSync(mergerPath, 'utf-8')) as MergeEntry[];
1583
1670
  const processedNames = new Set(entries.map(e => e.name));
1584
1671
  const newMerger = remainingEntries.filter(e => !processedNames.has(e.name));
1585
1672
  fs.writeFileSync(mergerPath, JSON.stringify(newMerger, null, 2));
1586
1673
 
1587
1674
  console.log(`\n${'='.repeat(50)}`);
1588
1675
  console.log(`Summary:`);
1589
- console.log(` Merged: ${mergeSuccess}`);
1676
+ console.log(` Merged: ${mergeSuccess}${samePhotoSkipped > 0 ? ` (${samePhotoSkipped} with same photo)` : ''}`);
1590
1677
  console.log(` Marked for deletion: ${deleteSuccess}`);
1591
1678
  console.log(` Remaining in merger.json: ${newMerger.length}`);
1592
1679
  console.log(`\nRun 'gcards push -u ${user}' to apply changes to Google.`);
1593
1680
  }
1594
1681
 
1682
+ // ============================================================
1683
+ // Photo Comparison Utilities
1684
+ // ============================================================
1685
+
1686
+ /** Fetch image and compute a simple hash for comparison */
1687
+ async function fetchPhotoHash(url: string): Promise<string> {
1688
+ try {
1689
+ const response = await fetch(url);
1690
+ if (!response.ok) return '';
1691
+ const buffer = await response.arrayBuffer();
1692
+ // Use a simple hash of the first 1KB + size for quick comparison
1693
+ const bytes = new Uint8Array(buffer);
1694
+ let hash = bytes.length;
1695
+ const sampleSize = Math.min(1024, bytes.length);
1696
+ for (let i = 0; i < sampleSize; i++) {
1697
+ hash = ((hash << 5) - hash + bytes[i]) | 0;
1698
+ }
1699
+ return `${bytes.length}:${hash.toString(16)}`;
1700
+ } catch {
1701
+ return '';
1702
+ }
1703
+ }
1704
+
1705
+ /** Generate photos.html with contact photos for review */
1706
+ async function runPhotos(user: string): Promise<void> {
1707
+ const paths = getUserPaths(user);
1708
+
1709
+ if (!fs.existsSync(paths.contactsDir)) {
1710
+ console.error(`No contacts directory for user: ${user}`);
1711
+ process.exit(1);
1712
+ }
1713
+
1714
+ const files = fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'));
1715
+ console.log(`\nScanning ${files.length} contacts for photos...\n`);
1716
+
1717
+ interface PhotoContact {
1718
+ id: string;
1719
+ displayName: string;
1720
+ photoUrl: string;
1721
+ resourceName: string;
1722
+ }
1723
+
1724
+ const contacts: PhotoContact[] = [];
1725
+
1726
+ for (const file of files) {
1727
+ const filePath = path.join(paths.contactsDir, file);
1728
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1729
+
1730
+ const photoUrl = getPhotoUrl(contact);
1731
+ if (!photoUrl) continue;
1732
+
1733
+ const displayName = contact.names?.[0]?.displayName ||
1734
+ contact.names?.[0]?.givenName ||
1735
+ 'Unknown';
1736
+ const id = contact.resourceName.replace('people/', '');
1737
+
1738
+ contacts.push({
1739
+ id,
1740
+ displayName,
1741
+ photoUrl,
1742
+ resourceName: contact.resourceName
1743
+ });
1744
+ }
1745
+
1746
+ // Sort by name
1747
+ contacts.sort((a, b) => a.displayName.localeCompare(b.displayName));
1748
+
1749
+ console.log(`Found ${contacts.length} contacts with photos`);
1750
+
1751
+ // Generate HTML
1752
+ const htmlPath = path.join(paths.userDir, 'photos.html');
1753
+ const html = `<!DOCTYPE html>
1754
+ <html>
1755
+ <head>
1756
+ <meta charset="UTF-8">
1757
+ <title>Contacts with Photos - ${user}</title>
1758
+ <style>
1759
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; }
1760
+ h1 { color: #333; }
1761
+ table { border-collapse: collapse; width: 100%; }
1762
+ th, td { border: 1px solid #ddd; padding: 8px; text-align: left; vertical-align: middle; }
1763
+ th { background: #f5f5f5; }
1764
+ tr:hover { background: #f9f9f9; }
1765
+ img { width: 100px; height: 100px; object-fit: cover; border-radius: 4px; }
1766
+ a { color: #1a73e8; text-decoration: none; }
1767
+ a:hover { text-decoration: underline; }
1768
+ .id { font-size: 12px; color: #666; }
1769
+ </style>
1770
+ </head>
1771
+ <body>
1772
+ <h1>Contacts with Photos (${contacts.length})</h1>
1773
+ <table>
1774
+ <tr><th>Photo</th><th>Name</th></tr>
1775
+ ${contacts.map(c => {
1776
+ const contactUrl = `https://contacts.google.com/person/${c.id}`;
1777
+ return ` <tr>
1778
+ <td><img src="${c.photoUrl}" alt="${c.displayName}" loading="lazy"></td>
1779
+ <td><a href="${contactUrl}" target="_blank">${c.displayName}</a><br><span class="id">${c.id}</span></td>
1780
+ </tr>`;
1781
+ }).join('\n')}
1782
+ </table>
1783
+ </body>
1784
+ </html>`;
1785
+
1786
+ fs.writeFileSync(htmlPath, html);
1787
+ console.log(`\nGenerated: ${htmlPath}`);
1788
+ console.log(`Open in browser to review contacts with photos.`);
1789
+ }
1790
+
1791
+ /** Find contacts with same photo (by downloading and hashing) */
1792
+ async function runPhotoCompare(user: string): Promise<void> {
1793
+ const paths = getUserPaths(user);
1794
+
1795
+ if (!fs.existsSync(paths.contactsDir)) {
1796
+ console.error(`No contacts directory for user: ${user}`);
1797
+ process.exit(1);
1798
+ }
1799
+
1800
+ const files = fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'));
1801
+ console.log(`\nScanning ${files.length} contacts for photo duplicates...\n`);
1802
+
1803
+ interface PhotoInfo {
1804
+ id: string;
1805
+ displayName: string;
1806
+ photoUrl: string;
1807
+ hash: string;
1808
+ }
1809
+
1810
+ const photoInfos: PhotoInfo[] = [];
1811
+
1812
+ // First collect all contacts with photos
1813
+ for (const file of files) {
1814
+ const filePath = path.join(paths.contactsDir, file);
1815
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1816
+
1817
+ const photoUrl = getPhotoUrl(contact);
1818
+ if (!photoUrl) continue;
1819
+
1820
+ const displayName = contact.names?.[0]?.displayName || 'Unknown';
1821
+ const id = contact.resourceName.replace('people/', '');
1822
+
1823
+ photoInfos.push({ id, displayName, photoUrl, hash: '' });
1824
+ }
1825
+
1826
+ console.log(`Found ${photoInfos.length} contacts with photos`);
1827
+
1828
+ // Check for URL duplicates first (fast)
1829
+ const byUrl = new Map<string, PhotoInfo[]>();
1830
+ for (const info of photoInfos) {
1831
+ const existing = byUrl.get(info.photoUrl) || [];
1832
+ existing.push(info);
1833
+ byUrl.set(info.photoUrl, existing);
1834
+ }
1835
+
1836
+ const urlDuplicates = [...byUrl.entries()].filter(([, infos]) => infos.length > 1);
1837
+ if (urlDuplicates.length > 0) {
1838
+ console.log(`\nSame URL duplicates (${urlDuplicates.length} groups):`);
1839
+ for (const [url, infos] of urlDuplicates) {
1840
+ console.log(` ${infos.map(i => i.displayName).join(', ')}`);
1841
+ }
1842
+ }
1843
+
1844
+ // Now fetch and hash unique URLs for pixel comparison
1845
+ console.log(`\nFetching ${byUrl.size} unique photo URLs for hash comparison...`);
1846
+ const hashByUrl = new Map<string, string>();
1847
+ let fetched = 0;
1848
+
1849
+ for (const url of byUrl.keys()) {
1850
+ const hash = await fetchPhotoHash(url);
1851
+ hashByUrl.set(url, hash);
1852
+ fetched++;
1853
+ if (fetched % 10 === 0) {
1854
+ process.stdout.write(`\r Fetched ${fetched}/${byUrl.size}...`);
1855
+ }
1856
+ }
1857
+ console.log(`\r Fetched ${fetched}/${byUrl.size} - done`);
1858
+
1859
+ // Assign hashes
1860
+ for (const info of photoInfos) {
1861
+ info.hash = hashByUrl.get(info.photoUrl) || '';
1862
+ }
1863
+
1864
+ // Group by hash (different URLs, same content)
1865
+ const byHash = new Map<string, PhotoInfo[]>();
1866
+ for (const info of photoInfos) {
1867
+ if (!info.hash) continue;
1868
+ const existing = byHash.get(info.hash) || [];
1869
+ existing.push(info);
1870
+ byHash.set(info.hash, existing);
1871
+ }
1872
+
1873
+ // Find hash groups that span multiple URLs (actual pixel duplicates)
1874
+ const hashDuplicates: { hash: string; infos: PhotoInfo[] }[] = [];
1875
+ for (const [hash, infos] of byHash) {
1876
+ const uniqueUrls = new Set(infos.map(i => i.photoUrl));
1877
+ if (uniqueUrls.size > 1) {
1878
+ hashDuplicates.push({ hash, infos });
1879
+ }
1880
+ }
1881
+
1882
+ if (hashDuplicates.length > 0) {
1883
+ console.log(`\nSame content (different URLs) duplicates (${hashDuplicates.length} groups):`);
1884
+ for (const { infos } of hashDuplicates) {
1885
+ console.log(` ${infos.map(i => i.displayName).join(', ')}`);
1886
+ }
1887
+ }
1888
+
1889
+ console.log(`\n${'='.repeat(50)}`);
1890
+ console.log(`Summary:`);
1891
+ console.log(` Total contacts with photos: ${photoInfos.length}`);
1892
+ console.log(` Same URL duplicates: ${urlDuplicates.reduce((sum, [, infos]) => sum + infos.length - 1, 0)}`);
1893
+ console.log(` Same content duplicates: ${hashDuplicates.reduce((sum, { infos }) => sum + infos.length - 1, 0)}`);
1894
+ }
1895
+
1595
1896
  // ============================================================
1596
1897
  // Export Feature - Transfer contacts between users
1597
1898
  // ============================================================
@@ -1612,7 +1913,7 @@ function findContactById(paths: UserPaths, id: string): { contact: GooglePerson;
1612
1913
  const contactPath = path.join(paths.contactsDir, `${cleanId}.json`);
1613
1914
  if (fs.existsSync(contactPath)) {
1614
1915
  return {
1615
- contact: JSON.parse(fs.readFileSync(contactPath, 'utf-8')),
1916
+ contact: parseJsonc(fs.readFileSync(contactPath, 'utf-8')),
1616
1917
  filePath: contactPath,
1617
1918
  source: 'contacts'
1618
1919
  };
@@ -1622,7 +1923,7 @@ function findContactById(paths: UserPaths, id: string): { contact: GooglePerson;
1622
1923
  const deletedPath = path.join(paths.deletedDir, `${cleanId}.json`);
1623
1924
  if (fs.existsSync(deletedPath)) {
1624
1925
  return {
1625
- contact: JSON.parse(fs.readFileSync(deletedPath, 'utf-8')),
1926
+ contact: parseJsonc(fs.readFileSync(deletedPath, 'utf-8')),
1626
1927
  filePath: deletedPath,
1627
1928
  source: 'deleted'
1628
1929
  };
@@ -1651,7 +1952,7 @@ function searchContacts(paths: UserPaths, pattern: string): ContactMatch[] {
1651
1952
  if (fs.existsSync(paths.contactsDir)) {
1652
1953
  for (const file of fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'))) {
1653
1954
  const filePath = path.join(paths.contactsDir, file);
1654
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1955
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1655
1956
  if (matchesContact(contact, regex)) {
1656
1957
  matches.push({
1657
1958
  id: file.replace(/\.json$/, ''),
@@ -1668,7 +1969,7 @@ function searchContacts(paths: UserPaths, pattern: string): ContactMatch[] {
1668
1969
  if (fs.existsSync(paths.deletedDir)) {
1669
1970
  for (const file of fs.readdirSync(paths.deletedDir).filter(f => f.endsWith('.json'))) {
1670
1971
  const filePath = path.join(paths.deletedDir, file);
1671
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1972
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1672
1973
  if (matchesContact(contact, regex)) {
1673
1974
  matches.push({
1674
1975
  id: file.replace(/\.json$/, ''),
@@ -1912,7 +2213,7 @@ async function runReset(user: string): Promise<void> {
1912
2213
 
1913
2214
  // Clear from index.json
1914
2215
  if (fs.existsSync(paths.indexFile)) {
1915
- const index = JSON.parse(fs.readFileSync(paths.indexFile, 'utf-8'));
2216
+ const index = parseJsonc(fs.readFileSync(paths.indexFile, 'utf-8'));
1916
2217
  for (const entry of Object.values(index.contacts) as any[]) {
1917
2218
  if (entry._delete) {
1918
2219
  delete entry._delete;
@@ -1928,7 +2229,7 @@ async function runReset(user: string): Promise<void> {
1928
2229
  for (const file of files) {
1929
2230
  const filePath = path.join(paths.contactsDir, file);
1930
2231
  const stat = fs.statSync(filePath);
1931
- const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
2232
+ const content = parseJsonc(fs.readFileSync(filePath, 'utf-8'));
1932
2233
  if (content._delete || content._deleted) {
1933
2234
  delete content._delete;
1934
2235
  delete content._deleted;
@@ -1942,7 +2243,7 @@ async function runReset(user: string): Promise<void> {
1942
2243
 
1943
2244
  // Clear _delete.json queue
1944
2245
  if (fs.existsSync(paths.deleteQueueFile)) {
1945
- const queue = JSON.parse(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
2246
+ const queue = parseJsonc(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
1946
2247
  queueCleared = queue.entries?.length || 0;
1947
2248
  fs.unlinkSync(paths.deleteQueueFile);
1948
2249
  }
@@ -1981,7 +2282,9 @@ Commands:
1981
2282
  names Parse givenName into first/middle/last, clean fileAs, remove dup phones/emails
1982
2283
  problems Process edited problems.txt (apply fixes or deletions)
1983
2284
  undup Find duplicate contacts (same name + overlapping email) -> merger.json
1984
- merge Merge duplicates locally (then use gcards push)
2285
+ merge Merge duplicates locally (prefers contacts with photos)
2286
+ photos Generate photos.html with 100x100 photos and contact links
2287
+ photocompare Find contacts with same photo (by URL or content hash)
1985
2288
  reset Clear all _delete flags from index.json
1986
2289
  export Transfer contact(s) to another user's _add directory
1987
2290
 
@@ -2017,9 +2320,15 @@ Processing problem contacts (from gfix names):
2017
2320
  Duplicate detection and merge:
2018
2321
  - gfix undup -u bob # Find duplicates -> merger.json
2019
2322
  - Edit merger.json (remove false positives, add "_delete": true for spam)
2020
- - gfix merge -u bob # Merge locally (updates files, marks for deletion)
2323
+ - gfix merge -u bob # Merge locally (prefers contacts with photos)
2021
2324
  - gcards push -u bob # Push changes to Google
2022
2325
  - gcards sync -u bob --full # Resync to get updated contacts
2326
+ Note: merge prefers contacts with photos. If all have same photo URL, safe to merge.
2327
+
2328
+ Photo review and comparison:
2329
+ - gfix photos -u bob # Generate photos.html for visual review
2330
+ - gfix photocompare -u bob # Find duplicates by URL and content hash
2331
+ Output: photos.html with 100x100 photos, names, and links to Google Contacts
2023
2332
 
2024
2333
  Export contacts to another user:
2025
2334
  - gfix export -u bob -to alice "John*" # Export contacts matching "John*" to alice
@@ -2128,6 +2437,10 @@ async function main(): Promise<void> {
2128
2437
  await runUndup(resolvedUser);
2129
2438
  } else if (command === 'merge') {
2130
2439
  await runMerge(resolvedUser, processLimit);
2440
+ } else if (command === 'photos') {
2441
+ await runPhotos(resolvedUser);
2442
+ } else if (command === 'photocompare') {
2443
+ await runPhotoCompare(resolvedUser);
2131
2444
  } else if (command === 'reset') {
2132
2445
  await runReset(resolvedUser);
2133
2446
  } else if (command === 'problems') {
package/glib/gctypes.ts CHANGED
@@ -99,7 +99,8 @@ export interface UserPaths {
99
99
  indexFile: string;
100
100
  deletedFile: string; /** Deleted contacts index (deleted.json) */
101
101
  deleteQueueFile: string; /** Pending deletions (_delete.json) */
102
- photosFile: string; /** Photos from deleted contacts (photos.json) */
102
+ photosFile: string; /** Photos data store (_photos.json) */
103
+ photosHtmlFile: string; /** Photos HTML view (photos.html) */
103
104
  statusFile: string;
104
105
  syncTokenFile: string;
105
106
  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
@@ -64,7 +64,8 @@ export function getUserPaths(user: string): UserPaths {
64
64
  indexFile: path.join(userDir, 'index.json'),
65
65
  deletedFile: path.join(userDir, 'deleted.json'),
66
66
  deleteQueueFile: path.join(userDir, '_delete.json'),
67
- photosFile: path.join(userDir, 'photos.json'),
67
+ photosFile: path.join(userDir, '_photos.json'),
68
+ photosHtmlFile: path.join(userDir, 'photos.html'),
68
69
  statusFile: path.join(userDir, 'status.json'),
69
70
  syncTokenFile: path.join(userDir, 'sync-token.json'),
70
71
  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.20",
3
+ "version": "0.1.22",
4
4
  "description": "Google Contacts cleanup and management tool",
5
5
  "type": "module",
6
6
  "main": "gcards.ts",