@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 +106 -22
- package/gfix.ts +70 -24
- 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;
|
|
@@ -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 =
|
|
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[] =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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'),
|