@bobfrankston/gcards 0.1.4 → 0.1.5
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/.claude/settings.local.json +3 -1
- package/README.md +19 -5
- package/gcards.ts +283 -61
- package/gfix.ts +124 -11
- package/glib/gctypes.ts +28 -6
- package/glib/gutils.ts +2 -0
- package/glib/parsecli.ts +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -119,18 +119,19 @@ gcards/ # App directory (%APPDATA%\gcards or ~/.config/gcards)
|
|
|
119
119
|
data/ # User data directory
|
|
120
120
|
<username>/
|
|
121
121
|
contacts/ # Active contacts (*.json)
|
|
122
|
+
deleted/ # Backup of deleted contact files
|
|
122
123
|
_delete/ # User requests to delete (move files here)
|
|
123
124
|
_add/ # User requests to add new contacts
|
|
124
125
|
fix-logs/ # Logs from gfix operations
|
|
125
|
-
index.json # Active contact index
|
|
126
|
-
deleted.json # Deleted contacts index
|
|
126
|
+
index.json # Active contact index (hasPhoto, starred flags)
|
|
127
|
+
deleted.json # Deleted contacts index
|
|
128
|
+
notdeleted.json # Contacts skipped due to photo/starred
|
|
127
129
|
token.json # OAuth token (read-only)
|
|
128
130
|
token-write.json # OAuth token (read-write)
|
|
129
131
|
changes.log # Log from gfix names
|
|
130
132
|
birthdays.csv # Extracted birthdays
|
|
131
133
|
merger.json # Duplicates to merge (from gfix undup)
|
|
132
134
|
merged.json # Processed merges (history)
|
|
133
|
-
photos.json # Photos from deleted contacts
|
|
134
135
|
```
|
|
135
136
|
|
|
136
137
|
## Fixes Applied by `gfix inspect/apply`
|
|
@@ -158,13 +159,26 @@ When a contact has a full name in `givenName` but no `familyName`:
|
|
|
158
159
|
|
|
159
160
|
## Deleting Contacts
|
|
160
161
|
|
|
161
|
-
Three ways to
|
|
162
|
+
Three ways to mark contacts for deletion:
|
|
162
163
|
|
|
163
164
|
1. **In index.json**: Add `"_delete": true` to an entry
|
|
164
165
|
2. **In contact JSON**: Add `"_delete": true` to the contact file
|
|
165
166
|
3. **Move to _delete/**: Move the contact file to the `_delete/` folder
|
|
166
167
|
|
|
167
|
-
Then run `gcards push` to apply deletions.
|
|
168
|
+
Then run `gcards push` to apply deletions.
|
|
169
|
+
|
|
170
|
+
### Photo/Starred Protection
|
|
171
|
+
|
|
172
|
+
Contacts with photos or starred status are **not deleted** from Google to prevent data loss:
|
|
173
|
+
- Contacts with non-default photos: `_delete` set to `"photo"`
|
|
174
|
+
- Starred/favorite contacts: `_delete` set to `"starred"`
|
|
175
|
+
|
|
176
|
+
These remain in Google and `index.json` with the skip reason. Review `notdeleted.json` after push.
|
|
177
|
+
|
|
178
|
+
### Backup
|
|
179
|
+
|
|
180
|
+
All deleted contact files are moved to `deleted/` folder as backup (not permanently deleted).
|
|
181
|
+
Index entries move to `deleted.json`.
|
|
168
182
|
|
|
169
183
|
## Duplicate Contact Merging
|
|
170
184
|
|
package/gcards.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { parseArgs, showUsage, showHelp } from './glib/parsecli.ts';
|
|
|
12
12
|
import { authenticateOAuth } from '../../../projects/oauth/oauthsupport/index.ts';
|
|
13
13
|
import type { GooglePerson, GoogleConnectionsResponse } from './glib/types.ts';
|
|
14
14
|
import { GCARDS_GUID_KEY, extractGuids } from './glib/gctypes.ts';
|
|
15
|
-
import type { ContactIndex, IndexEntry, PushStatus, PendingChange, UserPaths } from './glib/gctypes.ts';
|
|
15
|
+
import type { ContactIndex, IndexEntry, DeletedEntry, DeleteQueue, DeleteQueueEntry, PushStatus, PendingChange, UserPaths } from './glib/gctypes.ts';
|
|
16
16
|
import { DATA_DIR, CREDENTIALS_FILE, loadConfig, saveConfig, getUserPaths, ensureUserDir, loadIndex, normalizeUser, getAllUsers, resolveUser, FileLogger } from './glib/gutils.ts';
|
|
17
17
|
|
|
18
18
|
const PEOPLE_API_BASE = 'https://people.googleapis.com/v1';
|
|
@@ -112,7 +112,7 @@ function saveIndex(paths: UserPaths, index: ContactIndex): void {
|
|
|
112
112
|
|
|
113
113
|
/** Deleted contacts index (separate from active index) */
|
|
114
114
|
interface DeletedIndex {
|
|
115
|
-
deleted: Record<string,
|
|
115
|
+
deleted: Record<string, DeletedEntry>;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
function loadDeleted(paths: UserPaths): DeletedIndex {
|
|
@@ -124,7 +124,7 @@ function loadDeleted(paths: UserPaths): DeletedIndex {
|
|
|
124
124
|
|
|
125
125
|
function saveDeleted(paths: UserPaths, deletedIndex: DeletedIndex): void {
|
|
126
126
|
// Sort by displayName
|
|
127
|
-
const sorted: Record<string,
|
|
127
|
+
const sorted: Record<string, DeletedEntry> = {};
|
|
128
128
|
const entries = Object.entries(deletedIndex.deleted);
|
|
129
129
|
entries.sort((a, b) => a[1].displayName.localeCompare(b[1].displayName));
|
|
130
130
|
for (const [key, value] of entries) {
|
|
@@ -134,6 +134,72 @@ function saveDeleted(paths: UserPaths, deletedIndex: DeletedIndex): void {
|
|
|
134
134
|
fs.writeFileSync(paths.deletedFile, JSON.stringify(deletedIndex, null, 2));
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
interface SavedPhoto {
|
|
138
|
+
url: string;
|
|
139
|
+
sourceType: 'CONTACT' | 'PROFILE';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface PhotoEntry {
|
|
143
|
+
displayName: string;
|
|
144
|
+
photos: SavedPhoto[];
|
|
145
|
+
deletedAt: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface PhotosIndex {
|
|
149
|
+
photos: Record<string, PhotoEntry>; // resourceName -> PhotoEntry
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function loadPhotos(paths: UserPaths): PhotosIndex {
|
|
153
|
+
if (fs.existsSync(paths.photosFile)) {
|
|
154
|
+
return JSON.parse(fs.readFileSync(paths.photosFile, 'utf-8'));
|
|
155
|
+
}
|
|
156
|
+
return { photos: {} };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function savePhotos(paths: UserPaths, photosIndex: PhotosIndex): void {
|
|
160
|
+
// Sort by displayName
|
|
161
|
+
const sorted: Record<string, PhotoEntry> = {};
|
|
162
|
+
const entries = Object.entries(photosIndex.photos);
|
|
163
|
+
entries.sort((a, b) => a[1].displayName.localeCompare(b[1].displayName));
|
|
164
|
+
for (const [key, value] of entries) {
|
|
165
|
+
sorted[key] = value;
|
|
166
|
+
}
|
|
167
|
+
photosIndex.photos = sorted;
|
|
168
|
+
fs.writeFileSync(paths.photosFile, JSON.stringify(photosIndex, null, 2));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function loadDeleteQueue(paths: UserPaths): DeleteQueue {
|
|
172
|
+
if (fs.existsSync(paths.deleteQueueFile)) {
|
|
173
|
+
return JSON.parse(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
|
|
174
|
+
}
|
|
175
|
+
return { updatedAt: '', entries: [] };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function saveDeleteQueue(paths: UserPaths, queue: DeleteQueue): void {
|
|
179
|
+
queue.updatedAt = new Date().toISOString();
|
|
180
|
+
fs.writeFileSync(paths.deleteQueueFile, JSON.stringify(queue, null, 2));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function extractNonDefaultPhotos(contact: GooglePerson): SavedPhoto[] {
|
|
184
|
+
if (!contact.photos) return [];
|
|
185
|
+
return contact.photos
|
|
186
|
+
.filter(p => !p.default)
|
|
187
|
+
.map(p => ({
|
|
188
|
+
url: p.url,
|
|
189
|
+
sourceType: (p.metadata?.source?.type || 'CONTACT') as 'CONTACT' | 'PROFILE'
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function hasNonDefaultPhoto(contact: GooglePerson): boolean {
|
|
194
|
+
return contact.photos?.some(p => !p.default) || false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isStarred(contact: GooglePerson): boolean {
|
|
198
|
+
return contact.memberships?.some(m =>
|
|
199
|
+
m.contactGroupMembership?.contactGroupId === 'starred'
|
|
200
|
+
) || false;
|
|
201
|
+
}
|
|
202
|
+
|
|
137
203
|
function generateGuid(): string {
|
|
138
204
|
return crypto.randomUUID();
|
|
139
205
|
}
|
|
@@ -232,20 +298,37 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
|
|
|
232
298
|
|
|
233
299
|
const index = loadIndex(paths);
|
|
234
300
|
const deletedIndex = loadDeleted(paths);
|
|
301
|
+
const photosIndex = loadPhotos(paths);
|
|
235
302
|
let syncToken = options.full ? null : loadSyncToken(paths);
|
|
236
303
|
|
|
237
|
-
// Migrate any deleted entries from index.json to deleted.json
|
|
304
|
+
// Migrate any old deleted entries from index.json to deleted.json
|
|
305
|
+
// Also clean up old fields (deleted: false, etag)
|
|
238
306
|
let migrated = 0;
|
|
307
|
+
let cleaned = 0;
|
|
239
308
|
for (const [rn, entry] of Object.entries(index.contacts)) {
|
|
240
|
-
|
|
241
|
-
|
|
309
|
+
const oldEntry = entry as any;
|
|
310
|
+
if (oldEntry.deleted === true) {
|
|
311
|
+
deletedIndex.deleted[rn] = { ...entry, deletedAt: oldEntry.deletedAt || new Date().toISOString() };
|
|
242
312
|
delete index.contacts[rn];
|
|
243
313
|
migrated++;
|
|
314
|
+
} else {
|
|
315
|
+
// Clean up old fields
|
|
316
|
+
if ('deleted' in oldEntry) {
|
|
317
|
+
delete oldEntry.deleted;
|
|
318
|
+
cleaned++;
|
|
319
|
+
}
|
|
320
|
+
if ('etag' in oldEntry) {
|
|
321
|
+
delete oldEntry.etag;
|
|
322
|
+
cleaned++;
|
|
323
|
+
}
|
|
244
324
|
}
|
|
245
325
|
}
|
|
246
326
|
if (migrated > 0) {
|
|
247
327
|
console.log(`Migrated ${migrated} deleted entries to deleted.json`);
|
|
248
328
|
}
|
|
329
|
+
if (cleaned > 0) {
|
|
330
|
+
console.log(`Cleaned ${cleaned} old fields from index entries`);
|
|
331
|
+
}
|
|
249
332
|
|
|
250
333
|
if (syncToken) {
|
|
251
334
|
console.log('Performing incremental sync...');
|
|
@@ -275,12 +358,42 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
|
|
|
275
358
|
if (isDeleted) {
|
|
276
359
|
const entry = index.contacts[person.resourceName];
|
|
277
360
|
if (entry) {
|
|
361
|
+
const id = person.resourceName.replace('people/', '');
|
|
362
|
+
const contactFile = path.join(paths.contactsDir, `${id}.json`);
|
|
363
|
+
|
|
364
|
+
// Save photos before deleting
|
|
365
|
+
if (fs.existsSync(contactFile)) {
|
|
366
|
+
try {
|
|
367
|
+
const contact = JSON.parse(fs.readFileSync(contactFile, 'utf-8')) as GooglePerson;
|
|
368
|
+
const photos = extractNonDefaultPhotos(contact);
|
|
369
|
+
if (photos.length > 0) {
|
|
370
|
+
photosIndex.photos[person.resourceName] = {
|
|
371
|
+
displayName: entry.displayName,
|
|
372
|
+
photos,
|
|
373
|
+
deletedAt: new Date().toISOString()
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
} catch { /* ignore read errors */ }
|
|
377
|
+
}
|
|
378
|
+
|
|
278
379
|
// Move to deleted.json
|
|
279
|
-
entry.
|
|
280
|
-
|
|
281
|
-
deletedIndex.deleted[person.resourceName] = entry;
|
|
380
|
+
const deletedEntry = { ...entry, deletedAt: new Date().toISOString(), _delete: 'server' };
|
|
381
|
+
deletedIndex.deleted[person.resourceName] = deletedEntry;
|
|
282
382
|
delete index.contacts[person.resourceName];
|
|
283
|
-
|
|
383
|
+
|
|
384
|
+
// Backup contact file to deleted/ folder
|
|
385
|
+
if (fs.existsSync(contactFile)) {
|
|
386
|
+
if (!fs.existsSync(paths.deletedDir)) {
|
|
387
|
+
fs.mkdirSync(paths.deletedDir, { recursive: true });
|
|
388
|
+
}
|
|
389
|
+
const contactData = JSON.parse(fs.readFileSync(contactFile, 'utf-8'));
|
|
390
|
+
contactData._deletedAt = new Date().toISOString();
|
|
391
|
+
contactData._delete = 'server';
|
|
392
|
+
const destPath = path.join(paths.deletedDir, `${id}.json`);
|
|
393
|
+
fs.writeFileSync(destPath, JSON.stringify(contactData, null, 2));
|
|
394
|
+
fs.unlinkSync(contactFile);
|
|
395
|
+
}
|
|
396
|
+
|
|
284
397
|
deleted++;
|
|
285
398
|
if (options.verbose) console.log(`\n Deleted: ${displayName}`);
|
|
286
399
|
}
|
|
@@ -307,14 +420,20 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
|
|
|
307
420
|
|
|
308
421
|
// Extract GUIDs from userDefined fields
|
|
309
422
|
const guids = extractGuids(person);
|
|
423
|
+
const hasPhoto = hasNonDefaultPhoto(person);
|
|
424
|
+
const starred = isStarred(person);
|
|
425
|
+
|
|
426
|
+
// Preserve _delete flag from existing entry
|
|
427
|
+
const existingDelete = index.contacts[person.resourceName]?._delete;
|
|
310
428
|
|
|
311
429
|
index.contacts[person.resourceName] = {
|
|
312
430
|
resourceName: person.resourceName,
|
|
313
431
|
displayName,
|
|
314
|
-
etag: person.etag,
|
|
315
|
-
deleted: false,
|
|
316
432
|
updatedAt: new Date().toISOString(),
|
|
317
|
-
|
|
433
|
+
...(hasPhoto && { hasPhoto }),
|
|
434
|
+
...(starred && { starred }),
|
|
435
|
+
...(guids.length > 0 && { guids }),
|
|
436
|
+
...(existingDelete && { _delete: existingDelete })
|
|
318
437
|
};
|
|
319
438
|
|
|
320
439
|
if (existed) {
|
|
@@ -340,6 +459,7 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
|
|
|
340
459
|
index.lastSync = new Date().toISOString();
|
|
341
460
|
saveIndex(paths, index);
|
|
342
461
|
saveDeleted(paths, deletedIndex);
|
|
462
|
+
savePhotos(paths, photosIndex);
|
|
343
463
|
|
|
344
464
|
// Small delay between pages to be nice to the API
|
|
345
465
|
if (pageToken) {
|
|
@@ -383,23 +503,27 @@ interface PendingChangesResult {
|
|
|
383
503
|
parseErrors: string[];
|
|
384
504
|
}
|
|
385
505
|
|
|
386
|
-
async function findPendingChanges(paths: UserPaths, logger: FileLogger): Promise<PendingChangesResult> {
|
|
506
|
+
async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?: string): Promise<PendingChangesResult> {
|
|
387
507
|
const changes: PendingChange[] = [];
|
|
388
508
|
const parseErrors: string[] = [];
|
|
389
509
|
const index = loadIndex(paths);
|
|
390
|
-
|
|
510
|
+
// Use -since override if provided, otherwise use lastSync
|
|
511
|
+
const sinceTime = since ? new Date(since).getTime() : (index.lastSync ? new Date(index.lastSync).getTime() : 0);
|
|
512
|
+
if (since) {
|
|
513
|
+
console.log(`Using -since override: ${since}`);
|
|
514
|
+
}
|
|
391
515
|
|
|
392
|
-
// Check
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
}
|
|
516
|
+
// Check _delete.json for deletion requests
|
|
517
|
+
const deleteQueue = loadDeleteQueue(paths);
|
|
518
|
+
for (const entry of deleteQueue.entries) {
|
|
519
|
+
const filePath = path.join(paths.contactsDir, `${entry.resourceName.replace('people/', '')}.json`);
|
|
520
|
+
changes.push({
|
|
521
|
+
type: 'delete',
|
|
522
|
+
resourceName: entry.resourceName,
|
|
523
|
+
displayName: entry.displayName,
|
|
524
|
+
filePath,
|
|
525
|
+
_delete: entry._delete || 'queued'
|
|
526
|
+
});
|
|
403
527
|
}
|
|
404
528
|
|
|
405
529
|
// Check contacts/ for modified files and _delete markers
|
|
@@ -413,8 +537,8 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger): Promise
|
|
|
413
537
|
const stat = await fp.stat(filePath);
|
|
414
538
|
const modTime = stat.mtimeMs;
|
|
415
539
|
|
|
416
|
-
// Skip files not modified since last sync
|
|
417
|
-
if (modTime <=
|
|
540
|
+
// Skip files not modified since last sync (or -since override)
|
|
541
|
+
if (modTime <= sinceTime + 1000) continue;
|
|
418
542
|
|
|
419
543
|
checked++;
|
|
420
544
|
let content: GooglePerson & { _delete?: boolean };
|
|
@@ -645,7 +769,7 @@ async function createContactOnGoogle(person: GooglePerson, retryCount = 0, token
|
|
|
645
769
|
return await response.json();
|
|
646
770
|
}
|
|
647
771
|
|
|
648
|
-
async function pushContacts(user: string, options: { yes: boolean; verbose: boolean; limit: number }): Promise<void> {
|
|
772
|
+
async function pushContacts(user: string, options: { yes: boolean; verbose: boolean; limit: number; since: string }): Promise<void> {
|
|
649
773
|
const paths = getUserPaths(user);
|
|
650
774
|
ensureUserDir(user);
|
|
651
775
|
|
|
@@ -654,7 +778,7 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
654
778
|
const logger = new FileLogger(problemsFile, true);
|
|
655
779
|
|
|
656
780
|
console.log(`Pushing contacts for user: ${user}`);
|
|
657
|
-
let { changes, parseErrors } = await findPendingChanges(paths, logger);
|
|
781
|
+
let { changes, parseErrors } = await findPendingChanges(paths, logger, options.since || undefined);
|
|
658
782
|
|
|
659
783
|
if (parseErrors.length > 0) {
|
|
660
784
|
console.log(`\n${parseErrors.length} files had parse errors (see pushproblems.txt)`);
|
|
@@ -665,9 +789,28 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
665
789
|
return;
|
|
666
790
|
}
|
|
667
791
|
|
|
668
|
-
const adds = changes.filter(c => c.type === 'add');
|
|
669
|
-
const updates = changes.filter(c => c.type === 'update');
|
|
670
|
-
const deletes = changes.filter(c => c.type === 'delete');
|
|
792
|
+
const adds = changes.filter(c => c.type === 'add').sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
793
|
+
const updates = changes.filter(c => c.type === 'update').sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
794
|
+
const deletes = changes.filter(c => c.type === 'delete').sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
795
|
+
|
|
796
|
+
// Write to toPush.txt for debugging
|
|
797
|
+
const toPushLines: string[] = [];
|
|
798
|
+
if (adds.length > 0) {
|
|
799
|
+
toPushLines.push(`New contacts (${adds.length}):`);
|
|
800
|
+
for (const c of adds) toPushLines.push(` ${c.displayName}`);
|
|
801
|
+
toPushLines.push('');
|
|
802
|
+
}
|
|
803
|
+
if (updates.length > 0) {
|
|
804
|
+
toPushLines.push(`Updates (${updates.length}):`);
|
|
805
|
+
for (const c of updates) toPushLines.push(` ${c.displayName} (${c.resourceName})`);
|
|
806
|
+
toPushLines.push('');
|
|
807
|
+
}
|
|
808
|
+
if (deletes.length > 0) {
|
|
809
|
+
toPushLines.push(`Deletions (${deletes.length}):`);
|
|
810
|
+
for (const c of deletes) toPushLines.push(` ${c.displayName} (${c.resourceName})`);
|
|
811
|
+
}
|
|
812
|
+
const toPushFile = path.join(paths.userDir, 'toPush.txt');
|
|
813
|
+
fs.writeFileSync(toPushFile, toPushLines.join('\n'));
|
|
671
814
|
|
|
672
815
|
console.log('\nPending changes:');
|
|
673
816
|
if (adds.length > 0) {
|
|
@@ -688,6 +831,10 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
688
831
|
console.log(` - ${c.displayName} (${c.resourceName})`);
|
|
689
832
|
}
|
|
690
833
|
}
|
|
834
|
+
console.log(`\n(List written to ${toPushFile})`);
|
|
835
|
+
|
|
836
|
+
// Reorder changes to process in sorted order
|
|
837
|
+
changes = [...adds, ...updates, ...deletes];
|
|
691
838
|
|
|
692
839
|
if (!options.yes) {
|
|
693
840
|
const confirmed = await confirm(`\nPush ${changes.length} change(s) to Google?`);
|
|
@@ -703,6 +850,8 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
703
850
|
|
|
704
851
|
const index = loadIndex(paths);
|
|
705
852
|
const deletedIndex = loadDeleted(paths);
|
|
853
|
+
const deleteQueue = loadDeleteQueue(paths);
|
|
854
|
+
const completedDeletions = new Set<string>(); // Track successful deletions
|
|
706
855
|
// Apply limit if specified
|
|
707
856
|
if (options.limit > 0 && changes.length > options.limit) {
|
|
708
857
|
console.log(`[DEBUG] Limiting to first ${options.limit} of ${changes.length} changes\n`);
|
|
@@ -711,9 +860,12 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
711
860
|
|
|
712
861
|
let successCount = 0;
|
|
713
862
|
let errorCount = 0;
|
|
863
|
+
let skippedPhoto = 0;
|
|
864
|
+
let skippedStarred = 0;
|
|
714
865
|
let processed = 0;
|
|
715
866
|
const total = changes.length;
|
|
716
867
|
const problems: string[] = [];
|
|
868
|
+
const notDeleted: { resourceName: string; displayName: string; filePath: string; reason: string }[] = [];
|
|
717
869
|
|
|
718
870
|
for (const change of changes) {
|
|
719
871
|
if (escapePressed) {
|
|
@@ -728,17 +880,19 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
728
880
|
process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Creating ${change.displayName}...`);
|
|
729
881
|
const created = await createContactOnGoogle(content);
|
|
730
882
|
|
|
731
|
-
// Extract GUIDs from
|
|
883
|
+
// Extract GUIDs and flags from created contact
|
|
732
884
|
const guids = extractGuids(created);
|
|
885
|
+
const hasPhoto = hasNonDefaultPhoto(created);
|
|
886
|
+
const starred = isStarred(created);
|
|
733
887
|
|
|
734
888
|
// Add to index
|
|
735
889
|
index.contacts[created.resourceName] = {
|
|
736
890
|
resourceName: created.resourceName,
|
|
737
891
|
displayName: change.displayName,
|
|
738
|
-
etag: created.etag,
|
|
739
|
-
deleted: false,
|
|
740
892
|
updatedAt: new Date().toISOString(),
|
|
741
|
-
|
|
893
|
+
...(hasPhoto && { hasPhoto }),
|
|
894
|
+
...(starred && { starred }),
|
|
895
|
+
...(guids.length > 0 && { guids })
|
|
742
896
|
};
|
|
743
897
|
|
|
744
898
|
// Save new contact to contacts/ folder
|
|
@@ -766,32 +920,85 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
766
920
|
console.log(' done');
|
|
767
921
|
successCount++;
|
|
768
922
|
} else if (change.type === 'delete') {
|
|
769
|
-
process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Deleting ${change.displayName}...`);
|
|
770
|
-
await deleteContactOnGoogle(change.resourceName);
|
|
771
|
-
|
|
772
|
-
// Move to deleted.json
|
|
773
|
-
const entry = index.contacts[change.resourceName];
|
|
774
|
-
if (entry) {
|
|
775
|
-
entry.deleted = true;
|
|
776
|
-
entry.deletedAt = new Date().toISOString();
|
|
777
|
-
delete entry._delete; // Clear the delete request
|
|
778
|
-
deletedIndex.deleted[change.resourceName] = entry;
|
|
779
|
-
delete index.contacts[change.resourceName];
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
// Remove contact file from contacts/
|
|
783
923
|
const contactFile = path.join(paths.contactsDir, `${change.resourceName.replace('people/', '')}.json`);
|
|
784
|
-
|
|
785
|
-
|
|
924
|
+
const fileToRead = fs.existsSync(contactFile) ? contactFile : change.filePath;
|
|
925
|
+
|
|
926
|
+
// Check if contact has a real photo or is starred - skip deletion if so
|
|
927
|
+
let contactHasPhoto = false;
|
|
928
|
+
let contactIsStarred = false;
|
|
929
|
+
if (fs.existsSync(fileToRead)) {
|
|
930
|
+
try {
|
|
931
|
+
const contact = JSON.parse(fs.readFileSync(fileToRead, 'utf-8')) as GooglePerson;
|
|
932
|
+
contactHasPhoto = hasNonDefaultPhoto(contact);
|
|
933
|
+
contactIsStarred = isStarred(contact);
|
|
934
|
+
} catch { /* ignore read errors */ }
|
|
786
935
|
}
|
|
787
936
|
|
|
788
|
-
//
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
937
|
+
// Determine skip reason (photo takes precedence over starred)
|
|
938
|
+
// Use * prefix to indicate "skipped, not deleted"
|
|
939
|
+
// 'force' overrides photo/starred protection
|
|
940
|
+
const isForced = change._delete === 'force' || index.contacts[change.resourceName]?._delete === 'force';
|
|
941
|
+
const skipReason = isForced ? null : (contactHasPhoto ? '*photo' : (contactIsStarred ? '*starred' : null));
|
|
942
|
+
|
|
943
|
+
if (skipReason) {
|
|
944
|
+
// Skip Google deletion - mark why in index.json
|
|
945
|
+
notDeleted.push({
|
|
946
|
+
resourceName: change.resourceName,
|
|
947
|
+
displayName: change.displayName,
|
|
948
|
+
filePath: fileToRead,
|
|
949
|
+
reason: skipReason
|
|
950
|
+
});
|
|
951
|
+
if (skipReason === '*photo') skippedPhoto++;
|
|
952
|
+
else if (skipReason === '*starred') skippedStarred++;
|
|
953
|
+
console.log(`${ts()} [${String(processed).padStart(5)}/${total}] SKIPPED ${change.displayName} (${skipReason})`);
|
|
954
|
+
|
|
955
|
+
// Update _delete to show skip reason
|
|
956
|
+
if (index.contacts[change.resourceName]) {
|
|
957
|
+
index.contacts[change.resourceName]._delete = skipReason;
|
|
958
|
+
}
|
|
792
959
|
|
|
793
|
-
|
|
794
|
-
|
|
960
|
+
// Remove from _delete/ folder if it was there (keep in contacts/)
|
|
961
|
+
if (change.filePath !== fileToRead && change.filePath.startsWith(paths.toDeleteDir) && fs.existsSync(change.filePath)) {
|
|
962
|
+
fs.unlinkSync(change.filePath);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
completedDeletions.add(change.resourceName); // Remove from queue even if skipped
|
|
966
|
+
} else {
|
|
967
|
+
process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Deleting ${change.displayName}...`);
|
|
968
|
+
await deleteContactOnGoogle(change.resourceName);
|
|
969
|
+
|
|
970
|
+
// Move to deleted.json (preserve _delete reason)
|
|
971
|
+
const entry = index.contacts[change.resourceName];
|
|
972
|
+
if (entry) {
|
|
973
|
+
const deletedEntry = { ...entry, deletedAt: new Date().toISOString() };
|
|
974
|
+
// Keep _delete reason in deleted entry (it's the reason for deletion)
|
|
975
|
+
deletedIndex.deleted[change.resourceName] = deletedEntry;
|
|
976
|
+
delete index.contacts[change.resourceName];
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Move contact file to deleted/ folder (backup with timestamp)
|
|
980
|
+
if (!fs.existsSync(paths.deletedDir)) {
|
|
981
|
+
fs.mkdirSync(paths.deletedDir, { recursive: true });
|
|
982
|
+
}
|
|
983
|
+
if (fs.existsSync(contactFile)) {
|
|
984
|
+
// Add deletion metadata to the JSON file
|
|
985
|
+
const contactData = JSON.parse(fs.readFileSync(contactFile, 'utf-8'));
|
|
986
|
+
contactData._deletedAt = new Date().toISOString();
|
|
987
|
+
contactData._delete = change._delete || entry?._delete || 'unknown';
|
|
988
|
+
const destPath = path.join(paths.deletedDir, path.basename(contactFile));
|
|
989
|
+
fs.writeFileSync(destPath, JSON.stringify(contactData, null, 2));
|
|
990
|
+
fs.unlinkSync(contactFile);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// Remove from _delete/ folder if it was there
|
|
994
|
+
if (change.filePath.startsWith(paths.toDeleteDir) && fs.existsSync(change.filePath)) {
|
|
995
|
+
fs.unlinkSync(change.filePath);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
completedDeletions.add(change.resourceName);
|
|
999
|
+
console.log(' done');
|
|
1000
|
+
successCount++;
|
|
1001
|
+
}
|
|
795
1002
|
}
|
|
796
1003
|
|
|
797
1004
|
await sleep(700); // 90 writes/min limit = ~700ms between ops
|
|
@@ -811,12 +1018,27 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
811
1018
|
console.log(`\nErrors written to: ${problemsFile}`);
|
|
812
1019
|
}
|
|
813
1020
|
|
|
1021
|
+
// Write notdeleted file if any contacts were skipped due to photos
|
|
1022
|
+
if (notDeleted.length > 0) {
|
|
1023
|
+
const notDeletedFile = path.join(paths.userDir, 'notdeleted.json');
|
|
1024
|
+
fs.writeFileSync(notDeletedFile, JSON.stringify(notDeleted, null, 2));
|
|
1025
|
+
console.log(`\n${notDeleted.length} contacts with photos not deleted from Google (see notdeleted.json)`);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Remove completed deletions from queue
|
|
1029
|
+
if (completedDeletions.size > 0) {
|
|
1030
|
+
deleteQueue.entries = deleteQueue.entries.filter(e => !completedDeletions.has(e.resourceName));
|
|
1031
|
+
saveDeleteQueue(paths, deleteQueue);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
814
1034
|
// Save status with new timestamp
|
|
815
1035
|
saveStatus(paths, { lastPush: new Date().toISOString() });
|
|
816
1036
|
saveIndex(paths, index);
|
|
817
1037
|
saveDeleted(paths, deletedIndex);
|
|
818
1038
|
|
|
819
|
-
|
|
1039
|
+
const skipped = skippedPhoto + skippedStarred;
|
|
1040
|
+
const skippedDetails = skipped > 0 ? ` (${skippedPhoto} photo, ${skippedStarred} starred)` : '';
|
|
1041
|
+
console.log(`\nPush complete: ${successCount} succeeded, ${errorCount} failed, ${skipped} skipped${skippedDetails}`);
|
|
820
1042
|
}
|
|
821
1043
|
|
|
822
1044
|
async function main(): Promise<void> {
|
|
@@ -861,7 +1083,7 @@ async function main(): Promise<void> {
|
|
|
861
1083
|
await syncContacts(user, { full: options.full, verbose: options.verbose });
|
|
862
1084
|
break;
|
|
863
1085
|
case 'push':
|
|
864
|
-
await pushContacts(user, { yes: options.yes, verbose: options.verbose, limit: options.limit });
|
|
1086
|
+
await pushContacts(user, { yes: options.yes, verbose: options.verbose, limit: options.limit, since: options.since });
|
|
865
1087
|
break;
|
|
866
1088
|
default:
|
|
867
1089
|
console.error(`Unknown command: ${options.command}`);
|
package/gfix.ts
CHANGED
|
@@ -11,9 +11,22 @@
|
|
|
11
11
|
import fs from 'fs';
|
|
12
12
|
import path from 'path';
|
|
13
13
|
import type { GooglePerson, GoogleName, GooglePhoneNumber, GoogleEmailAddress, GoogleBirthday } from './glib/types.ts';
|
|
14
|
-
import {
|
|
14
|
+
import type { DeleteQueue, DeleteQueueEntry, UserPaths } from './glib/gctypes.ts';
|
|
15
|
+
import { DATA_DIR, resolveUser, getUserPaths, loadIndex } from './glib/gutils.ts';
|
|
15
16
|
import { mergeContacts as mergeContactData, collectSourcePhotos, type MergeEntry, type PhotoEntry } from './glib/gmerge.ts';
|
|
16
17
|
|
|
18
|
+
function loadDeleteQueue(paths: UserPaths): DeleteQueue {
|
|
19
|
+
if (fs.existsSync(paths.deleteQueueFile)) {
|
|
20
|
+
return JSON.parse(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
|
|
21
|
+
}
|
|
22
|
+
return { updatedAt: '', entries: [] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function saveDeleteQueue(paths: UserPaths, queue: DeleteQueue): void {
|
|
26
|
+
queue.updatedAt = new Date().toISOString();
|
|
27
|
+
fs.writeFileSync(paths.deleteQueueFile, JSON.stringify(queue, null, 2));
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
interface BirthdayEntry {
|
|
18
31
|
name: string;
|
|
19
32
|
date: string;
|
|
@@ -1243,15 +1256,23 @@ async function runMerge(user: string, limit: number): Promise<void> {
|
|
|
1243
1256
|
}
|
|
1244
1257
|
}
|
|
1245
1258
|
|
|
1246
|
-
// Load existing
|
|
1259
|
+
// Load existing files
|
|
1247
1260
|
let savedPhotos: PhotoEntry[] = [];
|
|
1248
1261
|
if (fs.existsSync(photosPath)) {
|
|
1249
1262
|
savedPhotos = JSON.parse(fs.readFileSync(photosPath, 'utf-8'));
|
|
1250
1263
|
}
|
|
1251
1264
|
|
|
1265
|
+
const deleteQueue = loadDeleteQueue(paths);
|
|
1266
|
+
const index = loadIndex(paths);
|
|
1267
|
+
|
|
1252
1268
|
let mergedEntries: MergeEntry[] = [];
|
|
1253
1269
|
if (fs.existsSync(mergedPath)) {
|
|
1254
|
-
|
|
1270
|
+
const content = fs.readFileSync(mergedPath, 'utf-8').trim();
|
|
1271
|
+
if (content) {
|
|
1272
|
+
try {
|
|
1273
|
+
mergedEntries = JSON.parse(content);
|
|
1274
|
+
} catch { /* ignore parse errors, start fresh */ }
|
|
1275
|
+
}
|
|
1255
1276
|
}
|
|
1256
1277
|
|
|
1257
1278
|
let mergeSuccess = 0;
|
|
@@ -1304,12 +1325,20 @@ async function runMerge(user: string, limit: number): Promise<void> {
|
|
|
1304
1325
|
fs.writeFileSync(targetPath, JSON.stringify(updatedTarget, null, 2));
|
|
1305
1326
|
console.log(` Updated ${targetPath}`);
|
|
1306
1327
|
|
|
1307
|
-
//
|
|
1328
|
+
// Add sources to delete queue and set _delete reason in index
|
|
1308
1329
|
for (const source of sources) {
|
|
1309
1330
|
const sourceId = source.resourceName.replace('people/', '');
|
|
1310
|
-
const
|
|
1311
|
-
|
|
1312
|
-
|
|
1331
|
+
const displayName = source.names?.[0]?.displayName || 'Unknown';
|
|
1332
|
+
deleteQueue.entries.push({
|
|
1333
|
+
resourceName: source.resourceName,
|
|
1334
|
+
displayName,
|
|
1335
|
+
addedAt: new Date().toISOString(),
|
|
1336
|
+
_delete: 'duplicate'
|
|
1337
|
+
});
|
|
1338
|
+
// Set _delete reason in index.json
|
|
1339
|
+
if (index.contacts[source.resourceName]) {
|
|
1340
|
+
index.contacts[source.resourceName]._delete = 'duplicate';
|
|
1341
|
+
}
|
|
1313
1342
|
console.log(` Marked ${sourceId} for deletion`);
|
|
1314
1343
|
}
|
|
1315
1344
|
|
|
@@ -1321,7 +1350,7 @@ async function runMerge(user: string, limit: number): Promise<void> {
|
|
|
1321
1350
|
}
|
|
1322
1351
|
}
|
|
1323
1352
|
|
|
1324
|
-
// Process deletes -
|
|
1353
|
+
// Process deletes - add to delete queue and set _delete reason
|
|
1325
1354
|
for (const entry of toDelete) {
|
|
1326
1355
|
console.log(`\nMarking for deletion: ${entry.name}...`);
|
|
1327
1356
|
|
|
@@ -1329,9 +1358,18 @@ async function runMerge(user: string, limit: number): Promise<void> {
|
|
|
1329
1358
|
const id = rn.replace('people/', '');
|
|
1330
1359
|
const filePath = path.join(paths.contactsDir, `${id}.json`);
|
|
1331
1360
|
if (fs.existsSync(filePath)) {
|
|
1332
|
-
const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
1333
|
-
const
|
|
1334
|
-
|
|
1361
|
+
const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
|
|
1362
|
+
const displayName = contact.names?.[0]?.displayName || entry.name;
|
|
1363
|
+
deleteQueue.entries.push({
|
|
1364
|
+
resourceName: rn,
|
|
1365
|
+
displayName,
|
|
1366
|
+
addedAt: new Date().toISOString(),
|
|
1367
|
+
_delete: 'duplicate'
|
|
1368
|
+
});
|
|
1369
|
+
// Set _delete reason in index.json
|
|
1370
|
+
if (index.contacts[rn]) {
|
|
1371
|
+
index.contacts[rn]._delete = 'duplicate';
|
|
1372
|
+
}
|
|
1335
1373
|
console.log(` Marked ${id} for deletion`);
|
|
1336
1374
|
deleteSuccess++;
|
|
1337
1375
|
}
|
|
@@ -1347,6 +1385,12 @@ async function runMerge(user: string, limit: number): Promise<void> {
|
|
|
1347
1385
|
// Save merged.json (history of processed entries)
|
|
1348
1386
|
fs.writeFileSync(mergedPath, JSON.stringify(mergedEntries, null, 2));
|
|
1349
1387
|
|
|
1388
|
+
// Save delete queue
|
|
1389
|
+
saveDeleteQueue(paths, deleteQueue);
|
|
1390
|
+
|
|
1391
|
+
// Save index.json with _delete reasons
|
|
1392
|
+
fs.writeFileSync(paths.indexFile, JSON.stringify(index, null, 2));
|
|
1393
|
+
|
|
1350
1394
|
// Remove processed entries from merger.json
|
|
1351
1395
|
const remainingEntries = JSON.parse(fs.readFileSync(mergerPath, 'utf-8')) as MergeEntry[];
|
|
1352
1396
|
const processedNames = new Set(entries.map(e => e.name));
|
|
@@ -1361,6 +1405,72 @@ async function runMerge(user: string, limit: number): Promise<void> {
|
|
|
1361
1405
|
console.log(`\nRun 'gcards push -u ${user}' to apply changes to Google.`);
|
|
1362
1406
|
}
|
|
1363
1407
|
|
|
1408
|
+
/** Reset ALL delete markers: index.json, contact files, _delete.json, _delete/ folder */
|
|
1409
|
+
async function runReset(user: string): Promise<void> {
|
|
1410
|
+
const paths = getUserPaths(user);
|
|
1411
|
+
|
|
1412
|
+
let indexCleared = 0;
|
|
1413
|
+
let filesCleared = 0;
|
|
1414
|
+
let queueCleared = 0;
|
|
1415
|
+
let folderMoved = 0;
|
|
1416
|
+
|
|
1417
|
+
// Clear from index.json
|
|
1418
|
+
if (fs.existsSync(paths.indexFile)) {
|
|
1419
|
+
const index = JSON.parse(fs.readFileSync(paths.indexFile, 'utf-8'));
|
|
1420
|
+
for (const entry of Object.values(index.contacts) as any[]) {
|
|
1421
|
+
if (entry._delete) {
|
|
1422
|
+
delete entry._delete;
|
|
1423
|
+
indexCleared++;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
fs.writeFileSync(paths.indexFile, JSON.stringify(index, null, 2));
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// Clear from contact JSON files (preserve mtime to avoid false "modified" detection)
|
|
1430
|
+
if (fs.existsSync(paths.contactsDir)) {
|
|
1431
|
+
const files = fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'));
|
|
1432
|
+
for (const file of files) {
|
|
1433
|
+
const filePath = path.join(paths.contactsDir, file);
|
|
1434
|
+
const stat = fs.statSync(filePath);
|
|
1435
|
+
const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
1436
|
+
if (content._delete || content._deleted) {
|
|
1437
|
+
delete content._delete;
|
|
1438
|
+
delete content._deleted;
|
|
1439
|
+
fs.writeFileSync(filePath, JSON.stringify(content, null, 2));
|
|
1440
|
+
// Restore original mtime so file doesn't appear modified
|
|
1441
|
+
fs.utimesSync(filePath, stat.atime, stat.mtime);
|
|
1442
|
+
filesCleared++;
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// Clear _delete.json queue
|
|
1448
|
+
if (fs.existsSync(paths.deleteQueueFile)) {
|
|
1449
|
+
const queue = JSON.parse(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
|
|
1450
|
+
queueCleared = queue.entries?.length || 0;
|
|
1451
|
+
fs.unlinkSync(paths.deleteQueueFile);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// Move files from _delete/ folder back to contacts/
|
|
1455
|
+
if (fs.existsSync(paths.toDeleteDir)) {
|
|
1456
|
+
const files = fs.readdirSync(paths.toDeleteDir).filter(f => f.endsWith('.json'));
|
|
1457
|
+
for (const file of files) {
|
|
1458
|
+
const src = path.join(paths.toDeleteDir, file);
|
|
1459
|
+
const dest = path.join(paths.contactsDir, file);
|
|
1460
|
+
fs.renameSync(src, dest);
|
|
1461
|
+
folderMoved++;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
console.log(`Reset complete for user: ${user}`);
|
|
1466
|
+
console.log(` Index entries: ${indexCleared}`);
|
|
1467
|
+
console.log(` Contact files: ${filesCleared}`);
|
|
1468
|
+
console.log(` Queue entries: ${queueCleared}`);
|
|
1469
|
+
if (folderMoved > 0) {
|
|
1470
|
+
console.log(` Moved from _delete/: ${folderMoved}`);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1364
1474
|
function showUsage(): void {
|
|
1365
1475
|
console.log(`
|
|
1366
1476
|
gfix - One-time fix routines for gcards contacts
|
|
@@ -1375,6 +1485,7 @@ Commands:
|
|
|
1375
1485
|
names Parse givenName into first/middle/last, clean fileAs, remove dup phones/emails
|
|
1376
1486
|
undup Find duplicate contacts (same name + overlapping email) -> merger.json
|
|
1377
1487
|
merge Merge duplicates locally (then use gcards push)
|
|
1488
|
+
reset Clear all _delete flags from index.json
|
|
1378
1489
|
|
|
1379
1490
|
Options:
|
|
1380
1491
|
-u, --user <name> User profile to process
|
|
@@ -1460,6 +1571,8 @@ async function main(): Promise<void> {
|
|
|
1460
1571
|
await runUndup(resolvedUser);
|
|
1461
1572
|
} else if (command === 'merge') {
|
|
1462
1573
|
await runMerge(resolvedUser, processLimit);
|
|
1574
|
+
} else if (command === 'reset') {
|
|
1575
|
+
await runReset(resolvedUser);
|
|
1463
1576
|
} else {
|
|
1464
1577
|
console.error(`Unknown command: ${command}`);
|
|
1465
1578
|
showUsage();
|
package/glib/gctypes.ts
CHANGED
|
@@ -28,14 +28,33 @@ export interface ContactIndex {
|
|
|
28
28
|
export interface IndexEntry {
|
|
29
29
|
resourceName: string;
|
|
30
30
|
displayName: string;
|
|
31
|
-
etag?: string;
|
|
32
|
-
deleted: boolean; /** Google deleted this contact */
|
|
33
|
-
deletedAt?: string;
|
|
34
31
|
updatedAt: string;
|
|
35
|
-
|
|
32
|
+
hasPhoto?: boolean; /** Has non-default photo */
|
|
33
|
+
starred?: boolean; /** In starred/favorites group */
|
|
34
|
+
_delete?: string; /** Deletion reason. *prefix means skipped (e.g., *photo, *starred) */
|
|
36
35
|
guids?: string[]; /** User-defined GUIDs for cross-account tracking */
|
|
37
36
|
}
|
|
38
37
|
|
|
38
|
+
export interface DeletedEntry extends IndexEntry {
|
|
39
|
+
deletedAt: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ============================================================
|
|
43
|
+
// Delete Queue (_delete.json)
|
|
44
|
+
// ============================================================
|
|
45
|
+
|
|
46
|
+
export interface DeleteQueueEntry {
|
|
47
|
+
resourceName: string;
|
|
48
|
+
displayName: string;
|
|
49
|
+
addedAt: string;
|
|
50
|
+
_delete: string; /** Reason for deletion (e.g., 'duplicate') */
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface DeleteQueue {
|
|
54
|
+
updatedAt: string;
|
|
55
|
+
entries: DeleteQueueEntry[];
|
|
56
|
+
}
|
|
57
|
+
|
|
39
58
|
// ============================================================
|
|
40
59
|
// Push Status (status.json)
|
|
41
60
|
// ============================================================
|
|
@@ -62,6 +81,7 @@ export interface PendingChange {
|
|
|
62
81
|
resourceName: string;
|
|
63
82
|
displayName: string;
|
|
64
83
|
filePath: string;
|
|
84
|
+
_delete?: string; /** Deletion reason (for type='delete') */
|
|
65
85
|
}
|
|
66
86
|
|
|
67
87
|
// ============================================================
|
|
@@ -71,12 +91,14 @@ export interface PendingChange {
|
|
|
71
91
|
export interface UserPaths {
|
|
72
92
|
userDir: string;
|
|
73
93
|
contactsDir: string; /** Active contacts synced from Google */
|
|
74
|
-
deletedDir: string; /**
|
|
75
|
-
toDeleteDir: string; /** User requests to delete */
|
|
94
|
+
deletedDir: string; /** Backup of deleted contact files */
|
|
95
|
+
toDeleteDir: string; /** User requests to delete (move files here) */
|
|
76
96
|
toAddDir: string; /** User requests to add new contacts */
|
|
77
97
|
fixLogDir: string; /** Logs from gfix operations */
|
|
78
98
|
indexFile: string;
|
|
79
99
|
deletedFile: string; /** Deleted contacts index (deleted.json) */
|
|
100
|
+
deleteQueueFile: string; /** Pending deletions (_delete.json) */
|
|
101
|
+
photosFile: string; /** Photos from deleted contacts (photos.json) */
|
|
80
102
|
statusFile: string;
|
|
81
103
|
syncTokenFile: string;
|
|
82
104
|
tokenFile: string;
|
package/glib/gutils.ts
CHANGED
|
@@ -58,6 +58,8 @@ export function getUserPaths(user: string): UserPaths {
|
|
|
58
58
|
fixLogDir: path.join(userDir, 'fix-logs'),
|
|
59
59
|
indexFile: path.join(userDir, 'index.json'),
|
|
60
60
|
deletedFile: path.join(userDir, 'deleted.json'),
|
|
61
|
+
deleteQueueFile: path.join(userDir, '_delete.json'),
|
|
62
|
+
photosFile: path.join(userDir, 'photos.json'),
|
|
61
63
|
statusFile: path.join(userDir, 'status.json'),
|
|
62
64
|
syncTokenFile: path.join(userDir, 'sync-token.json'),
|
|
63
65
|
tokenFile: path.join(userDir, 'token.json'),
|
package/glib/parsecli.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface CliOptions {
|
|
|
11
11
|
help: boolean;
|
|
12
12
|
verbose: boolean;
|
|
13
13
|
limit: number; /** Process only first n entries (0 = no limit) */
|
|
14
|
+
since: string; /** Override lastSync - check files since this datetime */
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export function parseArgs(args: string[]): CliOptions {
|
|
@@ -22,7 +23,8 @@ export function parseArgs(args: string[]): CliOptions {
|
|
|
22
23
|
all: false,
|
|
23
24
|
help: false,
|
|
24
25
|
verbose: false,
|
|
25
|
-
limit: 0
|
|
26
|
+
limit: 0,
|
|
27
|
+
since: ''
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -41,6 +43,8 @@ export function parseArgs(args: string[]): CliOptions {
|
|
|
41
43
|
options.user = args[++i];
|
|
42
44
|
} else if ((arg === '--limit' || arg === '-limit') && i + 1 < args.length) {
|
|
43
45
|
options.limit = parseInt(args[++i], 10) || 0;
|
|
46
|
+
} else if ((arg === '--since' || arg === '-since') && i + 1 < args.length) {
|
|
47
|
+
options.since = args[++i];
|
|
44
48
|
} else if (!arg.startsWith('-') && !options.command) {
|
|
45
49
|
options.command = arg;
|
|
46
50
|
}
|