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