@bobfrankston/gcards 0.1.2 → 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 +9 -1
- package/.gitattributes +1 -0
- package/.vscode/settings.json +22 -2
- package/README.md +229 -61
- package/gcards.ts +728 -193
- package/gfix.ts +1585 -0
- package/glib/gctypes.ts +132 -0
- package/glib/gmerge.ts +290 -0
- package/glib/gutils.ts +143 -0
- package/{cli.ts → glib/parsecli.ts} +45 -5
- package/{types.ts → glib/types.ts} +305 -305
- package/package.json +6 -4
package/gcards.ts
CHANGED
|
@@ -5,102 +5,58 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import fs from 'fs';
|
|
8
|
+
import fp from 'fs/promises';
|
|
8
9
|
import path from 'path';
|
|
9
|
-
import
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
import { parseArgs, showUsage, showHelp } from './glib/parsecli.ts';
|
|
10
12
|
import { authenticateOAuth } from '../../../projects/oauth/oauthsupport/index.ts';
|
|
11
|
-
import type { GooglePerson, GoogleConnectionsResponse } from './types.ts';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const CREDENTIALS_FILE = path.join(APP_DIR, 'credentials.json');
|
|
16
|
-
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
|
13
|
+
import type { GooglePerson, GoogleConnectionsResponse } from './glib/types.ts';
|
|
14
|
+
import { GCARDS_GUID_KEY, extractGuids } from './glib/gctypes.ts';
|
|
15
|
+
import type { ContactIndex, IndexEntry, DeletedEntry, DeleteQueue, DeleteQueueEntry, PushStatus, PendingChange, UserPaths } from './glib/gctypes.ts';
|
|
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';
|
|
19
19
|
const CONTACTS_SCOPE_READ = 'https://www.googleapis.com/auth/contacts.readonly';
|
|
20
20
|
const CONTACTS_SCOPE_WRITE = 'https://www.googleapis.com/auth/contacts';
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
function ts(): string {
|
|
23
|
+
const now = new Date();
|
|
24
|
+
return `[${now.toTimeString().slice(0, 8)}]`;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
userDir: string;
|
|
28
|
-
contactsDir: string;
|
|
29
|
-
deletedDir: string;
|
|
30
|
-
indexFile: string;
|
|
31
|
-
statusFile: string;
|
|
32
|
-
syncTokenFile: string;
|
|
33
|
-
tokenFile: string;
|
|
34
|
-
tokenWriteFile: string;
|
|
35
|
-
}
|
|
27
|
+
let escapePressed = false;
|
|
36
28
|
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
29
|
+
function setupEscapeHandler(): void {
|
|
30
|
+
// Handle Ctrl+C via SIGINT (works without raw mode)
|
|
31
|
+
process.on('SIGINT', () => {
|
|
32
|
+
escapePressed = true;
|
|
33
|
+
console.log('\n\nCtrl+C pressed - stopping after current operation...');
|
|
34
|
+
});
|
|
43
35
|
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
|
|
36
|
+
// Handle ESC key via raw mode (optional, for graceful stop)
|
|
37
|
+
if (process.stdin.isTTY) {
|
|
38
|
+
process.stdin.setRawMode(true);
|
|
39
|
+
process.stdin.resume();
|
|
40
|
+
process.stdin.on('data', (key) => {
|
|
41
|
+
if (key[0] === 27) { // ESC key
|
|
42
|
+
escapePressed = true;
|
|
43
|
+
console.log('\n\nESC pressed - stopping after current operation...');
|
|
44
|
+
} else if (key[0] === 3) { // Ctrl+C in raw mode
|
|
45
|
+
escapePressed = true;
|
|
46
|
+
console.log('\n\nCtrl+C pressed - stopping after current operation...');
|
|
47
|
+
}
|
|
48
|
+
});
|
|
47
49
|
}
|
|
48
|
-
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
function getUserPaths(user: string): UserPaths {
|
|
57
|
-
const userDir = path.join(DATA_DIR, user);
|
|
58
|
-
return {
|
|
59
|
-
userDir,
|
|
60
|
-
contactsDir: path.join(userDir, 'contacts'),
|
|
61
|
-
deletedDir: path.join(userDir, 'deleted'),
|
|
62
|
-
indexFile: path.join(userDir, 'index.json'),
|
|
63
|
-
statusFile: path.join(userDir, 'status.json'),
|
|
64
|
-
syncTokenFile: path.join(userDir, 'sync-token.json'),
|
|
65
|
-
tokenFile: path.join(userDir, 'token.json'),
|
|
66
|
-
tokenWriteFile: path.join(userDir, 'token-write.json')
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function ensureUserDir(user: string): void {
|
|
71
|
-
const paths = getUserPaths(user);
|
|
72
|
-
if (!fs.existsSync(paths.userDir)) {
|
|
73
|
-
fs.mkdirSync(paths.userDir, { recursive: true });
|
|
52
|
+
function cleanupEscapeHandler(): void {
|
|
53
|
+
if (process.stdin.isTTY) {
|
|
54
|
+
process.stdin.setRawMode(false);
|
|
55
|
+
process.stdin.pause();
|
|
74
56
|
}
|
|
75
57
|
}
|
|
76
58
|
|
|
77
|
-
|
|
78
|
-
contacts: Record<string, IndexEntry>; /** resourceName -> entry */
|
|
79
|
-
lastSync: string;
|
|
80
|
-
syncToken?: string;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
interface IndexEntry {
|
|
84
|
-
resourceName: string;
|
|
85
|
-
displayName: string;
|
|
86
|
-
etag?: string;
|
|
87
|
-
deleted: boolean;
|
|
88
|
-
deletedAt?: string;
|
|
89
|
-
updatedAt: string;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
interface PushStatus {
|
|
93
|
-
lastPush: string; /** ISO timestamp of last push */
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
interface PendingChange {
|
|
97
|
-
type: 'update' | 'delete';
|
|
98
|
-
resourceName: string;
|
|
99
|
-
displayName: string;
|
|
100
|
-
filePath: string;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async function getAccessToken(user: string, writeAccess = false): Promise<string> {
|
|
59
|
+
async function getAccessToken(user: string, writeAccess = false, forceRefresh = false): Promise<string> {
|
|
104
60
|
if (!fs.existsSync(CREDENTIALS_FILE)) {
|
|
105
61
|
throw new Error(`Credentials file not found: ${CREDENTIALS_FILE}`);
|
|
106
62
|
}
|
|
@@ -110,6 +66,13 @@ async function getAccessToken(user: string, writeAccess = false): Promise<string
|
|
|
110
66
|
|
|
111
67
|
const scope = writeAccess ? CONTACTS_SCOPE_WRITE : CONTACTS_SCOPE_READ;
|
|
112
68
|
const tokenFileName = writeAccess ? 'token-write.json' : 'token.json';
|
|
69
|
+
const tokenFilePath = path.join(paths.userDir, tokenFileName);
|
|
70
|
+
|
|
71
|
+
// Delete cached token to force re-auth
|
|
72
|
+
if (forceRefresh && fs.existsSync(tokenFilePath)) {
|
|
73
|
+
fs.unlinkSync(tokenFilePath);
|
|
74
|
+
console.log(`${ts()} Token expired, refreshing...`);
|
|
75
|
+
}
|
|
113
76
|
|
|
114
77
|
const token = await authenticateOAuth(CREDENTIALS_FILE, {
|
|
115
78
|
scope,
|
|
@@ -126,18 +89,122 @@ async function getAccessToken(user: string, writeAccess = false): Promise<string
|
|
|
126
89
|
return token.access_token;
|
|
127
90
|
}
|
|
128
91
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
92
|
+
// Mutable token holder for refresh during long operations
|
|
93
|
+
let currentAccessToken = '';
|
|
94
|
+
let currentUser = '';
|
|
95
|
+
|
|
96
|
+
async function refreshAccessToken(): Promise<string> {
|
|
97
|
+
currentAccessToken = await getAccessToken(currentUser, true, true);
|
|
98
|
+
return currentAccessToken;
|
|
134
99
|
}
|
|
135
100
|
|
|
136
101
|
function saveIndex(paths: UserPaths, index: ContactIndex): void {
|
|
102
|
+
// Sort contacts by displayName for convenience
|
|
103
|
+
const sortedContacts: Record<string, IndexEntry> = {};
|
|
104
|
+
const entries = Object.entries(index.contacts);
|
|
105
|
+
entries.sort((a, b) => a[1].displayName.localeCompare(b[1].displayName));
|
|
106
|
+
for (const [key, value] of entries) {
|
|
107
|
+
sortedContacts[key] = value;
|
|
108
|
+
}
|
|
109
|
+
index.contacts = sortedContacts;
|
|
137
110
|
fs.writeFileSync(paths.indexFile, JSON.stringify(index, null, 2));
|
|
138
111
|
}
|
|
139
112
|
|
|
140
|
-
|
|
113
|
+
/** Deleted contacts index (separate from active index) */
|
|
114
|
+
interface DeletedIndex {
|
|
115
|
+
deleted: Record<string, DeletedEntry>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function loadDeleted(paths: UserPaths): DeletedIndex {
|
|
119
|
+
if (fs.existsSync(paths.deletedFile)) {
|
|
120
|
+
return JSON.parse(fs.readFileSync(paths.deletedFile, 'utf-8'));
|
|
121
|
+
}
|
|
122
|
+
return { deleted: {} };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function saveDeleted(paths: UserPaths, deletedIndex: DeletedIndex): void {
|
|
126
|
+
// Sort by displayName
|
|
127
|
+
const sorted: Record<string, DeletedEntry> = {};
|
|
128
|
+
const entries = Object.entries(deletedIndex.deleted);
|
|
129
|
+
entries.sort((a, b) => a[1].displayName.localeCompare(b[1].displayName));
|
|
130
|
+
for (const [key, value] of entries) {
|
|
131
|
+
sorted[key] = value;
|
|
132
|
+
}
|
|
133
|
+
deletedIndex.deleted = sorted;
|
|
134
|
+
fs.writeFileSync(paths.deletedFile, JSON.stringify(deletedIndex, null, 2));
|
|
135
|
+
}
|
|
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
|
+
|
|
203
|
+
function generateGuid(): string {
|
|
204
|
+
return crypto.randomUUID();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function loadSyncToken(paths: UserPaths): string {
|
|
141
208
|
if (fs.existsSync(paths.syncTokenFile)) {
|
|
142
209
|
const data = JSON.parse(fs.readFileSync(paths.syncTokenFile, 'utf-8'));
|
|
143
210
|
return data.syncToken || null;
|
|
@@ -163,7 +230,7 @@ async function fetchContactsWithRetry(
|
|
|
163
230
|
const BASE_DELAY = 5000; /** 5 seconds base delay */
|
|
164
231
|
|
|
165
232
|
const params = new URLSearchParams({
|
|
166
|
-
personFields: 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays,photos,memberships,metadata',
|
|
233
|
+
personFields: 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays,photos,memberships,metadata,fileAses',
|
|
167
234
|
pageSize: '100' /** Smaller page size to avoid quota issues */
|
|
168
235
|
});
|
|
169
236
|
|
|
@@ -230,8 +297,39 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
|
|
|
230
297
|
const accessToken = await getAccessToken(user);
|
|
231
298
|
|
|
232
299
|
const index = loadIndex(paths);
|
|
300
|
+
const deletedIndex = loadDeleted(paths);
|
|
301
|
+
const photosIndex = loadPhotos(paths);
|
|
233
302
|
let syncToken = options.full ? null : loadSyncToken(paths);
|
|
234
303
|
|
|
304
|
+
// Migrate any old deleted entries from index.json to deleted.json
|
|
305
|
+
// Also clean up old fields (deleted: false, etag)
|
|
306
|
+
let migrated = 0;
|
|
307
|
+
let cleaned = 0;
|
|
308
|
+
for (const [rn, entry] of Object.entries(index.contacts)) {
|
|
309
|
+
const oldEntry = entry as any;
|
|
310
|
+
if (oldEntry.deleted === true) {
|
|
311
|
+
deletedIndex.deleted[rn] = { ...entry, deletedAt: oldEntry.deletedAt || new Date().toISOString() };
|
|
312
|
+
delete index.contacts[rn];
|
|
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
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (migrated > 0) {
|
|
327
|
+
console.log(`Migrated ${migrated} deleted entries to deleted.json`);
|
|
328
|
+
}
|
|
329
|
+
if (cleaned > 0) {
|
|
330
|
+
console.log(`Cleaned ${cleaned} old fields from index entries`);
|
|
331
|
+
}
|
|
332
|
+
|
|
235
333
|
if (syncToken) {
|
|
236
334
|
console.log('Performing incremental sync...');
|
|
237
335
|
} else {
|
|
@@ -243,6 +341,7 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
|
|
|
243
341
|
let added = 0;
|
|
244
342
|
let updated = 0;
|
|
245
343
|
let deleted = 0;
|
|
344
|
+
let conflicts = 0;
|
|
246
345
|
let pageNum = 0;
|
|
247
346
|
|
|
248
347
|
do {
|
|
@@ -257,23 +356,84 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
|
|
|
257
356
|
const displayName = person.names?.[0]?.displayName || 'Unknown';
|
|
258
357
|
|
|
259
358
|
if (isDeleted) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
359
|
+
const entry = index.contacts[person.resourceName];
|
|
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
|
+
|
|
379
|
+
// Move to deleted.json
|
|
380
|
+
const deletedEntry = { ...entry, deletedAt: new Date().toISOString(), _delete: 'server' };
|
|
381
|
+
deletedIndex.deleted[person.resourceName] = deletedEntry;
|
|
382
|
+
delete index.contacts[person.resourceName];
|
|
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
|
+
|
|
264
397
|
deleted++;
|
|
265
398
|
if (options.verbose) console.log(`\n Deleted: ${displayName}`);
|
|
266
399
|
}
|
|
267
400
|
} else {
|
|
268
401
|
const existed = !!index.contacts[person.resourceName];
|
|
402
|
+
|
|
403
|
+
// Check for local conflict before overwriting
|
|
404
|
+
const filePath = path.join(paths.contactsDir, `${person.resourceName.replace(/\//g, '_')}.json`);
|
|
405
|
+
const indexEntry = index.contacts[person.resourceName];
|
|
406
|
+
if (existed && fs.existsSync(filePath)) {
|
|
407
|
+
const stat = fs.statSync(filePath);
|
|
408
|
+
const modTime = stat.mtimeMs;
|
|
409
|
+
const indexUpdatedAt = indexEntry?.updatedAt ? new Date(indexEntry.updatedAt).getTime() : 0;
|
|
410
|
+
|
|
411
|
+
if (modTime > indexUpdatedAt + 1000) {
|
|
412
|
+
// Local file was modified - conflict!
|
|
413
|
+
console.log(`\n CONFLICT: ${displayName} - local changes exist, skipping (delete local file and sync to get server version)`);
|
|
414
|
+
conflicts++;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
269
419
|
saveContact(paths, person);
|
|
270
420
|
|
|
421
|
+
// Extract GUIDs from userDefined fields
|
|
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;
|
|
428
|
+
|
|
271
429
|
index.contacts[person.resourceName] = {
|
|
272
430
|
resourceName: person.resourceName,
|
|
273
431
|
displayName,
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
432
|
+
updatedAt: new Date().toISOString(),
|
|
433
|
+
...(hasPhoto && { hasPhoto }),
|
|
434
|
+
...(starred && { starred }),
|
|
435
|
+
...(guids.length > 0 && { guids }),
|
|
436
|
+
...(existingDelete && { _delete: existingDelete })
|
|
277
437
|
};
|
|
278
438
|
|
|
279
439
|
if (existed) {
|
|
@@ -298,21 +458,31 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
|
|
|
298
458
|
// Save index after each page in case of interruption
|
|
299
459
|
index.lastSync = new Date().toISOString();
|
|
300
460
|
saveIndex(paths, index);
|
|
461
|
+
saveDeleted(paths, deletedIndex);
|
|
462
|
+
savePhotos(paths, photosIndex);
|
|
301
463
|
|
|
302
464
|
// Small delay between pages to be nice to the API
|
|
303
465
|
if (pageToken) {
|
|
304
466
|
await sleep(500);
|
|
305
467
|
}
|
|
468
|
+
|
|
469
|
+
if (escapePressed) {
|
|
470
|
+
console.log('\n\nStopped by user. Progress saved.');
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
306
473
|
} while (pageToken);
|
|
307
474
|
|
|
308
|
-
const activeContacts = Object.
|
|
309
|
-
const tombstones = Object.
|
|
475
|
+
const activeContacts = Object.keys(index.contacts).length;
|
|
476
|
+
const tombstones = Object.keys(deletedIndex.deleted).length;
|
|
310
477
|
|
|
311
478
|
console.log(`\n\nSync complete:`);
|
|
312
479
|
console.log(` Processed: ${totalProcessed}`);
|
|
313
480
|
console.log(` Added: ${added}`);
|
|
314
481
|
console.log(` Updated: ${updated}`);
|
|
315
482
|
console.log(` Deleted: ${deleted}`);
|
|
483
|
+
if (conflicts > 0) {
|
|
484
|
+
console.log(` Conflicts: ${conflicts} (delete local file and sync to resolve)`);
|
|
485
|
+
}
|
|
316
486
|
console.log(` Active contacts: ${activeContacts}`);
|
|
317
487
|
console.log(` Tombstones: ${tombstones}`);
|
|
318
488
|
}
|
|
@@ -328,48 +498,96 @@ function saveStatus(paths: UserPaths, status: PushStatus): void {
|
|
|
328
498
|
fs.writeFileSync(paths.statusFile, JSON.stringify(status, null, 2));
|
|
329
499
|
}
|
|
330
500
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
501
|
+
interface PendingChangesResult {
|
|
502
|
+
changes: PendingChange[];
|
|
503
|
+
parseErrors: string[];
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?: string): Promise<PendingChangesResult> {
|
|
334
507
|
const changes: PendingChange[] = [];
|
|
508
|
+
const parseErrors: string[] = [];
|
|
509
|
+
const index = loadIndex(paths);
|
|
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
|
+
}
|
|
515
|
+
|
|
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
|
+
});
|
|
527
|
+
}
|
|
335
528
|
|
|
336
|
-
// Check contacts/ for modified files and
|
|
529
|
+
// Check contacts/ for modified files and _delete markers
|
|
530
|
+
// Only check files modified after lastSync (fast filter using mtime)
|
|
337
531
|
if (fs.existsSync(paths.contactsDir)) {
|
|
338
532
|
const files = fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'));
|
|
533
|
+
let checked = 0;
|
|
534
|
+
|
|
339
535
|
for (const file of files) {
|
|
340
536
|
const filePath = path.join(paths.contactsDir, file);
|
|
341
|
-
const stat =
|
|
537
|
+
const stat = await fp.stat(filePath);
|
|
342
538
|
const modTime = stat.mtimeMs;
|
|
343
539
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
540
|
+
// Skip files not modified since last sync (or -since override)
|
|
541
|
+
if (modTime <= sinceTime + 1000) continue;
|
|
542
|
+
|
|
543
|
+
checked++;
|
|
544
|
+
let content: GooglePerson & { _delete?: boolean };
|
|
545
|
+
try {
|
|
546
|
+
content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
|
|
547
|
+
} catch (e: any) {
|
|
548
|
+
const errMsg = `[PARSE ERROR] ${file}: ${e.message}`;
|
|
549
|
+
parseErrors.push(errMsg);
|
|
550
|
+
logger.error(errMsg);
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
347
553
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
554
|
+
const displayName = content.names?.[0]?.displayName || 'Unknown';
|
|
555
|
+
|
|
556
|
+
if (content._delete) {
|
|
557
|
+
changes.push({
|
|
558
|
+
type: 'delete',
|
|
559
|
+
resourceName: content.resourceName,
|
|
560
|
+
displayName,
|
|
561
|
+
filePath
|
|
562
|
+
});
|
|
563
|
+
} else {
|
|
564
|
+
changes.push({
|
|
565
|
+
type: 'update',
|
|
566
|
+
resourceName: content.resourceName,
|
|
567
|
+
displayName,
|
|
568
|
+
filePath
|
|
569
|
+
});
|
|
363
570
|
}
|
|
364
571
|
}
|
|
572
|
+
if (checked > 0) {
|
|
573
|
+
console.log(`${ts()} Found ${checked} modified contact(s)`);
|
|
574
|
+
}
|
|
365
575
|
}
|
|
366
576
|
|
|
367
|
-
// Check
|
|
368
|
-
if (fs.existsSync(paths.
|
|
369
|
-
const files = fs.readdirSync(paths.
|
|
577
|
+
// Check _delete/ folder for user deletion requests
|
|
578
|
+
if (fs.existsSync(paths.toDeleteDir)) {
|
|
579
|
+
const files = fs.readdirSync(paths.toDeleteDir).filter(f => f.endsWith('.json'));
|
|
370
580
|
for (const file of files) {
|
|
371
|
-
const filePath = path.join(paths.
|
|
372
|
-
|
|
581
|
+
const filePath = path.join(paths.toDeleteDir, file);
|
|
582
|
+
let content: GooglePerson;
|
|
583
|
+
try {
|
|
584
|
+
content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
|
|
585
|
+
} catch (e: any) {
|
|
586
|
+
const errMsg = `[PARSE ERROR] ${file}: ${e.message}`;
|
|
587
|
+
parseErrors.push(errMsg);
|
|
588
|
+
logger.error(errMsg);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
373
591
|
const displayName = content.names?.[0]?.displayName || 'Unknown';
|
|
374
592
|
|
|
375
593
|
changes.push({
|
|
@@ -381,10 +599,41 @@ function findPendingChanges(paths: UserPaths): PendingChange[] {
|
|
|
381
599
|
}
|
|
382
600
|
}
|
|
383
601
|
|
|
384
|
-
|
|
602
|
+
// Check _add/ folder for new contacts to create
|
|
603
|
+
if (fs.existsSync(paths.toAddDir)) {
|
|
604
|
+
const files = fs.readdirSync(paths.toAddDir).filter(f => f.endsWith('.json'));
|
|
605
|
+
for (const file of files) {
|
|
606
|
+
const filePath = path.join(paths.toAddDir, file);
|
|
607
|
+
let content: GooglePerson;
|
|
608
|
+
try {
|
|
609
|
+
content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
|
|
610
|
+
} catch (e: any) {
|
|
611
|
+
const errMsg = `[PARSE ERROR] ${file}: ${e.message}`;
|
|
612
|
+
parseErrors.push(errMsg);
|
|
613
|
+
logger.error(errMsg);
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const displayName = content.names?.[0]?.displayName || 'Unknown';
|
|
617
|
+
|
|
618
|
+
changes.push({
|
|
619
|
+
type: 'add',
|
|
620
|
+
resourceName: '', // Will be assigned by Google
|
|
621
|
+
displayName,
|
|
622
|
+
filePath
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return { changes, parseErrors };
|
|
385
628
|
}
|
|
386
629
|
|
|
387
630
|
async function confirm(message: string): Promise<boolean> {
|
|
631
|
+
// Temporarily disable raw mode for readline to work
|
|
632
|
+
const wasRawMode = process.stdin.isTTY && (process.stdin as any).isRaw;
|
|
633
|
+
if (wasRawMode) {
|
|
634
|
+
process.stdin.setRawMode(false);
|
|
635
|
+
}
|
|
636
|
+
|
|
388
637
|
const readline = await import('readline');
|
|
389
638
|
const rl = readline.createInterface({
|
|
390
639
|
input: process.stdin,
|
|
@@ -394,55 +643,182 @@ async function confirm(message: string): Promise<boolean> {
|
|
|
394
643
|
return new Promise(resolve => {
|
|
395
644
|
rl.question(`${message} (y/N): `, answer => {
|
|
396
645
|
rl.close();
|
|
646
|
+
// Restore raw mode if it was enabled
|
|
647
|
+
if (wasRawMode && process.stdin.isTTY) {
|
|
648
|
+
process.stdin.setRawMode(true);
|
|
649
|
+
}
|
|
397
650
|
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
398
651
|
});
|
|
399
652
|
});
|
|
400
653
|
}
|
|
401
654
|
|
|
402
|
-
async function updateContactOnGoogle(
|
|
403
|
-
const updateMask = 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays';
|
|
655
|
+
async function updateContactOnGoogle(person: GooglePerson, retryCount = 0, tokenRefreshed = false): Promise<void> {
|
|
656
|
+
const updateMask = 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays,fileAses';
|
|
404
657
|
|
|
405
658
|
const response = await fetch(`${PEOPLE_API_BASE}/${person.resourceName}:updateContact?updatePersonFields=${updateMask}`, {
|
|
406
659
|
method: 'PATCH',
|
|
407
660
|
headers: {
|
|
408
|
-
'Authorization': `Bearer ${
|
|
661
|
+
'Authorization': `Bearer ${currentAccessToken}`,
|
|
409
662
|
'Content-Type': 'application/json'
|
|
410
663
|
},
|
|
411
664
|
body: JSON.stringify(person)
|
|
412
665
|
});
|
|
413
666
|
|
|
667
|
+
// Token expired - refresh and retry once
|
|
668
|
+
if (response.status === 401 && !tokenRefreshed) {
|
|
669
|
+
await refreshAccessToken();
|
|
670
|
+
return updateContactOnGoogle(person, retryCount, true);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (response.status === 429) {
|
|
674
|
+
if (retryCount >= 3 || escapePressed) {
|
|
675
|
+
throw new Error(`rate limit - try again later`);
|
|
676
|
+
}
|
|
677
|
+
const delay = Math.pow(2, retryCount + 1) * 10000; // 20s, 40s, 80s
|
|
678
|
+
console.log(` ${ts()} rate limited, waiting ${delay / 1000}s (ESC to stop)...`);
|
|
679
|
+
await sleep(delay);
|
|
680
|
+
if (escapePressed) throw new Error(`stopped by user`);
|
|
681
|
+
console.log(`${ts()} retrying...`);
|
|
682
|
+
return updateContactOnGoogle(person, retryCount + 1, tokenRefreshed);
|
|
683
|
+
}
|
|
684
|
+
|
|
414
685
|
if (!response.ok) {
|
|
415
|
-
throw new Error(
|
|
686
|
+
throw new Error(`${response.status} ${await response.text()}`);
|
|
416
687
|
}
|
|
417
688
|
}
|
|
418
689
|
|
|
419
|
-
async function deleteContactOnGoogle(
|
|
690
|
+
async function deleteContactOnGoogle(resourceName: string, retryCount = 0, tokenRefreshed = false): Promise<void> {
|
|
420
691
|
const response = await fetch(`${PEOPLE_API_BASE}/${resourceName}:deleteContact`, {
|
|
421
692
|
method: 'DELETE',
|
|
422
|
-
headers: { 'Authorization': `Bearer ${
|
|
693
|
+
headers: { 'Authorization': `Bearer ${currentAccessToken}` }
|
|
423
694
|
});
|
|
424
695
|
|
|
696
|
+
// Already deleted - treat as success
|
|
697
|
+
if (response.status === 404) {
|
|
698
|
+
console.log(' (already deleted)');
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Token expired - refresh and retry once
|
|
703
|
+
if (response.status === 401 && !tokenRefreshed) {
|
|
704
|
+
await refreshAccessToken();
|
|
705
|
+
return deleteContactOnGoogle(resourceName, retryCount, true);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (response.status === 429) {
|
|
709
|
+
if (retryCount >= 3 || escapePressed) {
|
|
710
|
+
throw new Error(`rate limit - try again later`);
|
|
711
|
+
}
|
|
712
|
+
const delay = Math.pow(2, retryCount + 1) * 10000; // 20s, 40s, 80s
|
|
713
|
+
console.log(` ${ts()} rate limited, waiting ${delay / 1000}s (ESC to stop)...`);
|
|
714
|
+
await sleep(delay);
|
|
715
|
+
if (escapePressed) throw new Error(`stopped by user`);
|
|
716
|
+
console.log(`${ts()} retrying...`);
|
|
717
|
+
return deleteContactOnGoogle(resourceName, retryCount + 1, tokenRefreshed);
|
|
718
|
+
}
|
|
719
|
+
|
|
425
720
|
if (!response.ok) {
|
|
426
|
-
throw new Error(
|
|
721
|
+
throw new Error(`${response.status} ${await response.text()}`);
|
|
427
722
|
}
|
|
428
723
|
}
|
|
429
724
|
|
|
430
|
-
async function
|
|
725
|
+
async function createContactOnGoogle(person: GooglePerson, retryCount = 0, tokenRefreshed = false): Promise<GooglePerson> {
|
|
726
|
+
// Remove resourceName and etag - Google will assign new ones
|
|
727
|
+
const { resourceName, etag, ...personData } = person as any;
|
|
728
|
+
|
|
729
|
+
// Add a GUID if not already present in userDefined
|
|
730
|
+
if (!personData.userDefined) {
|
|
731
|
+
personData.userDefined = [];
|
|
732
|
+
}
|
|
733
|
+
const hasGuid = personData.userDefined.some((u: any) => u.key === GCARDS_GUID_KEY);
|
|
734
|
+
if (!hasGuid) {
|
|
735
|
+
personData.userDefined.push({ key: GCARDS_GUID_KEY, value: generateGuid() });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const response = await fetch(`${PEOPLE_API_BASE}/people:createContact`, {
|
|
739
|
+
method: 'POST',
|
|
740
|
+
headers: {
|
|
741
|
+
'Authorization': `Bearer ${currentAccessToken}`,
|
|
742
|
+
'Content-Type': 'application/json'
|
|
743
|
+
},
|
|
744
|
+
body: JSON.stringify(personData)
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// Token expired - refresh and retry once
|
|
748
|
+
if (response.status === 401 && !tokenRefreshed) {
|
|
749
|
+
await refreshAccessToken();
|
|
750
|
+
return createContactOnGoogle(person, retryCount, true);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (response.status === 429) {
|
|
754
|
+
if (retryCount >= 3 || escapePressed) {
|
|
755
|
+
throw new Error(`rate limit - try again later`);
|
|
756
|
+
}
|
|
757
|
+
const delay = Math.pow(2, retryCount + 1) * 10000; // 20s, 40s, 80s
|
|
758
|
+
console.log(` ${ts()} rate limited, waiting ${delay / 1000}s (ESC to stop)...`);
|
|
759
|
+
await sleep(delay);
|
|
760
|
+
if (escapePressed) throw new Error(`stopped by user`);
|
|
761
|
+
console.log(`${ts()} retrying...`);
|
|
762
|
+
return createContactOnGoogle(person, retryCount + 1, tokenRefreshed);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (!response.ok) {
|
|
766
|
+
throw new Error(`${response.status} ${await response.text()}`);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return await response.json();
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function pushContacts(user: string, options: { yes: boolean; verbose: boolean; limit: number; since: string }): Promise<void> {
|
|
431
773
|
const paths = getUserPaths(user);
|
|
432
774
|
ensureUserDir(user);
|
|
433
775
|
|
|
776
|
+
// Create logger for problems
|
|
777
|
+
const problemsFile = path.join(paths.userDir, 'pushproblems.txt');
|
|
778
|
+
const logger = new FileLogger(problemsFile, true);
|
|
779
|
+
|
|
434
780
|
console.log(`Pushing contacts for user: ${user}`);
|
|
435
|
-
|
|
781
|
+
let { changes, parseErrors } = await findPendingChanges(paths, logger, options.since || undefined);
|
|
782
|
+
|
|
783
|
+
if (parseErrors.length > 0) {
|
|
784
|
+
console.log(`\n${parseErrors.length} files had parse errors (see pushproblems.txt)`);
|
|
785
|
+
}
|
|
436
786
|
|
|
437
787
|
if (changes.length === 0) {
|
|
438
788
|
console.log('No pending changes to push.');
|
|
439
789
|
return;
|
|
440
790
|
}
|
|
441
791
|
|
|
442
|
-
const
|
|
443
|
-
const
|
|
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'));
|
|
444
814
|
|
|
445
815
|
console.log('\nPending changes:');
|
|
816
|
+
if (adds.length > 0) {
|
|
817
|
+
console.log(`\n New contacts (${adds.length}):`);
|
|
818
|
+
for (const c of adds) {
|
|
819
|
+
console.log(` - ${c.displayName}`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
446
822
|
if (updates.length > 0) {
|
|
447
823
|
console.log(`\n Updates (${updates.length}):`);
|
|
448
824
|
for (const c of updates) {
|
|
@@ -455,6 +831,10 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
455
831
|
console.log(` - ${c.displayName} (${c.resourceName})`);
|
|
456
832
|
}
|
|
457
833
|
}
|
|
834
|
+
console.log(`\n(List written to ${toPushFile})`);
|
|
835
|
+
|
|
836
|
+
// Reorder changes to process in sorted order
|
|
837
|
+
changes = [...adds, ...updates, ...deletes];
|
|
458
838
|
|
|
459
839
|
if (!options.yes) {
|
|
460
840
|
const confirmed = await confirm(`\nPush ${changes.length} change(s) to Google?`);
|
|
@@ -465,110 +845,265 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
465
845
|
}
|
|
466
846
|
|
|
467
847
|
console.log('\nGetting access token (write access)...');
|
|
468
|
-
|
|
848
|
+
currentUser = user;
|
|
849
|
+
currentAccessToken = await getAccessToken(user, true);
|
|
469
850
|
|
|
470
851
|
const index = loadIndex(paths);
|
|
852
|
+
const deletedIndex = loadDeleted(paths);
|
|
853
|
+
const deleteQueue = loadDeleteQueue(paths);
|
|
854
|
+
const completedDeletions = new Set<string>(); // Track successful deletions
|
|
855
|
+
// Apply limit if specified
|
|
856
|
+
if (options.limit > 0 && changes.length > options.limit) {
|
|
857
|
+
console.log(`[DEBUG] Limiting to first ${options.limit} of ${changes.length} changes\n`);
|
|
858
|
+
changes = changes.slice(0, options.limit);
|
|
859
|
+
}
|
|
860
|
+
|
|
471
861
|
let successCount = 0;
|
|
472
862
|
let errorCount = 0;
|
|
863
|
+
let skippedPhoto = 0;
|
|
864
|
+
let skippedStarred = 0;
|
|
865
|
+
let processed = 0;
|
|
866
|
+
const total = changes.length;
|
|
867
|
+
const problems: string[] = [];
|
|
868
|
+
const notDeleted: { resourceName: string; displayName: string; filePath: string; reason: string }[] = [];
|
|
473
869
|
|
|
474
870
|
for (const change of changes) {
|
|
871
|
+
if (escapePressed) {
|
|
872
|
+
console.log('\n\nStopped by user. Progress saved.');
|
|
873
|
+
break;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
processed++;
|
|
475
877
|
try {
|
|
476
|
-
if (change.type === '
|
|
878
|
+
if (change.type === 'add') {
|
|
477
879
|
const content = JSON.parse(fs.readFileSync(change.filePath, 'utf-8')) as GooglePerson;
|
|
478
|
-
process.stdout.write(
|
|
479
|
-
await
|
|
480
|
-
|
|
880
|
+
process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Creating ${change.displayName}...`);
|
|
881
|
+
const created = await createContactOnGoogle(content);
|
|
882
|
+
|
|
883
|
+
// Extract GUIDs and flags from created contact
|
|
884
|
+
const guids = extractGuids(created);
|
|
885
|
+
const hasPhoto = hasNonDefaultPhoto(created);
|
|
886
|
+
const starred = isStarred(created);
|
|
887
|
+
|
|
888
|
+
// Add to index
|
|
889
|
+
index.contacts[created.resourceName] = {
|
|
890
|
+
resourceName: created.resourceName,
|
|
891
|
+
displayName: change.displayName,
|
|
892
|
+
updatedAt: new Date().toISOString(),
|
|
893
|
+
...(hasPhoto && { hasPhoto }),
|
|
894
|
+
...(starred && { starred }),
|
|
895
|
+
...(guids.length > 0 && { guids })
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
// Save new contact to contacts/ folder
|
|
899
|
+
const newFilePath = path.join(paths.contactsDir, `${created.resourceName.replace(/\//g, '_')}.json`);
|
|
900
|
+
if (!fs.existsSync(paths.contactsDir)) {
|
|
901
|
+
fs.mkdirSync(paths.contactsDir, { recursive: true });
|
|
902
|
+
}
|
|
903
|
+
fs.writeFileSync(newFilePath, JSON.stringify(created, null, 2));
|
|
904
|
+
|
|
905
|
+
// Remove from _add/ folder
|
|
906
|
+
fs.unlinkSync(change.filePath);
|
|
907
|
+
|
|
908
|
+
console.log(` done (${created.resourceName})`);
|
|
481
909
|
successCount++;
|
|
482
|
-
} else {
|
|
483
|
-
|
|
484
|
-
|
|
910
|
+
} else if (change.type === 'update') {
|
|
911
|
+
const content = JSON.parse(fs.readFileSync(change.filePath, 'utf-8')) as GooglePerson;
|
|
912
|
+
process.stdout.write(`${ts()} [${String(processed).padStart(5)}/${total}] Updating ${change.displayName}...`);
|
|
913
|
+
await updateContactOnGoogle(content);
|
|
485
914
|
|
|
486
|
-
// Update index
|
|
915
|
+
// Update index timestamp so file no longer appears modified
|
|
487
916
|
if (index.contacts[change.resourceName]) {
|
|
488
|
-
index.contacts[change.resourceName].
|
|
489
|
-
index.contacts[change.resourceName].deletedAt = new Date().toISOString();
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// Remove from deleted/ folder if it was there
|
|
493
|
-
if (change.filePath.startsWith(paths.deletedDir)) {
|
|
494
|
-
fs.unlinkSync(change.filePath);
|
|
917
|
+
index.contacts[change.resourceName].updatedAt = new Date().toISOString();
|
|
495
918
|
}
|
|
496
919
|
|
|
497
920
|
console.log(' done');
|
|
498
921
|
successCount++;
|
|
922
|
+
} else if (change.type === 'delete') {
|
|
923
|
+
const contactFile = path.join(paths.contactsDir, `${change.resourceName.replace('people/', '')}.json`);
|
|
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 */ }
|
|
935
|
+
}
|
|
936
|
+
|
|
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
|
+
}
|
|
959
|
+
|
|
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
|
+
}
|
|
499
1002
|
}
|
|
500
1003
|
|
|
501
|
-
await sleep(
|
|
1004
|
+
await sleep(700); // 90 writes/min limit = ~700ms between ops
|
|
502
1005
|
} catch (error: any) {
|
|
1006
|
+
const fileName = path.basename(change.filePath);
|
|
1007
|
+
const errorMsg = `${fileName}: ${error.message}`;
|
|
503
1008
|
console.log(` ERROR: ${error.message}`);
|
|
1009
|
+
problems.push(errorMsg);
|
|
504
1010
|
errorCount++;
|
|
505
|
-
debugger;
|
|
506
1011
|
}
|
|
507
1012
|
}
|
|
508
1013
|
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
1014
|
+
// Write problems to file if any
|
|
1015
|
+
if (problems.length > 0) {
|
|
1016
|
+
const problemsFile = path.join(paths.userDir, 'pushproblems.txt');
|
|
1017
|
+
fs.writeFileSync(problemsFile, problems.join('\n'));
|
|
1018
|
+
console.log(`\nErrors written to: ${problemsFile}`);
|
|
1019
|
+
}
|
|
515
1020
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
config.lastUser = normalized;
|
|
522
|
-
saveConfig(config);
|
|
523
|
-
return normalized;
|
|
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)`);
|
|
524
1026
|
}
|
|
525
1027
|
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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);
|
|
530
1032
|
}
|
|
531
1033
|
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
1034
|
+
// Save status with new timestamp
|
|
1035
|
+
saveStatus(paths, { lastPush: new Date().toISOString() });
|
|
1036
|
+
saveIndex(paths, index);
|
|
1037
|
+
saveDeleted(paths, deletedIndex);
|
|
1038
|
+
|
|
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}`);
|
|
536
1042
|
}
|
|
537
1043
|
|
|
538
1044
|
async function main(): Promise<void> {
|
|
539
1045
|
const args = process.argv.slice(2);
|
|
540
1046
|
const options = parseArgs(args);
|
|
541
1047
|
|
|
542
|
-
if (options.help
|
|
1048
|
+
if (options.help) {
|
|
543
1049
|
showHelp();
|
|
544
1050
|
return;
|
|
545
1051
|
}
|
|
546
1052
|
|
|
1053
|
+
if (!options.command) {
|
|
1054
|
+
showUsage();
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
setupEscapeHandler();
|
|
1059
|
+
|
|
547
1060
|
try {
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
case 'push':
|
|
555
|
-
await pushContacts(user, { yes: options.yes, verbose: options.verbose });
|
|
556
|
-
break;
|
|
557
|
-
default:
|
|
558
|
-
console.error(`Unknown command: ${options.command}`);
|
|
559
|
-
showHelp();
|
|
1061
|
+
// Determine users to process
|
|
1062
|
+
let users: string[];
|
|
1063
|
+
if (options.all) {
|
|
1064
|
+
users = getAllUsers();
|
|
1065
|
+
if (users.length === 0) {
|
|
1066
|
+
console.error('No users found. Use --user <name> to create one first.');
|
|
560
1067
|
process.exit(1);
|
|
1068
|
+
}
|
|
1069
|
+
console.log(`Processing all users: ${users.join(', ')}\n`);
|
|
1070
|
+
} else {
|
|
1071
|
+
users = [resolveUser(options.user, true)];
|
|
561
1072
|
}
|
|
562
1073
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1074
|
+
for (const user of users) {
|
|
1075
|
+
if (escapePressed) break;
|
|
1076
|
+
|
|
1077
|
+
if (users.length > 1) {
|
|
1078
|
+
console.log(`\n${'='.repeat(50)}\nUser: ${user}\n${'='.repeat(50)}`);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
switch (options.command) {
|
|
1082
|
+
case 'sync':
|
|
1083
|
+
await syncContacts(user, { full: options.full, verbose: options.verbose });
|
|
1084
|
+
break;
|
|
1085
|
+
case 'push':
|
|
1086
|
+
await pushContacts(user, { yes: options.yes, verbose: options.verbose, limit: options.limit, since: options.since });
|
|
1087
|
+
break;
|
|
1088
|
+
default:
|
|
1089
|
+
console.error(`Unknown command: ${options.command}`);
|
|
1090
|
+
showHelp();
|
|
1091
|
+
process.exit(1);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// Update lastUser in config (use last processed user)
|
|
1096
|
+
if (!options.all && users.length === 1) {
|
|
1097
|
+
const config = loadConfig();
|
|
1098
|
+
config.lastUser = users[0];
|
|
1099
|
+
saveConfig(config);
|
|
1100
|
+
}
|
|
567
1101
|
|
|
568
1102
|
} catch (error: any) {
|
|
569
1103
|
console.error(`Error: ${error.message}`);
|
|
570
|
-
debugger;
|
|
571
1104
|
process.exit(1);
|
|
1105
|
+
} finally {
|
|
1106
|
+
cleanupEscapeHandler();
|
|
572
1107
|
}
|
|
573
1108
|
}
|
|
574
1109
|
|