@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 +106 -22
- package/gfix.ts +340 -27
- package/glib/gctypes.ts +2 -1
- package/glib/gmerge.ts +2 -2
- package/glib/gutils.ts +2 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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 =
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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) ?
|
|
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) ?
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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[] =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
1491
|
-
const
|
|
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 =
|
|
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
|
|
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 =
|
|
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:
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
265
|
-
if (photo.url && photo.default
|
|
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, '
|
|
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'),
|