@bobfrankston/gcards 0.1.21 → 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;
@@ -1438,14 +1444,14 @@ async function runMerge(user: string, limit: number): Promise<void> {
1438
1444
  const paths = getUserPaths(user);
1439
1445
  const mergerPath = path.join(paths.userDir, 'merger.json');
1440
1446
  const mergedPath = path.join(paths.userDir, 'merged.json');
1441
- const photosPath = path.join(paths.userDir, 'photos.json');
1447
+ const photosPath = paths.photosFile; // _photos.json
1442
1448
 
1443
1449
  if (!fs.existsSync(mergerPath)) {
1444
1450
  console.error(`No merger.json found. Run 'gfix undup -u ${user}' first.`);
1445
1451
  process.exit(1);
1446
1452
  }
1447
1453
 
1448
- let entries: MergeEntry[] = JSON.parse(fs.readFileSync(mergerPath, 'utf-8'));
1454
+ let entries: MergeEntry[] = parseJsonc(fs.readFileSync(mergerPath, 'utf-8'));
1449
1455
 
1450
1456
  if (entries.length === 0) {
1451
1457
  console.log('No entries in merger.json');
@@ -1478,7 +1484,8 @@ async function runMerge(user: string, limit: number): Promise<void> {
1478
1484
  // Load existing files
1479
1485
  let savedPhotos: PhotoEntry[] = [];
1480
1486
  if (fs.existsSync(photosPath)) {
1481
- savedPhotos = JSON.parse(fs.readFileSync(photosPath, 'utf-8'));
1487
+ const parsed = parseJsonc(fs.readFileSync(photosPath, 'utf-8'));
1488
+ savedPhotos = Array.isArray(parsed) ? parsed : [];
1482
1489
  }
1483
1490
 
1484
1491
  const deleteQueue = loadDeleteQueue(paths);
@@ -1489,7 +1496,8 @@ async function runMerge(user: string, limit: number): Promise<void> {
1489
1496
  const content = fs.readFileSync(mergedPath, 'utf-8').trim();
1490
1497
  if (content) {
1491
1498
  try {
1492
- mergedEntries = JSON.parse(content);
1499
+ const parsed = parseJsonc(content);
1500
+ mergedEntries = Array.isArray(parsed) ? parsed : [];
1493
1501
  } catch { /* ignore parse errors, start fresh */ }
1494
1502
  }
1495
1503
  }
@@ -1509,7 +1517,7 @@ async function runMerge(user: string, limit: number): Promise<void> {
1509
1517
  const id = rn.replace('people/', '');
1510
1518
  const filePath = path.join(paths.contactsDir, `${id}.json`);
1511
1519
  if (fs.existsSync(filePath)) {
1512
- contacts.push(JSON.parse(fs.readFileSync(filePath, 'utf-8')));
1520
+ contacts.push(parseJsonc(fs.readFileSync(filePath, 'utf-8')));
1513
1521
  } else {
1514
1522
  throw new Error(`Contact file not found: ${filePath}`);
1515
1523
  }
@@ -1586,7 +1594,7 @@ async function runMerge(user: string, limit: number): Promise<void> {
1586
1594
  const id = rn.replace('people/', '');
1587
1595
  const filePath = path.join(paths.contactsDir, `${id}.json`);
1588
1596
  if (fs.existsSync(filePath)) {
1589
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1597
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1590
1598
  const displayName = contact.names?.[0]?.displayName || entry.name;
1591
1599
  deleteQueue.entries.push({
1592
1600
  resourceName: rn,
@@ -1605,9 +1613,47 @@ async function runMerge(user: string, limit: number): Promise<void> {
1605
1613
  mergedEntries.push(entry);
1606
1614
  }
1607
1615
 
1608
- // Save photos.json
1616
+ // Save photos (JSON data + HTML view)
1609
1617
  if (savedPhotos.length > 0) {
1610
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}`);
1611
1657
  }
1612
1658
 
1613
1659
  // Save merged.json (history of processed entries)
@@ -1620,7 +1666,7 @@ async function runMerge(user: string, limit: number): Promise<void> {
1620
1666
  fs.writeFileSync(paths.indexFile, JSON.stringify(index, null, 2));
1621
1667
 
1622
1668
  // Remove processed entries from merger.json
1623
- const remainingEntries = JSON.parse(fs.readFileSync(mergerPath, 'utf-8')) as MergeEntry[];
1669
+ const remainingEntries = parseJsonc(fs.readFileSync(mergerPath, 'utf-8')) as MergeEntry[];
1624
1670
  const processedNames = new Set(entries.map(e => e.name));
1625
1671
  const newMerger = remainingEntries.filter(e => !processedNames.has(e.name));
1626
1672
  fs.writeFileSync(mergerPath, JSON.stringify(newMerger, null, 2));
@@ -1679,7 +1725,7 @@ async function runPhotos(user: string): Promise<void> {
1679
1725
 
1680
1726
  for (const file of files) {
1681
1727
  const filePath = path.join(paths.contactsDir, file);
1682
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1728
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1683
1729
 
1684
1730
  const photoUrl = getPhotoUrl(contact);
1685
1731
  if (!photoUrl) continue;
@@ -1766,7 +1812,7 @@ async function runPhotoCompare(user: string): Promise<void> {
1766
1812
  // First collect all contacts with photos
1767
1813
  for (const file of files) {
1768
1814
  const filePath = path.join(paths.contactsDir, file);
1769
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1815
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1770
1816
 
1771
1817
  const photoUrl = getPhotoUrl(contact);
1772
1818
  if (!photoUrl) continue;
@@ -1867,7 +1913,7 @@ function findContactById(paths: UserPaths, id: string): { contact: GooglePerson;
1867
1913
  const contactPath = path.join(paths.contactsDir, `${cleanId}.json`);
1868
1914
  if (fs.existsSync(contactPath)) {
1869
1915
  return {
1870
- contact: JSON.parse(fs.readFileSync(contactPath, 'utf-8')),
1916
+ contact: parseJsonc(fs.readFileSync(contactPath, 'utf-8')),
1871
1917
  filePath: contactPath,
1872
1918
  source: 'contacts'
1873
1919
  };
@@ -1877,7 +1923,7 @@ function findContactById(paths: UserPaths, id: string): { contact: GooglePerson;
1877
1923
  const deletedPath = path.join(paths.deletedDir, `${cleanId}.json`);
1878
1924
  if (fs.existsSync(deletedPath)) {
1879
1925
  return {
1880
- contact: JSON.parse(fs.readFileSync(deletedPath, 'utf-8')),
1926
+ contact: parseJsonc(fs.readFileSync(deletedPath, 'utf-8')),
1881
1927
  filePath: deletedPath,
1882
1928
  source: 'deleted'
1883
1929
  };
@@ -1906,7 +1952,7 @@ function searchContacts(paths: UserPaths, pattern: string): ContactMatch[] {
1906
1952
  if (fs.existsSync(paths.contactsDir)) {
1907
1953
  for (const file of fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'))) {
1908
1954
  const filePath = path.join(paths.contactsDir, file);
1909
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1955
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1910
1956
  if (matchesContact(contact, regex)) {
1911
1957
  matches.push({
1912
1958
  id: file.replace(/\.json$/, ''),
@@ -1923,7 +1969,7 @@ function searchContacts(paths: UserPaths, pattern: string): ContactMatch[] {
1923
1969
  if (fs.existsSync(paths.deletedDir)) {
1924
1970
  for (const file of fs.readdirSync(paths.deletedDir).filter(f => f.endsWith('.json'))) {
1925
1971
  const filePath = path.join(paths.deletedDir, file);
1926
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1972
+ const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1927
1973
  if (matchesContact(contact, regex)) {
1928
1974
  matches.push({
1929
1975
  id: file.replace(/\.json$/, ''),
@@ -2167,7 +2213,7 @@ async function runReset(user: string): Promise<void> {
2167
2213
 
2168
2214
  // Clear from index.json
2169
2215
  if (fs.existsSync(paths.indexFile)) {
2170
- const index = JSON.parse(fs.readFileSync(paths.indexFile, 'utf-8'));
2216
+ const index = parseJsonc(fs.readFileSync(paths.indexFile, 'utf-8'));
2171
2217
  for (const entry of Object.values(index.contacts) as any[]) {
2172
2218
  if (entry._delete) {
2173
2219
  delete entry._delete;
@@ -2183,7 +2229,7 @@ async function runReset(user: string): Promise<void> {
2183
2229
  for (const file of files) {
2184
2230
  const filePath = path.join(paths.contactsDir, file);
2185
2231
  const stat = fs.statSync(filePath);
2186
- const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
2232
+ const content = parseJsonc(fs.readFileSync(filePath, 'utf-8'));
2187
2233
  if (content._delete || content._deleted) {
2188
2234
  delete content._delete;
2189
2235
  delete content._deleted;
@@ -2197,7 +2243,7 @@ async function runReset(user: string): Promise<void> {
2197
2243
 
2198
2244
  // Clear _delete.json queue
2199
2245
  if (fs.existsSync(paths.deleteQueueFile)) {
2200
- const queue = JSON.parse(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
2246
+ const queue = parseJsonc(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
2201
2247
  queueCleared = queue.entries?.length || 0;
2202
2248
  fs.unlinkSync(paths.deleteQueueFile);
2203
2249
  }
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.21",
3
+ "version": "0.1.22",
4
4
  "description": "Google Contacts cleanup and management tool",
5
5
  "type": "module",
6
6
  "main": "gcards.ts",