@bobfrankston/gcards 0.1.4 → 0.1.6
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 +4 -1
- package/README.md +42 -5
- package/gcards.ts +285 -62
- package/gfix.ts +483 -14
- package/glib/gctypes.ts +28 -6
- package/glib/gutils.ts +2 -0
- package/glib/parsecli.ts +5 -1
- package/package.json +1 -1
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
"Bash(node gfix.ts:*)",
|
|
20
20
|
"Bash(node --import tsx gcards.ts:*)",
|
|
21
21
|
"Bash(npm install:*)",
|
|
22
|
-
"Bash(npx tsx:*)"
|
|
22
|
+
"Bash(npx tsx:*)",
|
|
23
|
+
"WebFetch(domain:lh3.googleusercontent.com)",
|
|
24
|
+
"Bash(curl:*)",
|
|
25
|
+
"Bash(bun build:*)"
|
|
23
26
|
],
|
|
24
27
|
"deny": [],
|
|
25
28
|
"ask": []
|
package/README.md
CHANGED
|
@@ -47,6 +47,8 @@ gfix birthday -u user --apply # Extract to CSV and remove
|
|
|
47
47
|
|
|
48
48
|
gfix undup -u user # Find duplicate contacts -> merger.json
|
|
49
49
|
gfix merge -u user # Merge duplicates locally (then use gcards push)
|
|
50
|
+
|
|
51
|
+
gfix export -u source -to target "pattern" # Export contacts to another user
|
|
50
52
|
```
|
|
51
53
|
|
|
52
54
|
## Google People API Fields
|
|
@@ -119,18 +121,19 @@ gcards/ # App directory (%APPDATA%\gcards or ~/.config/gcards)
|
|
|
119
121
|
data/ # User data directory
|
|
120
122
|
<username>/
|
|
121
123
|
contacts/ # Active contacts (*.json)
|
|
124
|
+
deleted/ # Backup of deleted contact files
|
|
122
125
|
_delete/ # User requests to delete (move files here)
|
|
123
126
|
_add/ # User requests to add new contacts
|
|
124
127
|
fix-logs/ # Logs from gfix operations
|
|
125
|
-
index.json # Active contact index
|
|
126
|
-
deleted.json # Deleted contacts index
|
|
128
|
+
index.json # Active contact index (hasPhoto, starred flags)
|
|
129
|
+
deleted.json # Deleted contacts index
|
|
130
|
+
notdeleted.json # Contacts skipped due to photo/starred
|
|
127
131
|
token.json # OAuth token (read-only)
|
|
128
132
|
token-write.json # OAuth token (read-write)
|
|
129
133
|
changes.log # Log from gfix names
|
|
130
134
|
birthdays.csv # Extracted birthdays
|
|
131
135
|
merger.json # Duplicates to merge (from gfix undup)
|
|
132
136
|
merged.json # Processed merges (history)
|
|
133
|
-
photos.json # Photos from deleted contacts
|
|
134
137
|
```
|
|
135
138
|
|
|
136
139
|
## Fixes Applied by `gfix inspect/apply`
|
|
@@ -158,13 +161,26 @@ When a contact has a full name in `givenName` but no `familyName`:
|
|
|
158
161
|
|
|
159
162
|
## Deleting Contacts
|
|
160
163
|
|
|
161
|
-
Three ways to
|
|
164
|
+
Three ways to mark contacts for deletion:
|
|
162
165
|
|
|
163
166
|
1. **In index.json**: Add `"_delete": true` to an entry
|
|
164
167
|
2. **In contact JSON**: Add `"_delete": true` to the contact file
|
|
165
168
|
3. **Move to _delete/**: Move the contact file to the `_delete/` folder
|
|
166
169
|
|
|
167
|
-
Then run `gcards push` to apply deletions.
|
|
170
|
+
Then run `gcards push` to apply deletions.
|
|
171
|
+
|
|
172
|
+
### Photo/Starred Protection
|
|
173
|
+
|
|
174
|
+
Contacts with photos or starred status are **not deleted** from Google to prevent data loss:
|
|
175
|
+
- Contacts with non-default photos: `_delete` set to `"photo"`
|
|
176
|
+
- Starred/favorite contacts: `_delete` set to `"starred"`
|
|
177
|
+
|
|
178
|
+
These remain in Google and `index.json` with the skip reason. Review `notdeleted.json` after push.
|
|
179
|
+
|
|
180
|
+
### Backup
|
|
181
|
+
|
|
182
|
+
All deleted contact files are moved to `deleted/` folder as backup (not permanently deleted).
|
|
183
|
+
Index entries move to `deleted.json`.
|
|
168
184
|
|
|
169
185
|
## Duplicate Contact Merging
|
|
170
186
|
|
|
@@ -203,6 +219,27 @@ gcards sync -a
|
|
|
203
219
|
gcards push -a
|
|
204
220
|
```
|
|
205
221
|
|
|
222
|
+
## Exporting Contacts Between Users
|
|
223
|
+
|
|
224
|
+
Transfer contacts from one user's account to another:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
gfix export -u bob -to alice "John*" # Export contacts matching "John*"
|
|
228
|
+
gfix export -u bob -to ali "*@example.com" # Export by email (partial user match)
|
|
229
|
+
gfix export -u bob -to alice c1234567890 # Export specific contact by ID
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**Features:**
|
|
233
|
+
- `-u` source user (partial match supported)
|
|
234
|
+
- `-to` target user (partial match - "ali" matches "alice" if unique)
|
|
235
|
+
- Pattern supports `*` (any chars) and `?` (single char) wildcards
|
|
236
|
+
- Matches on: displayName, givenName, familyName, "Last, First", email
|
|
237
|
+
- Searches both `contacts/` and `deleted/` directories
|
|
238
|
+
- Multiple matches prompt for selection: number(s), `*` for all, `q` to quit
|
|
239
|
+
- Creates cleaned file in target's `_add/` as `x<sourceId>.json`
|
|
240
|
+
|
|
241
|
+
After export, run `gcards push -u <target>` to upload.
|
|
242
|
+
|
|
206
243
|
## Setup
|
|
207
244
|
|
|
208
245
|
1. Create Google Cloud project
|
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';
|
|
@@ -79,7 +79,8 @@ async function getAccessToken(user: string, writeAccess = false, forceRefresh =
|
|
|
79
79
|
tokenDirectory: paths.userDir,
|
|
80
80
|
tokenFileName,
|
|
81
81
|
credentialsKey: 'web',
|
|
82
|
-
includeOfflineAccess: true
|
|
82
|
+
includeOfflineAccess: true,
|
|
83
|
+
prompt: 'select_account' // Always show account picker for new auth
|
|
83
84
|
});
|
|
84
85
|
|
|
85
86
|
if (!token) {
|
|
@@ -112,7 +113,7 @@ function saveIndex(paths: UserPaths, index: ContactIndex): void {
|
|
|
112
113
|
|
|
113
114
|
/** Deleted contacts index (separate from active index) */
|
|
114
115
|
interface DeletedIndex {
|
|
115
|
-
deleted: Record<string,
|
|
116
|
+
deleted: Record<string, DeletedEntry>;
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
function loadDeleted(paths: UserPaths): DeletedIndex {
|
|
@@ -124,7 +125,7 @@ function loadDeleted(paths: UserPaths): DeletedIndex {
|
|
|
124
125
|
|
|
125
126
|
function saveDeleted(paths: UserPaths, deletedIndex: DeletedIndex): void {
|
|
126
127
|
// Sort by displayName
|
|
127
|
-
const sorted: Record<string,
|
|
128
|
+
const sorted: Record<string, DeletedEntry> = {};
|
|
128
129
|
const entries = Object.entries(deletedIndex.deleted);
|
|
129
130
|
entries.sort((a, b) => a[1].displayName.localeCompare(b[1].displayName));
|
|
130
131
|
for (const [key, value] of entries) {
|
|
@@ -134,6 +135,72 @@ function saveDeleted(paths: UserPaths, deletedIndex: DeletedIndex): void {
|
|
|
134
135
|
fs.writeFileSync(paths.deletedFile, JSON.stringify(deletedIndex, null, 2));
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
interface SavedPhoto {
|
|
139
|
+
url: string;
|
|
140
|
+
sourceType: 'CONTACT' | 'PROFILE';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface PhotoEntry {
|
|
144
|
+
displayName: string;
|
|
145
|
+
photos: SavedPhoto[];
|
|
146
|
+
deletedAt: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface PhotosIndex {
|
|
150
|
+
photos: Record<string, PhotoEntry>; // resourceName -> PhotoEntry
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function loadPhotos(paths: UserPaths): PhotosIndex {
|
|
154
|
+
if (fs.existsSync(paths.photosFile)) {
|
|
155
|
+
return JSON.parse(fs.readFileSync(paths.photosFile, 'utf-8'));
|
|
156
|
+
}
|
|
157
|
+
return { photos: {} };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function savePhotos(paths: UserPaths, photosIndex: PhotosIndex): void {
|
|
161
|
+
// Sort by displayName
|
|
162
|
+
const sorted: Record<string, PhotoEntry> = {};
|
|
163
|
+
const entries = Object.entries(photosIndex.photos);
|
|
164
|
+
entries.sort((a, b) => a[1].displayName.localeCompare(b[1].displayName));
|
|
165
|
+
for (const [key, value] of entries) {
|
|
166
|
+
sorted[key] = value;
|
|
167
|
+
}
|
|
168
|
+
photosIndex.photos = sorted;
|
|
169
|
+
fs.writeFileSync(paths.photosFile, JSON.stringify(photosIndex, null, 2));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function loadDeleteQueue(paths: UserPaths): DeleteQueue {
|
|
173
|
+
if (fs.existsSync(paths.deleteQueueFile)) {
|
|
174
|
+
return JSON.parse(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
|
|
175
|
+
}
|
|
176
|
+
return { updatedAt: '', entries: [] };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function saveDeleteQueue(paths: UserPaths, queue: DeleteQueue): void {
|
|
180
|
+
queue.updatedAt = new Date().toISOString();
|
|
181
|
+
fs.writeFileSync(paths.deleteQueueFile, JSON.stringify(queue, null, 2));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function extractNonDefaultPhotos(contact: GooglePerson): SavedPhoto[] {
|
|
185
|
+
if (!contact.photos) return [];
|
|
186
|
+
return contact.photos
|
|
187
|
+
.filter(p => !p.default)
|
|
188
|
+
.map(p => ({
|
|
189
|
+
url: p.url,
|
|
190
|
+
sourceType: (p.metadata?.source?.type || 'CONTACT') as 'CONTACT' | 'PROFILE'
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function hasNonDefaultPhoto(contact: GooglePerson): boolean {
|
|
195
|
+
return contact.photos?.some(p => !p.default) || false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isStarred(contact: GooglePerson): boolean {
|
|
199
|
+
return contact.memberships?.some(m =>
|
|
200
|
+
m.contactGroupMembership?.contactGroupId === 'starred'
|
|
201
|
+
) || false;
|
|
202
|
+
}
|
|
203
|
+
|
|
137
204
|
function generateGuid(): string {
|
|
138
205
|
return crypto.randomUUID();
|
|
139
206
|
}
|
|
@@ -232,20 +299,37 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
|
|
|
232
299
|
|
|
233
300
|
const index = loadIndex(paths);
|
|
234
301
|
const deletedIndex = loadDeleted(paths);
|
|
302
|
+
const photosIndex = loadPhotos(paths);
|
|
235
303
|
let syncToken = options.full ? null : loadSyncToken(paths);
|
|
236
304
|
|
|
237
|
-
// Migrate any deleted entries from index.json to deleted.json
|
|
305
|
+
// Migrate any old deleted entries from index.json to deleted.json
|
|
306
|
+
// Also clean up old fields (deleted: false, etag)
|
|
238
307
|
let migrated = 0;
|
|
308
|
+
let cleaned = 0;
|
|
239
309
|
for (const [rn, entry] of Object.entries(index.contacts)) {
|
|
240
|
-
|
|
241
|
-
|
|
310
|
+
const oldEntry = entry as any;
|
|
311
|
+
if (oldEntry.deleted === true) {
|
|
312
|
+
deletedIndex.deleted[rn] = { ...entry, deletedAt: oldEntry.deletedAt || new Date().toISOString() };
|
|
242
313
|
delete index.contacts[rn];
|
|
243
314
|
migrated++;
|
|
315
|
+
} else {
|
|
316
|
+
// Clean up old fields
|
|
317
|
+
if ('deleted' in oldEntry) {
|
|
318
|
+
delete oldEntry.deleted;
|
|
319
|
+
cleaned++;
|
|
320
|
+
}
|
|
321
|
+
if ('etag' in oldEntry) {
|
|
322
|
+
delete oldEntry.etag;
|
|
323
|
+
cleaned++;
|
|
324
|
+
}
|
|
244
325
|
}
|
|
245
326
|
}
|
|
246
327
|
if (migrated > 0) {
|
|
247
328
|
console.log(`Migrated ${migrated} deleted entries to deleted.json`);
|
|
248
329
|
}
|
|
330
|
+
if (cleaned > 0) {
|
|
331
|
+
console.log(`Cleaned ${cleaned} old fields from index entries`);
|
|
332
|
+
}
|
|
249
333
|
|
|
250
334
|
if (syncToken) {
|
|
251
335
|
console.log('Performing incremental sync...');
|
|
@@ -275,12 +359,42 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
|
|
|
275
359
|
if (isDeleted) {
|
|
276
360
|
const entry = index.contacts[person.resourceName];
|
|
277
361
|
if (entry) {
|
|
362
|
+
const id = person.resourceName.replace('people/', '');
|
|
363
|
+
const contactFile = path.join(paths.contactsDir, `${id}.json`);
|
|
364
|
+
|
|
365
|
+
// Save photos before deleting
|
|
366
|
+
if (fs.existsSync(contactFile)) {
|
|
367
|
+
try {
|
|
368
|
+
const contact = JSON.parse(fs.readFileSync(contactFile, 'utf-8')) as GooglePerson;
|
|
369
|
+
const photos = extractNonDefaultPhotos(contact);
|
|
370
|
+
if (photos.length > 0) {
|
|
371
|
+
photosIndex.photos[person.resourceName] = {
|
|
372
|
+
displayName: entry.displayName,
|
|
373
|
+
photos,
|
|
374
|
+
deletedAt: new Date().toISOString()
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
} catch { /* ignore read errors */ }
|
|
378
|
+
}
|
|
379
|
+
|
|
278
380
|
// Move to deleted.json
|
|
279
|
-
entry.
|
|
280
|
-
|
|
281
|
-
deletedIndex.deleted[person.resourceName] = entry;
|
|
381
|
+
const deletedEntry = { ...entry, deletedAt: new Date().toISOString(), _delete: 'server' };
|
|
382
|
+
deletedIndex.deleted[person.resourceName] = deletedEntry;
|
|
282
383
|
delete index.contacts[person.resourceName];
|
|
283
|
-
|
|
384
|
+
|
|
385
|
+
// Backup contact file to deleted/ folder
|
|
386
|
+
if (fs.existsSync(contactFile)) {
|
|
387
|
+
if (!fs.existsSync(paths.deletedDir)) {
|
|
388
|
+
fs.mkdirSync(paths.deletedDir, { recursive: true });
|
|
389
|
+
}
|
|
390
|
+
const contactData = JSON.parse(fs.readFileSync(contactFile, 'utf-8'));
|
|
391
|
+
contactData._deletedAt = new Date().toISOString();
|
|
392
|
+
contactData._delete = 'server';
|
|
393
|
+
const destPath = path.join(paths.deletedDir, `${id}.json`);
|
|
394
|
+
fs.writeFileSync(destPath, JSON.stringify(contactData, null, 2));
|
|
395
|
+
fs.unlinkSync(contactFile);
|
|
396
|
+
}
|
|
397
|
+
|
|
284
398
|
deleted++;
|
|
285
399
|
if (options.verbose) console.log(`\n Deleted: ${displayName}`);
|
|
286
400
|
}
|
|
@@ -307,14 +421,20 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
|
|
|
307
421
|
|
|
308
422
|
// Extract GUIDs from userDefined fields
|
|
309
423
|
const guids = extractGuids(person);
|
|
424
|
+
const hasPhoto = hasNonDefaultPhoto(person);
|
|
425
|
+
const starred = isStarred(person);
|
|
426
|
+
|
|
427
|
+
// Preserve _delete flag from existing entry
|
|
428
|
+
const existingDelete = index.contacts[person.resourceName]?._delete;
|
|
310
429
|
|
|
311
430
|
index.contacts[person.resourceName] = {
|
|
312
431
|
resourceName: person.resourceName,
|
|
313
432
|
displayName,
|
|
314
|
-
etag: person.etag,
|
|
315
|
-
deleted: false,
|
|
316
433
|
updatedAt: new Date().toISOString(),
|
|
317
|
-
|
|
434
|
+
...(hasPhoto && { hasPhoto }),
|
|
435
|
+
...(starred && { starred }),
|
|
436
|
+
...(guids.length > 0 && { guids }),
|
|
437
|
+
...(existingDelete && { _delete: existingDelete })
|
|
318
438
|
};
|
|
319
439
|
|
|
320
440
|
if (existed) {
|
|
@@ -340,6 +460,7 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
|
|
|
340
460
|
index.lastSync = new Date().toISOString();
|
|
341
461
|
saveIndex(paths, index);
|
|
342
462
|
saveDeleted(paths, deletedIndex);
|
|
463
|
+
savePhotos(paths, photosIndex);
|
|
343
464
|
|
|
344
465
|
// Small delay between pages to be nice to the API
|
|
345
466
|
if (pageToken) {
|
|
@@ -383,23 +504,27 @@ interface PendingChangesResult {
|
|
|
383
504
|
parseErrors: string[];
|
|
384
505
|
}
|
|
385
506
|
|
|
386
|
-
async function findPendingChanges(paths: UserPaths, logger: FileLogger): Promise<PendingChangesResult> {
|
|
507
|
+
async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?: string): Promise<PendingChangesResult> {
|
|
387
508
|
const changes: PendingChange[] = [];
|
|
388
509
|
const parseErrors: string[] = [];
|
|
389
510
|
const index = loadIndex(paths);
|
|
390
|
-
|
|
511
|
+
// Use -since override if provided, otherwise use lastSync
|
|
512
|
+
const sinceTime = since ? new Date(since).getTime() : (index.lastSync ? new Date(index.lastSync).getTime() : 0);
|
|
513
|
+
if (since) {
|
|
514
|
+
console.log(`Using -since override: ${since}`);
|
|
515
|
+
}
|
|
391
516
|
|
|
392
|
-
// Check
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
}
|
|
517
|
+
// Check _delete.json for deletion requests
|
|
518
|
+
const deleteQueue = loadDeleteQueue(paths);
|
|
519
|
+
for (const entry of deleteQueue.entries) {
|
|
520
|
+
const filePath = path.join(paths.contactsDir, `${entry.resourceName.replace('people/', '')}.json`);
|
|
521
|
+
changes.push({
|
|
522
|
+
type: 'delete',
|
|
523
|
+
resourceName: entry.resourceName,
|
|
524
|
+
displayName: entry.displayName,
|
|
525
|
+
filePath,
|
|
526
|
+
_delete: entry._delete || 'queued'
|
|
527
|
+
});
|
|
403
528
|
}
|
|
404
529
|
|
|
405
530
|
// Check contacts/ for modified files and _delete markers
|
|
@@ -413,8 +538,8 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger): Promise
|
|
|
413
538
|
const stat = await fp.stat(filePath);
|
|
414
539
|
const modTime = stat.mtimeMs;
|
|
415
540
|
|
|
416
|
-
// Skip files not modified since last sync
|
|
417
|
-
if (modTime <=
|
|
541
|
+
// Skip files not modified since last sync (or -since override)
|
|
542
|
+
if (modTime <= sinceTime + 1000) continue;
|
|
418
543
|
|
|
419
544
|
checked++;
|
|
420
545
|
let content: GooglePerson & { _delete?: boolean };
|
|
@@ -645,7 +770,7 @@ async function createContactOnGoogle(person: GooglePerson, retryCount = 0, token
|
|
|
645
770
|
return await response.json();
|
|
646
771
|
}
|
|
647
772
|
|
|
648
|
-
async function pushContacts(user: string, options: { yes: boolean; verbose: boolean; limit: number }): Promise<void> {
|
|
773
|
+
async function pushContacts(user: string, options: { yes: boolean; verbose: boolean; limit: number; since: string }): Promise<void> {
|
|
649
774
|
const paths = getUserPaths(user);
|
|
650
775
|
ensureUserDir(user);
|
|
651
776
|
|
|
@@ -654,7 +779,7 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
654
779
|
const logger = new FileLogger(problemsFile, true);
|
|
655
780
|
|
|
656
781
|
console.log(`Pushing contacts for user: ${user}`);
|
|
657
|
-
let { changes, parseErrors } = await findPendingChanges(paths, logger);
|
|
782
|
+
let { changes, parseErrors } = await findPendingChanges(paths, logger, options.since || undefined);
|
|
658
783
|
|
|
659
784
|
if (parseErrors.length > 0) {
|
|
660
785
|
console.log(`\n${parseErrors.length} files had parse errors (see pushproblems.txt)`);
|
|
@@ -665,9 +790,28 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
665
790
|
return;
|
|
666
791
|
}
|
|
667
792
|
|
|
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');
|
|
793
|
+
const adds = changes.filter(c => c.type === 'add').sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
794
|
+
const updates = changes.filter(c => c.type === 'update').sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
795
|
+
const deletes = changes.filter(c => c.type === 'delete').sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
796
|
+
|
|
797
|
+
// Write to toPush.txt for debugging
|
|
798
|
+
const toPushLines: string[] = [];
|
|
799
|
+
if (adds.length > 0) {
|
|
800
|
+
toPushLines.push(`New contacts (${adds.length}):`);
|
|
801
|
+
for (const c of adds) toPushLines.push(` ${c.displayName}`);
|
|
802
|
+
toPushLines.push('');
|
|
803
|
+
}
|
|
804
|
+
if (updates.length > 0) {
|
|
805
|
+
toPushLines.push(`Updates (${updates.length}):`);
|
|
806
|
+
for (const c of updates) toPushLines.push(` ${c.displayName} (${c.resourceName})`);
|
|
807
|
+
toPushLines.push('');
|
|
808
|
+
}
|
|
809
|
+
if (deletes.length > 0) {
|
|
810
|
+
toPushLines.push(`Deletions (${deletes.length}):`);
|
|
811
|
+
for (const c of deletes) toPushLines.push(` ${c.displayName} (${c.resourceName})`);
|
|
812
|
+
}
|
|
813
|
+
const toPushFile = path.join(paths.userDir, 'toPush.txt');
|
|
814
|
+
fs.writeFileSync(toPushFile, toPushLines.join('\n'));
|
|
671
815
|
|
|
672
816
|
console.log('\nPending changes:');
|
|
673
817
|
if (adds.length > 0) {
|
|
@@ -688,6 +832,10 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
688
832
|
console.log(` - ${c.displayName} (${c.resourceName})`);
|
|
689
833
|
}
|
|
690
834
|
}
|
|
835
|
+
console.log(`\n(List written to ${toPushFile})`);
|
|
836
|
+
|
|
837
|
+
// Reorder changes to process in sorted order
|
|
838
|
+
changes = [...adds, ...updates, ...deletes];
|
|
691
839
|
|
|
692
840
|
if (!options.yes) {
|
|
693
841
|
const confirmed = await confirm(`\nPush ${changes.length} change(s) to Google?`);
|
|
@@ -703,6 +851,8 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
703
851
|
|
|
704
852
|
const index = loadIndex(paths);
|
|
705
853
|
const deletedIndex = loadDeleted(paths);
|
|
854
|
+
const deleteQueue = loadDeleteQueue(paths);
|
|
855
|
+
const completedDeletions = new Set<string>(); // Track successful deletions
|
|
706
856
|
// Apply limit if specified
|
|
707
857
|
if (options.limit > 0 && changes.length > options.limit) {
|
|
708
858
|
console.log(`[DEBUG] Limiting to first ${options.limit} of ${changes.length} changes\n`);
|
|
@@ -711,9 +861,12 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
711
861
|
|
|
712
862
|
let successCount = 0;
|
|
713
863
|
let errorCount = 0;
|
|
864
|
+
let skippedPhoto = 0;
|
|
865
|
+
let skippedStarred = 0;
|
|
714
866
|
let processed = 0;
|
|
715
867
|
const total = changes.length;
|
|
716
868
|
const problems: string[] = [];
|
|
869
|
+
const notDeleted: { resourceName: string; displayName: string; filePath: string; reason: string }[] = [];
|
|
717
870
|
|
|
718
871
|
for (const change of changes) {
|
|
719
872
|
if (escapePressed) {
|
|
@@ -728,17 +881,19 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
728
881
|
process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Creating ${change.displayName}...`);
|
|
729
882
|
const created = await createContactOnGoogle(content);
|
|
730
883
|
|
|
731
|
-
// Extract GUIDs from
|
|
884
|
+
// Extract GUIDs and flags from created contact
|
|
732
885
|
const guids = extractGuids(created);
|
|
886
|
+
const hasPhoto = hasNonDefaultPhoto(created);
|
|
887
|
+
const starred = isStarred(created);
|
|
733
888
|
|
|
734
889
|
// Add to index
|
|
735
890
|
index.contacts[created.resourceName] = {
|
|
736
891
|
resourceName: created.resourceName,
|
|
737
892
|
displayName: change.displayName,
|
|
738
|
-
etag: created.etag,
|
|
739
|
-
deleted: false,
|
|
740
893
|
updatedAt: new Date().toISOString(),
|
|
741
|
-
|
|
894
|
+
...(hasPhoto && { hasPhoto }),
|
|
895
|
+
...(starred && { starred }),
|
|
896
|
+
...(guids.length > 0 && { guids })
|
|
742
897
|
};
|
|
743
898
|
|
|
744
899
|
// Save new contact to contacts/ folder
|
|
@@ -766,32 +921,85 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
766
921
|
console.log(' done');
|
|
767
922
|
successCount++;
|
|
768
923
|
} 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
924
|
const contactFile = path.join(paths.contactsDir, `${change.resourceName.replace('people/', '')}.json`);
|
|
784
|
-
|
|
785
|
-
|
|
925
|
+
const fileToRead = fs.existsSync(contactFile) ? contactFile : change.filePath;
|
|
926
|
+
|
|
927
|
+
// Check if contact has a real photo or is starred - skip deletion if so
|
|
928
|
+
let contactHasPhoto = false;
|
|
929
|
+
let contactIsStarred = false;
|
|
930
|
+
if (fs.existsSync(fileToRead)) {
|
|
931
|
+
try {
|
|
932
|
+
const contact = JSON.parse(fs.readFileSync(fileToRead, 'utf-8')) as GooglePerson;
|
|
933
|
+
contactHasPhoto = hasNonDefaultPhoto(contact);
|
|
934
|
+
contactIsStarred = isStarred(contact);
|
|
935
|
+
} catch { /* ignore read errors */ }
|
|
786
936
|
}
|
|
787
937
|
|
|
788
|
-
//
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
938
|
+
// Determine skip reason (photo takes precedence over starred)
|
|
939
|
+
// Use * prefix to indicate "skipped, not deleted"
|
|
940
|
+
// 'force' overrides photo/starred protection
|
|
941
|
+
const isForced = change._delete === 'force' || index.contacts[change.resourceName]?._delete === 'force';
|
|
942
|
+
const skipReason = isForced ? null : (contactHasPhoto ? '*photo' : (contactIsStarred ? '*starred' : null));
|
|
943
|
+
|
|
944
|
+
if (skipReason) {
|
|
945
|
+
// Skip Google deletion - mark why in index.json
|
|
946
|
+
notDeleted.push({
|
|
947
|
+
resourceName: change.resourceName,
|
|
948
|
+
displayName: change.displayName,
|
|
949
|
+
filePath: fileToRead,
|
|
950
|
+
reason: skipReason
|
|
951
|
+
});
|
|
952
|
+
if (skipReason === '*photo') skippedPhoto++;
|
|
953
|
+
else if (skipReason === '*starred') skippedStarred++;
|
|
954
|
+
console.log(`${ts()} [${String(processed).padStart(5)}/${total}] SKIPPED ${change.displayName} (${skipReason})`);
|
|
955
|
+
|
|
956
|
+
// Update _delete to show skip reason
|
|
957
|
+
if (index.contacts[change.resourceName]) {
|
|
958
|
+
index.contacts[change.resourceName]._delete = skipReason;
|
|
959
|
+
}
|
|
792
960
|
|
|
793
|
-
|
|
794
|
-
|
|
961
|
+
// Remove from _delete/ folder if it was there (keep in contacts/)
|
|
962
|
+
if (change.filePath !== fileToRead && change.filePath.startsWith(paths.toDeleteDir) && fs.existsSync(change.filePath)) {
|
|
963
|
+
fs.unlinkSync(change.filePath);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
completedDeletions.add(change.resourceName); // Remove from queue even if skipped
|
|
967
|
+
} else {
|
|
968
|
+
process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Deleting ${change.displayName}...`);
|
|
969
|
+
await deleteContactOnGoogle(change.resourceName);
|
|
970
|
+
|
|
971
|
+
// Move to deleted.json (preserve _delete reason)
|
|
972
|
+
const entry = index.contacts[change.resourceName];
|
|
973
|
+
if (entry) {
|
|
974
|
+
const deletedEntry = { ...entry, deletedAt: new Date().toISOString() };
|
|
975
|
+
// Keep _delete reason in deleted entry (it's the reason for deletion)
|
|
976
|
+
deletedIndex.deleted[change.resourceName] = deletedEntry;
|
|
977
|
+
delete index.contacts[change.resourceName];
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// Move contact file to deleted/ folder (backup with timestamp)
|
|
981
|
+
if (!fs.existsSync(paths.deletedDir)) {
|
|
982
|
+
fs.mkdirSync(paths.deletedDir, { recursive: true });
|
|
983
|
+
}
|
|
984
|
+
if (fs.existsSync(contactFile)) {
|
|
985
|
+
// Add deletion metadata to the JSON file
|
|
986
|
+
const contactData = JSON.parse(fs.readFileSync(contactFile, 'utf-8'));
|
|
987
|
+
contactData._deletedAt = new Date().toISOString();
|
|
988
|
+
contactData._delete = change._delete || entry?._delete || 'unknown';
|
|
989
|
+
const destPath = path.join(paths.deletedDir, path.basename(contactFile));
|
|
990
|
+
fs.writeFileSync(destPath, JSON.stringify(contactData, null, 2));
|
|
991
|
+
fs.unlinkSync(contactFile);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Remove from _delete/ folder if it was there
|
|
995
|
+
if (change.filePath.startsWith(paths.toDeleteDir) && fs.existsSync(change.filePath)) {
|
|
996
|
+
fs.unlinkSync(change.filePath);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
completedDeletions.add(change.resourceName);
|
|
1000
|
+
console.log(' done');
|
|
1001
|
+
successCount++;
|
|
1002
|
+
}
|
|
795
1003
|
}
|
|
796
1004
|
|
|
797
1005
|
await sleep(700); // 90 writes/min limit = ~700ms between ops
|
|
@@ -811,12 +1019,27 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
811
1019
|
console.log(`\nErrors written to: ${problemsFile}`);
|
|
812
1020
|
}
|
|
813
1021
|
|
|
1022
|
+
// Write notdeleted file if any contacts were skipped due to photos
|
|
1023
|
+
if (notDeleted.length > 0) {
|
|
1024
|
+
const notDeletedFile = path.join(paths.userDir, 'notdeleted.json');
|
|
1025
|
+
fs.writeFileSync(notDeletedFile, JSON.stringify(notDeleted, null, 2));
|
|
1026
|
+
console.log(`\n${notDeleted.length} contacts with photos not deleted from Google (see notdeleted.json)`);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Remove completed deletions from queue
|
|
1030
|
+
if (completedDeletions.size > 0) {
|
|
1031
|
+
deleteQueue.entries = deleteQueue.entries.filter(e => !completedDeletions.has(e.resourceName));
|
|
1032
|
+
saveDeleteQueue(paths, deleteQueue);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
814
1035
|
// Save status with new timestamp
|
|
815
1036
|
saveStatus(paths, { lastPush: new Date().toISOString() });
|
|
816
1037
|
saveIndex(paths, index);
|
|
817
1038
|
saveDeleted(paths, deletedIndex);
|
|
818
1039
|
|
|
819
|
-
|
|
1040
|
+
const skipped = skippedPhoto + skippedStarred;
|
|
1041
|
+
const skippedDetails = skipped > 0 ? ` (${skippedPhoto} photo, ${skippedStarred} starred)` : '';
|
|
1042
|
+
console.log(`\nPush complete: ${successCount} succeeded, ${errorCount} failed, ${skipped} skipped${skippedDetails}`);
|
|
820
1043
|
}
|
|
821
1044
|
|
|
822
1045
|
async function main(): Promise<void> {
|
|
@@ -861,7 +1084,7 @@ async function main(): Promise<void> {
|
|
|
861
1084
|
await syncContacts(user, { full: options.full, verbose: options.verbose });
|
|
862
1085
|
break;
|
|
863
1086
|
case 'push':
|
|
864
|
-
await pushContacts(user, { yes: options.yes, verbose: options.verbose, limit: options.limit });
|
|
1087
|
+
await pushContacts(user, { yes: options.yes, verbose: options.verbose, limit: options.limit, since: options.since });
|
|
865
1088
|
break;
|
|
866
1089
|
default:
|
|
867
1090
|
console.error(`Unknown command: ${options.command}`);
|
package/gfix.ts
CHANGED
|
@@ -10,10 +10,24 @@
|
|
|
10
10
|
|
|
11
11
|
import fs from 'fs';
|
|
12
12
|
import path from 'path';
|
|
13
|
+
import readline from 'readline';
|
|
13
14
|
import type { GooglePerson, GoogleName, GooglePhoneNumber, GoogleEmailAddress, GoogleBirthday } from './glib/types.ts';
|
|
14
|
-
import {
|
|
15
|
+
import type { DeleteQueue, DeleteQueueEntry, UserPaths } from './glib/gctypes.ts';
|
|
16
|
+
import { DATA_DIR, resolveUser, getUserPaths, loadIndex, getAllUsers, normalizeUser } from './glib/gutils.ts';
|
|
15
17
|
import { mergeContacts as mergeContactData, collectSourcePhotos, type MergeEntry, type PhotoEntry } from './glib/gmerge.ts';
|
|
16
18
|
|
|
19
|
+
function loadDeleteQueue(paths: UserPaths): DeleteQueue {
|
|
20
|
+
if (fs.existsSync(paths.deleteQueueFile)) {
|
|
21
|
+
return JSON.parse(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
|
|
22
|
+
}
|
|
23
|
+
return { updatedAt: '', entries: [] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function saveDeleteQueue(paths: UserPaths, queue: DeleteQueue): void {
|
|
27
|
+
queue.updatedAt = new Date().toISOString();
|
|
28
|
+
fs.writeFileSync(paths.deleteQueueFile, JSON.stringify(queue, null, 2));
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
interface BirthdayEntry {
|
|
18
32
|
name: string;
|
|
19
33
|
date: string;
|
|
@@ -1243,15 +1257,23 @@ async function runMerge(user: string, limit: number): Promise<void> {
|
|
|
1243
1257
|
}
|
|
1244
1258
|
}
|
|
1245
1259
|
|
|
1246
|
-
// Load existing
|
|
1260
|
+
// Load existing files
|
|
1247
1261
|
let savedPhotos: PhotoEntry[] = [];
|
|
1248
1262
|
if (fs.existsSync(photosPath)) {
|
|
1249
1263
|
savedPhotos = JSON.parse(fs.readFileSync(photosPath, 'utf-8'));
|
|
1250
1264
|
}
|
|
1251
1265
|
|
|
1266
|
+
const deleteQueue = loadDeleteQueue(paths);
|
|
1267
|
+
const index = loadIndex(paths);
|
|
1268
|
+
|
|
1252
1269
|
let mergedEntries: MergeEntry[] = [];
|
|
1253
1270
|
if (fs.existsSync(mergedPath)) {
|
|
1254
|
-
|
|
1271
|
+
const content = fs.readFileSync(mergedPath, 'utf-8').trim();
|
|
1272
|
+
if (content) {
|
|
1273
|
+
try {
|
|
1274
|
+
mergedEntries = JSON.parse(content);
|
|
1275
|
+
} catch { /* ignore parse errors, start fresh */ }
|
|
1276
|
+
}
|
|
1255
1277
|
}
|
|
1256
1278
|
|
|
1257
1279
|
let mergeSuccess = 0;
|
|
@@ -1304,12 +1326,20 @@ async function runMerge(user: string, limit: number): Promise<void> {
|
|
|
1304
1326
|
fs.writeFileSync(targetPath, JSON.stringify(updatedTarget, null, 2));
|
|
1305
1327
|
console.log(` Updated ${targetPath}`);
|
|
1306
1328
|
|
|
1307
|
-
//
|
|
1329
|
+
// Add sources to delete queue and set _delete reason in index
|
|
1308
1330
|
for (const source of sources) {
|
|
1309
1331
|
const sourceId = source.resourceName.replace('people/', '');
|
|
1310
|
-
const
|
|
1311
|
-
|
|
1312
|
-
|
|
1332
|
+
const displayName = source.names?.[0]?.displayName || 'Unknown';
|
|
1333
|
+
deleteQueue.entries.push({
|
|
1334
|
+
resourceName: source.resourceName,
|
|
1335
|
+
displayName,
|
|
1336
|
+
addedAt: new Date().toISOString(),
|
|
1337
|
+
_delete: 'duplicate'
|
|
1338
|
+
});
|
|
1339
|
+
// Set _delete reason in index.json
|
|
1340
|
+
if (index.contacts[source.resourceName]) {
|
|
1341
|
+
index.contacts[source.resourceName]._delete = 'duplicate';
|
|
1342
|
+
}
|
|
1313
1343
|
console.log(` Marked ${sourceId} for deletion`);
|
|
1314
1344
|
}
|
|
1315
1345
|
|
|
@@ -1321,7 +1351,7 @@ async function runMerge(user: string, limit: number): Promise<void> {
|
|
|
1321
1351
|
}
|
|
1322
1352
|
}
|
|
1323
1353
|
|
|
1324
|
-
// Process deletes -
|
|
1354
|
+
// Process deletes - add to delete queue and set _delete reason
|
|
1325
1355
|
for (const entry of toDelete) {
|
|
1326
1356
|
console.log(`\nMarking for deletion: ${entry.name}...`);
|
|
1327
1357
|
|
|
@@ -1329,9 +1359,18 @@ async function runMerge(user: string, limit: number): Promise<void> {
|
|
|
1329
1359
|
const id = rn.replace('people/', '');
|
|
1330
1360
|
const filePath = path.join(paths.contactsDir, `${id}.json`);
|
|
1331
1361
|
if (fs.existsSync(filePath)) {
|
|
1332
|
-
const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
1333
|
-
const
|
|
1334
|
-
|
|
1362
|
+
const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
|
|
1363
|
+
const displayName = contact.names?.[0]?.displayName || entry.name;
|
|
1364
|
+
deleteQueue.entries.push({
|
|
1365
|
+
resourceName: rn,
|
|
1366
|
+
displayName,
|
|
1367
|
+
addedAt: new Date().toISOString(),
|
|
1368
|
+
_delete: 'duplicate'
|
|
1369
|
+
});
|
|
1370
|
+
// Set _delete reason in index.json
|
|
1371
|
+
if (index.contacts[rn]) {
|
|
1372
|
+
index.contacts[rn]._delete = 'duplicate';
|
|
1373
|
+
}
|
|
1335
1374
|
console.log(` Marked ${id} for deletion`);
|
|
1336
1375
|
deleteSuccess++;
|
|
1337
1376
|
}
|
|
@@ -1347,6 +1386,12 @@ async function runMerge(user: string, limit: number): Promise<void> {
|
|
|
1347
1386
|
// Save merged.json (history of processed entries)
|
|
1348
1387
|
fs.writeFileSync(mergedPath, JSON.stringify(mergedEntries, null, 2));
|
|
1349
1388
|
|
|
1389
|
+
// Save delete queue
|
|
1390
|
+
saveDeleteQueue(paths, deleteQueue);
|
|
1391
|
+
|
|
1392
|
+
// Save index.json with _delete reasons
|
|
1393
|
+
fs.writeFileSync(paths.indexFile, JSON.stringify(index, null, 2));
|
|
1394
|
+
|
|
1350
1395
|
// Remove processed entries from merger.json
|
|
1351
1396
|
const remainingEntries = JSON.parse(fs.readFileSync(mergerPath, 'utf-8')) as MergeEntry[];
|
|
1352
1397
|
const processedNames = new Set(entries.map(e => e.name));
|
|
@@ -1361,6 +1406,387 @@ async function runMerge(user: string, limit: number): Promise<void> {
|
|
|
1361
1406
|
console.log(`\nRun 'gcards push -u ${user}' to apply changes to Google.`);
|
|
1362
1407
|
}
|
|
1363
1408
|
|
|
1409
|
+
// ============================================================
|
|
1410
|
+
// Export Feature - Transfer contacts between users
|
|
1411
|
+
// ============================================================
|
|
1412
|
+
|
|
1413
|
+
interface ContactMatch {
|
|
1414
|
+
id: string; /** JSON file id (without .json) */
|
|
1415
|
+
displayName: string;
|
|
1416
|
+
emails: string[];
|
|
1417
|
+
filePath: string;
|
|
1418
|
+
source: 'contacts' | 'deleted';
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** Find a contact by ID in contacts or deleted directories */
|
|
1422
|
+
function findContactById(paths: UserPaths, id: string): { contact: GooglePerson; filePath: string; source: 'contacts' | 'deleted' } {
|
|
1423
|
+
const cleanId = id.replace(/\.json$/i, '');
|
|
1424
|
+
|
|
1425
|
+
// Check contacts directory
|
|
1426
|
+
const contactPath = path.join(paths.contactsDir, `${cleanId}.json`);
|
|
1427
|
+
if (fs.existsSync(contactPath)) {
|
|
1428
|
+
return {
|
|
1429
|
+
contact: JSON.parse(fs.readFileSync(contactPath, 'utf-8')),
|
|
1430
|
+
filePath: contactPath,
|
|
1431
|
+
source: 'contacts'
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// Check deleted directory
|
|
1436
|
+
const deletedPath = path.join(paths.deletedDir, `${cleanId}.json`);
|
|
1437
|
+
if (fs.existsSync(deletedPath)) {
|
|
1438
|
+
return {
|
|
1439
|
+
contact: JSON.parse(fs.readFileSync(deletedPath, 'utf-8')),
|
|
1440
|
+
filePath: deletedPath,
|
|
1441
|
+
source: 'deleted'
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
return null;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/** Match users by partial name - returns matching user names */
|
|
1449
|
+
function matchUsers(pattern: string): string[] {
|
|
1450
|
+
const allUsers = getAllUsers();
|
|
1451
|
+
const normalizedPattern = pattern.toLowerCase();
|
|
1452
|
+
|
|
1453
|
+
return allUsers.filter(user => user.toLowerCase().includes(normalizedPattern));
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/** Convert wildcard pattern to regex (supports * and ?) */
|
|
1457
|
+
function wildcardToRegex(pattern: string): RegExp {
|
|
1458
|
+
const escaped = pattern
|
|
1459
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars
|
|
1460
|
+
.replace(/\*/g, '.*') // * matches anything
|
|
1461
|
+
.replace(/\?/g, '.'); // ? matches single char
|
|
1462
|
+
return new RegExp(`^${escaped}$`, 'i');
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/** Search contacts by name or email pattern */
|
|
1466
|
+
function searchContacts(paths: UserPaths, pattern: string): ContactMatch[] {
|
|
1467
|
+
const regex = wildcardToRegex(pattern);
|
|
1468
|
+
const matches: ContactMatch[] = [];
|
|
1469
|
+
|
|
1470
|
+
// Search in contacts directory
|
|
1471
|
+
if (fs.existsSync(paths.contactsDir)) {
|
|
1472
|
+
for (const file of fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'))) {
|
|
1473
|
+
const filePath = path.join(paths.contactsDir, file);
|
|
1474
|
+
const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
|
|
1475
|
+
if (matchesContact(contact, regex)) {
|
|
1476
|
+
matches.push({
|
|
1477
|
+
id: file.replace(/\.json$/, ''),
|
|
1478
|
+
displayName: getContactDisplayName(contact),
|
|
1479
|
+
emails: (contact.emailAddresses || []).map(e => e.value).filter(Boolean) as string[],
|
|
1480
|
+
filePath,
|
|
1481
|
+
source: 'contacts'
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// Search in deleted directory
|
|
1488
|
+
if (fs.existsSync(paths.deletedDir)) {
|
|
1489
|
+
for (const file of fs.readdirSync(paths.deletedDir).filter(f => f.endsWith('.json'))) {
|
|
1490
|
+
const filePath = path.join(paths.deletedDir, file);
|
|
1491
|
+
const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
|
|
1492
|
+
if (matchesContact(contact, regex)) {
|
|
1493
|
+
matches.push({
|
|
1494
|
+
id: file.replace(/\.json$/, ''),
|
|
1495
|
+
displayName: getContactDisplayName(contact),
|
|
1496
|
+
emails: (contact.emailAddresses || []).map(e => e.value).filter(Boolean) as string[],
|
|
1497
|
+
filePath,
|
|
1498
|
+
source: 'deleted'
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
return matches;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/** Check if contact matches regex on name or email */
|
|
1508
|
+
function matchesContact(contact: GooglePerson, regex: RegExp): boolean {
|
|
1509
|
+
// Check display name
|
|
1510
|
+
const name = contact.names?.[0];
|
|
1511
|
+
if (name) {
|
|
1512
|
+
if (regex.test(name.displayName || '')) return true;
|
|
1513
|
+
if (regex.test(name.givenName || '')) return true;
|
|
1514
|
+
if (regex.test(name.familyName || '')) return true;
|
|
1515
|
+
// "Last, First" format
|
|
1516
|
+
if (name.givenName && name.familyName) {
|
|
1517
|
+
if (regex.test(`${name.familyName}, ${name.givenName}`)) return true;
|
|
1518
|
+
if (regex.test(`${name.givenName} ${name.familyName}`)) return true;
|
|
1519
|
+
}
|
|
1520
|
+
if (regex.test(name.displayNameLastFirst || '')) return true;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// Check emails
|
|
1524
|
+
for (const email of contact.emailAddresses || []) {
|
|
1525
|
+
if (regex.test(email.value || '')) return true;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
return false;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/** Get display name for a contact */
|
|
1532
|
+
function getContactDisplayName(contact: GooglePerson): string {
|
|
1533
|
+
const name = contact.names?.[0];
|
|
1534
|
+
return name?.displayName || name?.givenName || name?.familyName || 'Unknown';
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/** Clean contact for export - remove Google-specific metadata */
|
|
1538
|
+
function cleanContactForExport(contact: GooglePerson): GooglePerson {
|
|
1539
|
+
const cleaned = { ...contact };
|
|
1540
|
+
|
|
1541
|
+
// Remove resource-specific fields that will be regenerated
|
|
1542
|
+
delete cleaned.resourceName;
|
|
1543
|
+
delete cleaned.etag;
|
|
1544
|
+
delete (cleaned as any)._delete;
|
|
1545
|
+
delete (cleaned as any)._deleted;
|
|
1546
|
+
|
|
1547
|
+
// Remove metadata that links to source account
|
|
1548
|
+
if (cleaned.metadata) {
|
|
1549
|
+
delete cleaned.metadata.sources;
|
|
1550
|
+
delete cleaned.metadata.previousResourceNames;
|
|
1551
|
+
delete cleaned.metadata.linkedPeopleResourceNames;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// Clean field metadata (source references)
|
|
1555
|
+
const cleanFieldMetadata = (items: any[]) => {
|
|
1556
|
+
if (!items) return;
|
|
1557
|
+
for (const item of items) {
|
|
1558
|
+
if (item.metadata) {
|
|
1559
|
+
delete item.metadata.source;
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
};
|
|
1563
|
+
|
|
1564
|
+
cleanFieldMetadata(cleaned.names);
|
|
1565
|
+
cleanFieldMetadata(cleaned.emailAddresses);
|
|
1566
|
+
cleanFieldMetadata(cleaned.phoneNumbers);
|
|
1567
|
+
cleanFieldMetadata(cleaned.addresses);
|
|
1568
|
+
cleanFieldMetadata(cleaned.organizations);
|
|
1569
|
+
cleanFieldMetadata(cleaned.urls);
|
|
1570
|
+
cleanFieldMetadata(cleaned.biographies);
|
|
1571
|
+
cleanFieldMetadata(cleaned.photos);
|
|
1572
|
+
cleanFieldMetadata(cleaned.birthdays);
|
|
1573
|
+
cleanFieldMetadata(cleaned.relations);
|
|
1574
|
+
cleanFieldMetadata(cleaned.events);
|
|
1575
|
+
cleanFieldMetadata(cleaned.memberships);
|
|
1576
|
+
cleanFieldMetadata(cleaned.userDefined);
|
|
1577
|
+
|
|
1578
|
+
return cleaned;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
/** Prompt user for selection when multiple matches */
|
|
1582
|
+
async function promptSelection(matches: ContactMatch[]): Promise<string[]> {
|
|
1583
|
+
console.log('\nMultiple contacts found:');
|
|
1584
|
+
for (let i = 0; i < matches.length; i++) {
|
|
1585
|
+
const m = matches[i];
|
|
1586
|
+
const emailStr = m.emails.length > 0 ? ` <${m.emails[0]}>` : '';
|
|
1587
|
+
const srcStr = m.source === 'deleted' ? ' [deleted]' : '';
|
|
1588
|
+
console.log(` ${i + 1}. ${m.displayName}${emailStr}${srcStr} (${m.id})`);
|
|
1589
|
+
}
|
|
1590
|
+
console.log('\nEnter selection: number(s) separated by commas, * for all, or q to quit');
|
|
1591
|
+
|
|
1592
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1593
|
+
|
|
1594
|
+
return new Promise((resolve) => {
|
|
1595
|
+
rl.question('> ', (answer) => {
|
|
1596
|
+
rl.close();
|
|
1597
|
+
const trimmed = answer.trim().toLowerCase();
|
|
1598
|
+
|
|
1599
|
+
if (trimmed === 'q' || trimmed === '') {
|
|
1600
|
+
resolve([]);
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
if (trimmed === '*') {
|
|
1605
|
+
resolve(matches.map(m => m.id));
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// Parse comma-separated numbers
|
|
1610
|
+
const selected: string[] = [];
|
|
1611
|
+
for (const part of trimmed.split(',')) {
|
|
1612
|
+
const num = parseInt(part.trim(), 10);
|
|
1613
|
+
if (num >= 1 && num <= matches.length) {
|
|
1614
|
+
selected.push(matches[num - 1].id);
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
resolve(selected);
|
|
1618
|
+
});
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/** Export contacts from source user to target user */
|
|
1623
|
+
async function runExport(sourceUser: string, targetPattern: string, contactPattern: string): Promise<void> {
|
|
1624
|
+
// Resolve target user by partial match
|
|
1625
|
+
const targetMatches = matchUsers(targetPattern);
|
|
1626
|
+
|
|
1627
|
+
if (targetMatches.length === 0) {
|
|
1628
|
+
console.error(`No users match pattern: ${targetPattern}`);
|
|
1629
|
+
console.error(`Available users: ${getAllUsers().join(', ')}`);
|
|
1630
|
+
process.exit(1);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
if (targetMatches.length > 1) {
|
|
1634
|
+
console.error(`Multiple users match pattern "${targetPattern}":`);
|
|
1635
|
+
for (const u of targetMatches) {
|
|
1636
|
+
console.error(` - ${u}`);
|
|
1637
|
+
}
|
|
1638
|
+
console.error('Please be more specific.');
|
|
1639
|
+
process.exit(1);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
const targetUser = targetMatches[0];
|
|
1643
|
+
|
|
1644
|
+
if (normalizeUser(sourceUser) === normalizeUser(targetUser)) {
|
|
1645
|
+
console.error('Source and target users cannot be the same.');
|
|
1646
|
+
process.exit(1);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
const sourcePaths = getUserPaths(sourceUser);
|
|
1650
|
+
const targetPaths = getUserPaths(targetUser);
|
|
1651
|
+
|
|
1652
|
+
// Ensure target _add directory exists
|
|
1653
|
+
if (!fs.existsSync(targetPaths.toAddDir)) {
|
|
1654
|
+
fs.mkdirSync(targetPaths.toAddDir, { recursive: true });
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// Find contacts to export
|
|
1658
|
+
let contactIds: string[] = [];
|
|
1659
|
+
|
|
1660
|
+
// Check if contactPattern looks like an ID (alphanumeric, possibly with .json)
|
|
1661
|
+
const cleanPattern = contactPattern.replace(/\.json$/i, '');
|
|
1662
|
+
const found = findContactById(sourcePaths, cleanPattern);
|
|
1663
|
+
|
|
1664
|
+
if (found) {
|
|
1665
|
+
// Direct ID match
|
|
1666
|
+
contactIds = [cleanPattern];
|
|
1667
|
+
} else {
|
|
1668
|
+
// Search by name/email pattern
|
|
1669
|
+
const matches = searchContacts(sourcePaths, contactPattern);
|
|
1670
|
+
|
|
1671
|
+
if (matches.length === 0) {
|
|
1672
|
+
console.error(`No contacts match pattern: ${contactPattern}`);
|
|
1673
|
+
process.exit(1);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
if (matches.length === 1) {
|
|
1677
|
+
contactIds = [matches[0].id];
|
|
1678
|
+
} else {
|
|
1679
|
+
contactIds = await promptSelection(matches);
|
|
1680
|
+
if (contactIds.length === 0) {
|
|
1681
|
+
console.log('No contacts selected.');
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// Export each selected contact
|
|
1688
|
+
let exported = 0;
|
|
1689
|
+
for (const id of contactIds) {
|
|
1690
|
+
const result = findContactById(sourcePaths, id);
|
|
1691
|
+
if (!result) {
|
|
1692
|
+
console.error(`Contact not found: ${id}`);
|
|
1693
|
+
continue;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
const { contact, source } = result;
|
|
1697
|
+
const displayName = getContactDisplayName(contact);
|
|
1698
|
+
|
|
1699
|
+
// Clean the contact for export
|
|
1700
|
+
const cleaned = cleanContactForExport(contact);
|
|
1701
|
+
|
|
1702
|
+
// Generate unique filename: x<sourceId>.json
|
|
1703
|
+
const exportFileName = `x${id}.json`;
|
|
1704
|
+
const exportPath = path.join(targetPaths.toAddDir, exportFileName);
|
|
1705
|
+
|
|
1706
|
+
// Check for existing file
|
|
1707
|
+
if (fs.existsSync(exportPath)) {
|
|
1708
|
+
console.log(` [SKIP] ${displayName} - already exists: ${exportFileName}`);
|
|
1709
|
+
continue;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
fs.writeFileSync(exportPath, JSON.stringify(cleaned, null, 2));
|
|
1713
|
+
console.log(` ${displayName} -> ${targetUser}/_add/${exportFileName}${source === 'deleted' ? ' [from deleted]' : ''}`);
|
|
1714
|
+
exported++;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
console.log(`\n${'='.repeat(50)}`);
|
|
1718
|
+
console.log(`Exported: ${exported} contact(s) to ${targetUser}/_add/`);
|
|
1719
|
+
if (exported > 0) {
|
|
1720
|
+
console.log(`\nTo upload, run: gcards push -u ${targetUser}`);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
/** Reset ALL delete markers: index.json, contact files, _delete.json, _delete/ folder */
|
|
1725
|
+
async function runReset(user: string): Promise<void> {
|
|
1726
|
+
const paths = getUserPaths(user);
|
|
1727
|
+
|
|
1728
|
+
let indexCleared = 0;
|
|
1729
|
+
let filesCleared = 0;
|
|
1730
|
+
let queueCleared = 0;
|
|
1731
|
+
let folderMoved = 0;
|
|
1732
|
+
|
|
1733
|
+
// Clear from index.json
|
|
1734
|
+
if (fs.existsSync(paths.indexFile)) {
|
|
1735
|
+
const index = JSON.parse(fs.readFileSync(paths.indexFile, 'utf-8'));
|
|
1736
|
+
for (const entry of Object.values(index.contacts) as any[]) {
|
|
1737
|
+
if (entry._delete) {
|
|
1738
|
+
delete entry._delete;
|
|
1739
|
+
indexCleared++;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
fs.writeFileSync(paths.indexFile, JSON.stringify(index, null, 2));
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// Clear from contact JSON files (preserve mtime to avoid false "modified" detection)
|
|
1746
|
+
if (fs.existsSync(paths.contactsDir)) {
|
|
1747
|
+
const files = fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'));
|
|
1748
|
+
for (const file of files) {
|
|
1749
|
+
const filePath = path.join(paths.contactsDir, file);
|
|
1750
|
+
const stat = fs.statSync(filePath);
|
|
1751
|
+
const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
1752
|
+
if (content._delete || content._deleted) {
|
|
1753
|
+
delete content._delete;
|
|
1754
|
+
delete content._deleted;
|
|
1755
|
+
fs.writeFileSync(filePath, JSON.stringify(content, null, 2));
|
|
1756
|
+
// Restore original mtime so file doesn't appear modified
|
|
1757
|
+
fs.utimesSync(filePath, stat.atime, stat.mtime);
|
|
1758
|
+
filesCleared++;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// Clear _delete.json queue
|
|
1764
|
+
if (fs.existsSync(paths.deleteQueueFile)) {
|
|
1765
|
+
const queue = JSON.parse(fs.readFileSync(paths.deleteQueueFile, 'utf-8'));
|
|
1766
|
+
queueCleared = queue.entries?.length || 0;
|
|
1767
|
+
fs.unlinkSync(paths.deleteQueueFile);
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// Move files from _delete/ folder back to contacts/
|
|
1771
|
+
if (fs.existsSync(paths.toDeleteDir)) {
|
|
1772
|
+
const files = fs.readdirSync(paths.toDeleteDir).filter(f => f.endsWith('.json'));
|
|
1773
|
+
for (const file of files) {
|
|
1774
|
+
const src = path.join(paths.toDeleteDir, file);
|
|
1775
|
+
const dest = path.join(paths.contactsDir, file);
|
|
1776
|
+
fs.renameSync(src, dest);
|
|
1777
|
+
folderMoved++;
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
console.log(`Reset complete for user: ${user}`);
|
|
1782
|
+
console.log(` Index entries: ${indexCleared}`);
|
|
1783
|
+
console.log(` Contact files: ${filesCleared}`);
|
|
1784
|
+
console.log(` Queue entries: ${queueCleared}`);
|
|
1785
|
+
if (folderMoved > 0) {
|
|
1786
|
+
console.log(` Moved from _delete/: ${folderMoved}`);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1364
1790
|
function showUsage(): void {
|
|
1365
1791
|
console.log(`
|
|
1366
1792
|
gfix - One-time fix routines for gcards contacts
|
|
@@ -1375,9 +1801,12 @@ Commands:
|
|
|
1375
1801
|
names Parse givenName into first/middle/last, clean fileAs, remove dup phones/emails
|
|
1376
1802
|
undup Find duplicate contacts (same name + overlapping email) -> merger.json
|
|
1377
1803
|
merge Merge duplicates locally (then use gcards push)
|
|
1804
|
+
reset Clear all _delete flags from index.json
|
|
1805
|
+
export Transfer contact(s) to another user's _add directory
|
|
1378
1806
|
|
|
1379
1807
|
Options:
|
|
1380
|
-
-u, --user <name> User profile to process
|
|
1808
|
+
-u, --user <name> User profile to process (source for export)
|
|
1809
|
+
-to <user> Target user for export (partial match OK)
|
|
1381
1810
|
--apply For birthday/fileas/names: actually apply changes
|
|
1382
1811
|
-limit <n> Process only first n contacts (for testing)
|
|
1383
1812
|
|
|
@@ -1407,6 +1836,16 @@ Duplicate detection and merge:
|
|
|
1407
1836
|
- gcards push -u bob # Push changes to Google
|
|
1408
1837
|
- gcards sync -u bob --full # Resync to get updated contacts
|
|
1409
1838
|
|
|
1839
|
+
Export contacts to another user:
|
|
1840
|
+
- gfix export -u bob -to alice "John*" # Export contacts matching "John*" to alice
|
|
1841
|
+
- gfix export -u bob -to ali "*@example.com" # Export by email pattern (ali matches alice)
|
|
1842
|
+
- gfix export -u bob -to alice c1234567890 # Export by contact ID (with or without .json)
|
|
1843
|
+
- Pattern supports * (any chars) and ? (single char) wildcards
|
|
1844
|
+
- Matches on: displayName, givenName, familyName, "Last, First", email
|
|
1845
|
+
- Searches both contacts/ and deleted/ directories
|
|
1846
|
+
- Multiple matches prompt for selection: number(s), * for all, q to quit
|
|
1847
|
+
- Creates cleaned file in target's _add/ directory as x<sourceId>.json
|
|
1848
|
+
|
|
1410
1849
|
Workflow:
|
|
1411
1850
|
1. gfix inspect -u bob # Review proposed changes
|
|
1412
1851
|
2. gfix apply -u bob # Apply changes to local files
|
|
@@ -1422,18 +1861,26 @@ async function main(): Promise<void> {
|
|
|
1422
1861
|
|
|
1423
1862
|
let command = '';
|
|
1424
1863
|
let user = '';
|
|
1864
|
+
let targetUser = '';
|
|
1425
1865
|
let applyFlag = false;
|
|
1866
|
+
const positionalArgs: string[] = [];
|
|
1426
1867
|
|
|
1427
1868
|
for (let i = 0; i < args.length; i++) {
|
|
1428
1869
|
const arg = args[i];
|
|
1429
1870
|
if ((arg === '-u' || arg === '--user' || arg === '-user') && i + 1 < args.length) {
|
|
1430
1871
|
user = args[++i];
|
|
1872
|
+
} else if ((arg === '-to' || arg === '--to') && i + 1 < args.length) {
|
|
1873
|
+
targetUser = args[++i];
|
|
1431
1874
|
} else if (arg === '--apply') {
|
|
1432
1875
|
applyFlag = true;
|
|
1433
1876
|
} else if ((arg === '-limit' || arg === '--limit') && i + 1 < args.length) {
|
|
1434
1877
|
processLimit = parseInt(args[++i], 10) || 0;
|
|
1435
|
-
} else if (!arg.startsWith('-')
|
|
1436
|
-
command
|
|
1878
|
+
} else if (!arg.startsWith('-')) {
|
|
1879
|
+
if (!command) {
|
|
1880
|
+
command = arg;
|
|
1881
|
+
} else {
|
|
1882
|
+
positionalArgs.push(arg);
|
|
1883
|
+
}
|
|
1437
1884
|
}
|
|
1438
1885
|
}
|
|
1439
1886
|
|
|
@@ -1442,6 +1889,26 @@ async function main(): Promise<void> {
|
|
|
1442
1889
|
return;
|
|
1443
1890
|
}
|
|
1444
1891
|
|
|
1892
|
+
// Export command has different argument handling
|
|
1893
|
+
if (command === 'export') {
|
|
1894
|
+
if (!user) {
|
|
1895
|
+
console.error('Export requires -u <sourceUser>');
|
|
1896
|
+
process.exit(1);
|
|
1897
|
+
}
|
|
1898
|
+
if (!targetUser) {
|
|
1899
|
+
console.error('Export requires -to <targetUser>');
|
|
1900
|
+
process.exit(1);
|
|
1901
|
+
}
|
|
1902
|
+
if (positionalArgs.length === 0) {
|
|
1903
|
+
console.error('Export requires a contact pattern or ID');
|
|
1904
|
+
console.error('Usage: gfix export -u <source> -to <target> <pattern>');
|
|
1905
|
+
process.exit(1);
|
|
1906
|
+
}
|
|
1907
|
+
const resolvedSource = resolveUser(user);
|
|
1908
|
+
await runExport(resolvedSource, targetUser, positionalArgs[0]);
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1445
1912
|
const resolvedUser = resolveUser(user);
|
|
1446
1913
|
|
|
1447
1914
|
if (processLimit > 0) {
|
|
@@ -1460,6 +1927,8 @@ async function main(): Promise<void> {
|
|
|
1460
1927
|
await runUndup(resolvedUser);
|
|
1461
1928
|
} else if (command === 'merge') {
|
|
1462
1929
|
await runMerge(resolvedUser, processLimit);
|
|
1930
|
+
} else if (command === 'reset') {
|
|
1931
|
+
await runReset(resolvedUser);
|
|
1463
1932
|
} else {
|
|
1464
1933
|
console.error(`Unknown command: ${command}`);
|
|
1465
1934
|
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
|
}
|