@bobfrankston/gcards 0.1.0

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.
@@ -0,0 +1,14 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(ls:*)",
5
+ "Bash(dir:*)",
6
+ "Bash(npm install)",
7
+ "Bash(tsc:*)",
8
+ "Bash(cat:*)",
9
+ "Bash(node -e:*)"
10
+ ],
11
+ "deny": [],
12
+ "ask": []
13
+ }
14
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "version": "0.2.0",
3
+ "configurations": [
4
+ {
5
+ "type": "node",
6
+ "request": "launch",
7
+ "name": "gcards sync",
8
+ "program": "${workspaceFolder}/gcards.ts",
9
+ "args": ["sync"],
10
+ "skipFiles": ["<node_internals>/**"]
11
+ },
12
+ {
13
+ "type": "node",
14
+ "request": "launch",
15
+ "name": "gcards sync --full",
16
+ "program": "${workspaceFolder}/gcards.ts",
17
+ "args": ["sync", "--full"],
18
+ "skipFiles": ["<node_internals>/**"]
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "workbench.colorTheme": "Visual Studio 2019 Dark"
3
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "version": "2.0.0",
3
+ "tasks": [
4
+ {
5
+ "label": "tsc: check",
6
+ "type": "shell",
7
+ "command": "tsc",
8
+ "args": ["--watch"],
9
+ "runOptions": {
10
+ "runOn": "folderOpen"
11
+ },
12
+ "problemMatcher": "$tsc-watch",
13
+ "isBackground": true,
14
+ "group": {
15
+ "kind": "build",
16
+ "isDefault": true
17
+ }
18
+ }
19
+ ]
20
+ }
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # Google Contacts Cleanup Tool (gcards)
2
+
3
+ ## Goal
4
+ Intelligent cleanup and merging of Google Contacts using the People API.
5
+
6
+ ## Roadmap
7
+ - **Current**: CLI tool
8
+ - **Future**: GUI interface
9
+ - **Eventually**: Own contacts database (vcf as intermediate format)
10
+
11
+ ## Features
12
+ - Fetch contacts via Google People API (preserves `resourceName` for reconciliation)
13
+ - Convert to simplified JSON structure for analysis
14
+ - Identify duplicates: **exact match first** (many blatant dupes), fuzzy matching optional/later
15
+ - Flag test entries (pattern: `m-\d+@bob\.ma` for "Bob Frankston")
16
+ - Generate merge candidates with confidence scores
17
+ - Apply changes back via API using `resourceName`
18
+ - Export to vCard format (`vcf/` directory)
19
+
20
+ ## Approach
21
+ 1. **Auth**: OAuth2 with Google People API scope
22
+ 2. **Fetch**: Full fetch first, save `syncToken` for incremental updates
23
+ 3. **Transform**: Convert `GooglePerson[]` → `LocalContact[]` (see `types.ts`)
24
+ 4. **Analyze**: Exact duplicate detection first, test entry flagging
25
+ 5. **Review**: Output candidates for user review
26
+ 6. **Apply**: Update/delete via API using `resourceName`
27
+ 7. **Export**: Save to `vcf/` as vCards for local backup/future DB
28
+ 8. **Log**: Track deletions in `deletion-log.json` to detect re-additions
29
+
30
+ ## Sync Token Usage
31
+ - After full fetch, save `nextSyncToken`
32
+ - Subsequent fetches: pass `syncToken` → get only new/changed/deleted
33
+ - Deleted entries appear with `metadata.deleted: true`
34
+ - Token expires ~30 days → fall back to full fetch (410 GONE error)
35
+
36
+ ## Deletion Logging
37
+ Maintain `deletion-log.json` with:
38
+ - `resourceName`, `displayName`, `reason`, `deletedAt`, `originalData`
39
+ - Allows detecting if cleaned-up contact reappears (re-synced, re-added)
40
+
41
+ ## Setup
42
+ 1. Create Google Cloud project
43
+ 2. Enable People API
44
+ 3. Create OAuth2 credentials (Desktop app)
45
+ 4. Download `credentials.json` to this directory
46
+
47
+ ## Directory Structure
48
+ ```
49
+ gcards/
50
+ ├── README.md
51
+ ├── types.ts # TypeScript definitions
52
+ ├── credentials.json # OAuth2 credentials (gitignored)
53
+ ├── token.json # OAuth tokens (gitignored)
54
+ ├── sync-token.json # Sync token for incremental fetch
55
+ ├── deletion-log.json # Track deleted contacts
56
+ └── vcf/ # vCard exports for future DB
57
+ ```
58
+
59
+ ## Test Entry Pattern
60
+
61
+ Emails matching `m-[2506|2512]+@bob\.ma` with name "Bob Frankston" are test business cards to be flagged/removed.
package/cli.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * CLI argument parsing for gcards
3
+ */
4
+
5
+ export interface CliOptions {
6
+ command: string;
7
+ user: string; /** User profile name (default: 'default') */
8
+ full: boolean; /** Force full sync instead of incremental */
9
+ yes: boolean; /** Skip confirmation prompt */
10
+ help: boolean;
11
+ verbose: boolean;
12
+ }
13
+
14
+ export function parseArgs(args: string[]): CliOptions {
15
+ const options: CliOptions = {
16
+ command: '',
17
+ user: 'default',
18
+ full: false,
19
+ yes: false,
20
+ help: false,
21
+ verbose: false
22
+ };
23
+
24
+ for (let i = 0; i < args.length; i++) {
25
+ const arg = args[i];
26
+ if (arg === '--full' || arg === '-f') {
27
+ options.full = true;
28
+ } else if (arg === '--yes' || arg === '-y') {
29
+ options.yes = true;
30
+ } else if (arg === '--help' || arg === '-h') {
31
+ options.help = true;
32
+ } else if (arg === '--verbose' || arg === '-v') {
33
+ options.verbose = true;
34
+ } else if ((arg === '--user' || arg === '-user' || arg === '-u') && i + 1 < args.length) {
35
+ options.user = args[++i];
36
+ } else if (!arg.startsWith('-') && !options.command) {
37
+ options.command = arg;
38
+ }
39
+ }
40
+
41
+ return options;
42
+ }
43
+
44
+ export function showHelp(): void {
45
+ console.log(`
46
+ gcards - Google Contacts management tool
47
+
48
+ Usage: gcards <command> [options]
49
+
50
+ Commands:
51
+ sync Sync contacts from Google (incremental by default)
52
+ push Push local changes to Google (with confirmation)
53
+
54
+ Options:
55
+ --user, -u NAME User profile (default: 'default')
56
+ --full, -f Force full sync (ignore sync token)
57
+ --yes, -y Skip confirmation prompt
58
+ --verbose, -v Verbose output
59
+ --help, -h Show this help
60
+
61
+ Deletion:
62
+ To delete a contact from Google, either:
63
+ - Edit the contact's JSON and add the field: "_deleted": true
64
+ - Or move the JSON file to the <user>/deleted/ folder
65
+ Then run 'gcards push' to apply the deletion
66
+
67
+ Examples:
68
+ gcards sync # Sync default user
69
+ gcards sync -u bob # Sync bob's contacts
70
+ gcards sync --full -u alice # Full sync for alice
71
+ gcards push -u bob # Push bob's changes
72
+ `);
73
+ }
package/gcards.ts ADDED
@@ -0,0 +1,577 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gcards - Google Contacts management tool
4
+ * Downloads and manages Google Contacts with incremental sync support
5
+ */
6
+
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+ import { parseArgs, showHelp } from './cli.ts';
10
+ import { authenticateOAuth } from '../../../projects/oauth/oauthsupport/index.ts';
11
+ import type { GooglePerson, GoogleConnectionsResponse } from './types.ts';
12
+
13
+ const APP_DIR = import.meta.dirname;
14
+ const DATA_DIR = path.join(APP_DIR, 'data');
15
+ const CREDENTIALS_FILE = path.join(APP_DIR, 'credentials.json');
16
+ const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
17
+
18
+ const PEOPLE_API_BASE = 'https://people.googleapis.com/v1';
19
+ const CONTACTS_SCOPE_READ = 'https://www.googleapis.com/auth/contacts.readonly';
20
+ const CONTACTS_SCOPE_WRITE = 'https://www.googleapis.com/auth/contacts';
21
+
22
+ interface AppConfig {
23
+ lastUser?: string;
24
+ }
25
+
26
+ interface UserPaths {
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
+ }
36
+
37
+ function loadConfig(): AppConfig {
38
+ if (fs.existsSync(CONFIG_FILE)) {
39
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
40
+ }
41
+ return {};
42
+ }
43
+
44
+ function saveConfig(config: AppConfig): void {
45
+ if (!fs.existsSync(DATA_DIR)) {
46
+ fs.mkdirSync(DATA_DIR, { recursive: true });
47
+ }
48
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
49
+ }
50
+
51
+ /** Normalize Gmail username: lowercase, remove dots, strip +suffix and @domain */
52
+ function normalizeUser(user: string): string {
53
+ return user.toLowerCase().split(/[+@]/)[0].replace(/\./g, '');
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 });
74
+ }
75
+ }
76
+
77
+ interface ContactIndex {
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> {
104
+ if (!fs.existsSync(CREDENTIALS_FILE)) {
105
+ throw new Error(`Credentials file not found: ${CREDENTIALS_FILE}`);
106
+ }
107
+
108
+ const paths = getUserPaths(user);
109
+ ensureUserDir(user);
110
+
111
+ const scope = writeAccess ? CONTACTS_SCOPE_WRITE : CONTACTS_SCOPE_READ;
112
+ const tokenFileName = writeAccess ? 'token-write.json' : 'token.json';
113
+
114
+ const token = await authenticateOAuth(CREDENTIALS_FILE, {
115
+ scope,
116
+ tokenDirectory: paths.userDir,
117
+ tokenFileName,
118
+ credentialsKey: 'web',
119
+ includeOfflineAccess: true
120
+ });
121
+
122
+ if (!token) {
123
+ throw new Error('OAuth authentication failed');
124
+ }
125
+
126
+ return token.access_token;
127
+ }
128
+
129
+ function loadIndex(paths: UserPaths): ContactIndex {
130
+ if (fs.existsSync(paths.indexFile)) {
131
+ return JSON.parse(fs.readFileSync(paths.indexFile, 'utf-8'));
132
+ }
133
+ return { contacts: {}, lastSync: '' };
134
+ }
135
+
136
+ function saveIndex(paths: UserPaths, index: ContactIndex): void {
137
+ fs.writeFileSync(paths.indexFile, JSON.stringify(index, null, 2));
138
+ }
139
+
140
+ function loadSyncToken(paths: UserPaths): string | null {
141
+ if (fs.existsSync(paths.syncTokenFile)) {
142
+ const data = JSON.parse(fs.readFileSync(paths.syncTokenFile, 'utf-8'));
143
+ return data.syncToken || null;
144
+ }
145
+ return null;
146
+ }
147
+
148
+ function saveSyncToken(paths: UserPaths, token: string): void {
149
+ fs.writeFileSync(paths.syncTokenFile, JSON.stringify({ syncToken: token, savedAt: new Date().toISOString() }, null, 2));
150
+ }
151
+
152
+ async function sleep(ms: number): Promise<void> {
153
+ return new Promise(resolve => setTimeout(resolve, ms));
154
+ }
155
+
156
+ async function fetchContactsWithRetry(
157
+ accessToken: string,
158
+ syncToken?: string,
159
+ pageToken?: string,
160
+ retryCount = 0
161
+ ): Promise<GoogleConnectionsResponse> {
162
+ const MAX_RETRIES = 10;
163
+ const BASE_DELAY = 5000; /** 5 seconds base delay */
164
+
165
+ const params = new URLSearchParams({
166
+ personFields: 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays,photos,memberships,metadata',
167
+ pageSize: '100' /** Smaller page size to avoid quota issues */
168
+ });
169
+
170
+ if (syncToken) {
171
+ params.set('syncToken', syncToken);
172
+ }
173
+ params.set('requestSyncToken', 'true');
174
+
175
+ if (pageToken) {
176
+ params.set('pageToken', pageToken);
177
+ }
178
+
179
+ const url = `${PEOPLE_API_BASE}/people/me/connections?${params}`;
180
+ const response = await fetch(url, {
181
+ headers: { Authorization: `Bearer ${accessToken}` }
182
+ });
183
+
184
+ if (response.status === 410) {
185
+ console.log('Sync token expired, performing full sync...');
186
+ return fetchContactsWithRetry(accessToken, undefined, pageToken, 0);
187
+ }
188
+
189
+ if (response.status === 429) {
190
+ if (retryCount >= MAX_RETRIES) {
191
+ throw new Error(`Rate limit exceeded after ${MAX_RETRIES} retries`);
192
+ }
193
+ const delay = BASE_DELAY * Math.pow(2, retryCount); /** Exponential backoff */
194
+ console.log(`Rate limited, waiting ${delay / 1000}s before retry ${retryCount + 1}/${MAX_RETRIES}...`);
195
+ await sleep(delay);
196
+ return fetchContactsWithRetry(accessToken, syncToken, pageToken, retryCount + 1);
197
+ }
198
+
199
+ if (!response.ok) {
200
+ throw new Error(`API error: ${response.status} ${await response.text()}`);
201
+ }
202
+
203
+ return await response.json() as GoogleConnectionsResponse;
204
+ }
205
+
206
+ function saveContact(paths: UserPaths, person: GooglePerson): void {
207
+ if (!fs.existsSync(paths.contactsDir)) {
208
+ fs.mkdirSync(paths.contactsDir, { recursive: true });
209
+ }
210
+
211
+ const id = person.resourceName.replace('people/', '');
212
+ const filePath = path.join(paths.contactsDir, `${id}.json`);
213
+ fs.writeFileSync(filePath, JSON.stringify(person, null, 2));
214
+ }
215
+
216
+ function deleteContactFile(paths: UserPaths, resourceName: string): void {
217
+ const id = resourceName.replace('people/', '');
218
+ const filePath = path.join(paths.contactsDir, `${id}.json`);
219
+ if (fs.existsSync(filePath)) {
220
+ fs.unlinkSync(filePath);
221
+ }
222
+ }
223
+
224
+ async function syncContacts(user: string, options: { full: boolean; verbose: boolean }): Promise<void> {
225
+ const paths = getUserPaths(user);
226
+ ensureUserDir(user);
227
+
228
+ console.log(`Syncing contacts for user: ${user}`);
229
+ console.log('Getting access token...');
230
+ const accessToken = await getAccessToken(user);
231
+
232
+ const index = loadIndex(paths);
233
+ let syncToken = options.full ? null : loadSyncToken(paths);
234
+
235
+ if (syncToken) {
236
+ console.log('Performing incremental sync...');
237
+ } else {
238
+ console.log('Performing full sync...');
239
+ }
240
+
241
+ let pageToken: string | undefined;
242
+ let totalProcessed = 0;
243
+ let added = 0;
244
+ let updated = 0;
245
+ let deleted = 0;
246
+ let pageNum = 0;
247
+
248
+ do {
249
+ pageNum++;
250
+ process.stdout.write(`\rFetching page ${pageNum}... (${totalProcessed} contacts so far)`);
251
+
252
+ const response = await fetchContactsWithRetry(accessToken, syncToken || undefined, pageToken);
253
+
254
+ if (response.connections) {
255
+ for (const person of response.connections) {
256
+ const isDeleted = person.metadata?.deleted === true;
257
+ const displayName = person.names?.[0]?.displayName || 'Unknown';
258
+
259
+ if (isDeleted) {
260
+ if (index.contacts[person.resourceName]) {
261
+ index.contacts[person.resourceName].deleted = true;
262
+ index.contacts[person.resourceName].deletedAt = new Date().toISOString();
263
+ deleteContactFile(paths, person.resourceName);
264
+ deleted++;
265
+ if (options.verbose) console.log(`\n Deleted: ${displayName}`);
266
+ }
267
+ } else {
268
+ const existed = !!index.contacts[person.resourceName];
269
+ saveContact(paths, person);
270
+
271
+ index.contacts[person.resourceName] = {
272
+ resourceName: person.resourceName,
273
+ displayName,
274
+ etag: person.etag,
275
+ deleted: false,
276
+ updatedAt: new Date().toISOString()
277
+ };
278
+
279
+ if (existed) {
280
+ updated++;
281
+ if (options.verbose) console.log(`\n Updated: ${displayName}`);
282
+ } else {
283
+ added++;
284
+ if (options.verbose) console.log(`\n Added: ${displayName}`);
285
+ }
286
+ }
287
+ totalProcessed++;
288
+ }
289
+ }
290
+
291
+ if (response.nextSyncToken) {
292
+ saveSyncToken(paths, response.nextSyncToken);
293
+ index.syncToken = response.nextSyncToken;
294
+ }
295
+
296
+ pageToken = response.nextPageToken;
297
+
298
+ // Save index after each page in case of interruption
299
+ index.lastSync = new Date().toISOString();
300
+ saveIndex(paths, index);
301
+
302
+ // Small delay between pages to be nice to the API
303
+ if (pageToken) {
304
+ await sleep(500);
305
+ }
306
+ } while (pageToken);
307
+
308
+ const activeContacts = Object.values(index.contacts).filter(c => !c.deleted).length;
309
+ const tombstones = Object.values(index.contacts).filter(c => c.deleted).length;
310
+
311
+ console.log(`\n\nSync complete:`);
312
+ console.log(` Processed: ${totalProcessed}`);
313
+ console.log(` Added: ${added}`);
314
+ console.log(` Updated: ${updated}`);
315
+ console.log(` Deleted: ${deleted}`);
316
+ console.log(` Active contacts: ${activeContacts}`);
317
+ console.log(` Tombstones: ${tombstones}`);
318
+ }
319
+
320
+ function loadStatus(paths: UserPaths): PushStatus {
321
+ if (fs.existsSync(paths.statusFile)) {
322
+ return JSON.parse(fs.readFileSync(paths.statusFile, 'utf-8'));
323
+ }
324
+ return { lastPush: '' };
325
+ }
326
+
327
+ function saveStatus(paths: UserPaths, status: PushStatus): void {
328
+ fs.writeFileSync(paths.statusFile, JSON.stringify(status, null, 2));
329
+ }
330
+
331
+ function findPendingChanges(paths: UserPaths): PendingChange[] {
332
+ const status = loadStatus(paths);
333
+ const lastPush = status.lastPush ? new Date(status.lastPush).getTime() : 0;
334
+ const changes: PendingChange[] = [];
335
+
336
+ // Check contacts/ for modified files and _deleted markers
337
+ if (fs.existsSync(paths.contactsDir)) {
338
+ const files = fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'));
339
+ for (const file of files) {
340
+ const filePath = path.join(paths.contactsDir, file);
341
+ const stat = fs.statSync(filePath);
342
+ const modTime = stat.mtimeMs;
343
+
344
+ if (modTime > lastPush) {
345
+ const content = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson & { _deleted?: boolean };
346
+ const displayName = content.names?.[0]?.displayName || 'Unknown';
347
+
348
+ if (content._deleted) {
349
+ changes.push({
350
+ type: 'delete',
351
+ resourceName: content.resourceName,
352
+ displayName,
353
+ filePath
354
+ });
355
+ } else {
356
+ changes.push({
357
+ type: 'update',
358
+ resourceName: content.resourceName,
359
+ displayName,
360
+ filePath
361
+ });
362
+ }
363
+ }
364
+ }
365
+ }
366
+
367
+ // Check deleted/ folder
368
+ if (fs.existsSync(paths.deletedDir)) {
369
+ const files = fs.readdirSync(paths.deletedDir).filter(f => f.endsWith('.json'));
370
+ for (const file of files) {
371
+ const filePath = path.join(paths.deletedDir, file);
372
+ const content = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
373
+ const displayName = content.names?.[0]?.displayName || 'Unknown';
374
+
375
+ changes.push({
376
+ type: 'delete',
377
+ resourceName: content.resourceName,
378
+ displayName,
379
+ filePath
380
+ });
381
+ }
382
+ }
383
+
384
+ return changes;
385
+ }
386
+
387
+ async function confirm(message: string): Promise<boolean> {
388
+ const readline = await import('readline');
389
+ const rl = readline.createInterface({
390
+ input: process.stdin,
391
+ output: process.stdout
392
+ });
393
+
394
+ return new Promise(resolve => {
395
+ rl.question(`${message} (y/N): `, answer => {
396
+ rl.close();
397
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
398
+ });
399
+ });
400
+ }
401
+
402
+ async function updateContactOnGoogle(accessToken: string, person: GooglePerson): Promise<void> {
403
+ const updateMask = 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays';
404
+
405
+ const response = await fetch(`${PEOPLE_API_BASE}/${person.resourceName}:updateContact?updatePersonFields=${updateMask}`, {
406
+ method: 'PATCH',
407
+ headers: {
408
+ 'Authorization': `Bearer ${accessToken}`,
409
+ 'Content-Type': 'application/json'
410
+ },
411
+ body: JSON.stringify(person)
412
+ });
413
+
414
+ if (!response.ok) {
415
+ throw new Error(`Update failed for ${person.resourceName}: ${response.status} ${await response.text()}`);
416
+ }
417
+ }
418
+
419
+ async function deleteContactOnGoogle(accessToken: string, resourceName: string): Promise<void> {
420
+ const response = await fetch(`${PEOPLE_API_BASE}/${resourceName}:deleteContact`, {
421
+ method: 'DELETE',
422
+ headers: { 'Authorization': `Bearer ${accessToken}` }
423
+ });
424
+
425
+ if (!response.ok) {
426
+ throw new Error(`Delete failed for ${resourceName}: ${response.status} ${await response.text()}`);
427
+ }
428
+ }
429
+
430
+ async function pushContacts(user: string, options: { yes: boolean; verbose: boolean }): Promise<void> {
431
+ const paths = getUserPaths(user);
432
+ ensureUserDir(user);
433
+
434
+ console.log(`Pushing contacts for user: ${user}`);
435
+ const changes = findPendingChanges(paths);
436
+
437
+ if (changes.length === 0) {
438
+ console.log('No pending changes to push.');
439
+ return;
440
+ }
441
+
442
+ const updates = changes.filter(c => c.type === 'update');
443
+ const deletes = changes.filter(c => c.type === 'delete');
444
+
445
+ console.log('\nPending changes:');
446
+ if (updates.length > 0) {
447
+ console.log(`\n Updates (${updates.length}):`);
448
+ for (const c of updates) {
449
+ console.log(` - ${c.displayName} (${c.resourceName})`);
450
+ }
451
+ }
452
+ if (deletes.length > 0) {
453
+ console.log(`\n Deletions (${deletes.length}):`);
454
+ for (const c of deletes) {
455
+ console.log(` - ${c.displayName} (${c.resourceName})`);
456
+ }
457
+ }
458
+
459
+ if (!options.yes) {
460
+ const confirmed = await confirm(`\nPush ${changes.length} change(s) to Google?`);
461
+ if (!confirmed) {
462
+ console.log('Aborted.');
463
+ return;
464
+ }
465
+ }
466
+
467
+ console.log('\nGetting access token (write access)...');
468
+ const accessToken = await getAccessToken(user, true);
469
+
470
+ const index = loadIndex(paths);
471
+ let successCount = 0;
472
+ let errorCount = 0;
473
+
474
+ for (const change of changes) {
475
+ try {
476
+ if (change.type === 'update') {
477
+ const content = JSON.parse(fs.readFileSync(change.filePath, 'utf-8')) as GooglePerson;
478
+ process.stdout.write(`Updating ${change.displayName}...`);
479
+ await updateContactOnGoogle(accessToken, content);
480
+ console.log(' done');
481
+ successCount++;
482
+ } else {
483
+ process.stdout.write(`Deleting ${change.displayName}...`);
484
+ await deleteContactOnGoogle(accessToken, change.resourceName);
485
+
486
+ // Update index
487
+ if (index.contacts[change.resourceName]) {
488
+ index.contacts[change.resourceName].deleted = true;
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);
495
+ }
496
+
497
+ console.log(' done');
498
+ successCount++;
499
+ }
500
+
501
+ await sleep(200); // Be nice to the API
502
+ } catch (error: any) {
503
+ console.log(` ERROR: ${error.message}`);
504
+ errorCount++;
505
+ debugger;
506
+ }
507
+ }
508
+
509
+ // Save status with new timestamp
510
+ saveStatus(paths, { lastPush: new Date().toISOString() });
511
+ saveIndex(paths, index);
512
+
513
+ console.log(`\nPush complete: ${successCount} succeeded, ${errorCount} failed`);
514
+ }
515
+
516
+ function resolveUser(cliUser: string): string {
517
+ // 1. Use CLI-specified user if provided (and not 'default')
518
+ if (cliUser && cliUser !== 'default') {
519
+ const normalized = normalizeUser(cliUser);
520
+ const config = loadConfig();
521
+ config.lastUser = normalized;
522
+ saveConfig(config);
523
+ return normalized;
524
+ }
525
+
526
+ // 2. Use lastUser from config (already normalized)
527
+ const config = loadConfig();
528
+ if (config.lastUser) {
529
+ return config.lastUser;
530
+ }
531
+
532
+ // 3. No user - require --user on first use
533
+ console.error('No user specified. Use --user <name> on first run (e.g., your Gmail username).');
534
+ console.error('This will be remembered for future runs.');
535
+ process.exit(1);
536
+ }
537
+
538
+ async function main(): Promise<void> {
539
+ const args = process.argv.slice(2);
540
+ const options = parseArgs(args);
541
+
542
+ if (options.help || !options.command) {
543
+ showHelp();
544
+ return;
545
+ }
546
+
547
+ try {
548
+ const user = resolveUser(options.user);
549
+
550
+ switch (options.command) {
551
+ case 'sync':
552
+ await syncContacts(user, { full: options.full, verbose: options.verbose });
553
+ break;
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();
560
+ process.exit(1);
561
+ }
562
+
563
+ // Update lastUser in config
564
+ const config = loadConfig();
565
+ config.lastUser = user;
566
+ saveConfig(config);
567
+
568
+ } catch (error: any) {
569
+ console.error(`Error: ${error.message}`);
570
+ debugger;
571
+ process.exit(1);
572
+ }
573
+ }
574
+
575
+ if (import.meta.main) {
576
+ await main();
577
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@bobfrankston/gcards",
3
+ "version": "0.1.0",
4
+ "description": "Google Contacts cleanup and management tool",
5
+ "type": "module",
6
+ "main": "gcards.ts",
7
+ "bin": {
8
+ "gcards": "gcards.ts"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/bobfrankston/gcards.git"
13
+ },
14
+ "scripts": {
15
+ "check": "tsc --noEmit",
16
+ "prerelease:local": "git add -A && (git diff-index --quiet HEAD || git commit -m \"Pre-release commit\")",
17
+ "preversion": "npm run check && git add -A",
18
+ "postversion": "git push && git push --tags",
19
+ "release": "npm run prerelease:local && npm version patch && npm publish --access public"
20
+ },
21
+ "keywords": [
22
+ "google",
23
+ "contacts",
24
+ "cleanup",
25
+ "oauth"
26
+ ],
27
+ "author": "Bob Frankston",
28
+ "license": "MIT",
29
+ "devDependencies": {
30
+ "@types/node": "^22.10.1"
31
+ },
32
+ "dependencies": {
33
+ "@bobfrankston/miscassists": "file:../../../projects/NodeJS/miscassists"
34
+ }
35
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "allowSyntheticDefaultImports": true,
7
+ "esModuleInterop": true,
8
+ "allowJs": true,
9
+ "allowImportingTsExtensions": true,
10
+ "strict": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "skipLibCheck": true,
13
+ "noEmit": true,
14
+ "strictNullChecks": false,
15
+ "noImplicitAny": true,
16
+ "noImplicitReturns": false,
17
+ "noImplicitThis": true,
18
+ "newLine": "lf"
19
+ },
20
+ "exclude": [
21
+ "node_modules",
22
+ "cruft",
23
+ ".git",
24
+ "tests",
25
+ "prev"
26
+ ]
27
+ }
package/types.ts ADDED
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Google People API Type Definitions
3
+ * Reference: https://developers.google.com/people/api/rest/v1/people
4
+ */
5
+
6
+ // ============================================================
7
+ // Google People API Response Types (from API)
8
+ // ============================================================
9
+
10
+ export interface GooglePersonMetadata {
11
+ sources?: Array<{
12
+ type: string; // "CONTACT", "PROFILE", "DOMAIN_PROFILE"
13
+ id: string;
14
+ etag?: string;
15
+ updateTime?: string;
16
+ }>;
17
+ previousResourceNames?: string[];
18
+ linkedPeopleResourceNames?: string[];
19
+ deleted?: boolean;
20
+ }
21
+
22
+ export interface GoogleName {
23
+ metadata?: FieldMetadata;
24
+ displayName?: string;
25
+ displayNameLastFirst?: string;
26
+ familyName?: string;
27
+ givenName?: string;
28
+ middleName?: string;
29
+ honorificPrefix?: string;
30
+ honorificSuffix?: string;
31
+ phoneticFullName?: string;
32
+ phoneticFamilyName?: string;
33
+ phoneticGivenName?: string;
34
+ phoneticMiddleName?: string;
35
+ phoneticHonorificPrefix?: string;
36
+ phoneticHonorificSuffix?: string;
37
+ unstructuredName?: string;
38
+ }
39
+
40
+ export interface GoogleNickname {
41
+ metadata?: FieldMetadata;
42
+ value?: string;
43
+ type?: string; // "DEFAULT", "MAIDEN_NAME", "INITIALS", etc.
44
+ }
45
+
46
+ export interface GoogleEmailAddress {
47
+ metadata?: FieldMetadata;
48
+ value?: string;
49
+ type?: string; // "home", "work", "other"
50
+ formattedType?: string;
51
+ displayName?: string;
52
+ }
53
+
54
+ export interface GooglePhoneNumber {
55
+ metadata?: FieldMetadata;
56
+ value?: string;
57
+ canonicalForm?: string; // E.164 format
58
+ type?: string; // "home", "work", "mobile", etc.
59
+ formattedType?: string;
60
+ }
61
+
62
+ export interface GoogleAddress {
63
+ metadata?: FieldMetadata;
64
+ formattedValue?: string;
65
+ type?: string;
66
+ formattedType?: string;
67
+ poBox?: string;
68
+ streetAddress?: string;
69
+ extendedAddress?: string;
70
+ city?: string;
71
+ region?: string;
72
+ postalCode?: string;
73
+ country?: string;
74
+ countryCode?: string;
75
+ }
76
+
77
+ export interface GoogleOrganization {
78
+ metadata?: FieldMetadata;
79
+ type?: string;
80
+ formattedType?: string;
81
+ name?: string;
82
+ department?: string;
83
+ title?: string;
84
+ jobDescription?: string;
85
+ symbol?: string;
86
+ phoneticName?: string;
87
+ location?: string;
88
+ startDate?: GoogleDate;
89
+ endDate?: GoogleDate;
90
+ current?: boolean;
91
+ }
92
+
93
+ export interface GoogleBiography {
94
+ metadata?: FieldMetadata;
95
+ value?: string;
96
+ contentType?: string; // "TEXT_PLAIN", "TEXT_HTML"
97
+ }
98
+
99
+ export interface GoogleUrl {
100
+ metadata?: FieldMetadata;
101
+ value?: string;
102
+ type?: string;
103
+ formattedType?: string;
104
+ }
105
+
106
+ export interface GooglePhoto {
107
+ metadata?: FieldMetadata;
108
+ url?: string;
109
+ default?: boolean;
110
+ }
111
+
112
+ export interface GoogleBirthday {
113
+ metadata?: FieldMetadata;
114
+ date?: GoogleDate;
115
+ text?: string;
116
+ }
117
+
118
+ export interface GoogleDate {
119
+ year?: number;
120
+ month?: number;
121
+ day?: number;
122
+ }
123
+
124
+ export interface GoogleEvent {
125
+ metadata?: FieldMetadata;
126
+ date?: GoogleDate;
127
+ type?: string;
128
+ formattedType?: string;
129
+ }
130
+
131
+ export interface GoogleRelation {
132
+ metadata?: FieldMetadata;
133
+ person?: string;
134
+ type?: string;
135
+ formattedType?: string;
136
+ }
137
+
138
+ export interface GoogleMembership {
139
+ metadata?: FieldMetadata;
140
+ contactGroupMembership?: {
141
+ contactGroupId?: string;
142
+ contactGroupResourceName?: string;
143
+ };
144
+ domainMembership?: {
145
+ inViewerDomain?: boolean;
146
+ };
147
+ }
148
+
149
+ export interface GoogleUserDefined {
150
+ metadata?: FieldMetadata;
151
+ key?: string;
152
+ value?: string;
153
+ }
154
+
155
+ export interface FieldMetadata {
156
+ primary?: boolean;
157
+ verified?: boolean;
158
+ source?: {
159
+ type: string;
160
+ id: string;
161
+ };
162
+ sourcePrimary?: boolean;
163
+ }
164
+
165
+ /**
166
+ * Full Google Person resource as returned by People API
167
+ */
168
+ export interface GooglePerson {
169
+ resourceName: string; // e.g., "people/c1234567890"
170
+ etag?: string;
171
+ metadata?: GooglePersonMetadata;
172
+ names?: GoogleName[];
173
+ nicknames?: GoogleNickname[];
174
+ photos?: GooglePhoto[];
175
+ birthdays?: GoogleBirthday[];
176
+ addresses?: GoogleAddress[];
177
+ emailAddresses?: GoogleEmailAddress[];
178
+ phoneNumbers?: GooglePhoneNumber[];
179
+ organizations?: GoogleOrganization[];
180
+ biographies?: GoogleBiography[];
181
+ urls?: GoogleUrl[];
182
+ events?: GoogleEvent[];
183
+ relations?: GoogleRelation[];
184
+ memberships?: GoogleMembership[];
185
+ userDefined?: GoogleUserDefined[];
186
+ imClients?: Array<{
187
+ metadata?: FieldMetadata;
188
+ username?: string;
189
+ type?: string;
190
+ formattedType?: string;
191
+ protocol?: string;
192
+ formattedProtocol?: string;
193
+ }>;
194
+ sipAddresses?: Array<{
195
+ metadata?: FieldMetadata;
196
+ value?: string;
197
+ type?: string;
198
+ formattedType?: string;
199
+ }>;
200
+ fileAses?: Array<{
201
+ metadata?: FieldMetadata;
202
+ value?: string;
203
+ }>;
204
+ }
205
+
206
+ export interface GoogleConnectionsResponse {
207
+ connections?: GooglePerson[];
208
+ nextPageToken?: string;
209
+ nextSyncToken?: string;
210
+ totalPeople?: number;
211
+ totalItems?: number;
212
+ }
213
+
214
+ // ============================================================
215
+ // Local Simplified Format (for analysis/cleanup)
216
+ // ============================================================
217
+
218
+ export interface LocalContact {
219
+ // Identity & reconciliation
220
+ resourceName: string; // From Google - used for updates
221
+ etag?: string; // For conflict detection
222
+
223
+ // Core identity
224
+ displayName: string;
225
+ givenName?: string;
226
+ familyName?: string;
227
+ nickname?: string;
228
+
229
+ // Contact info (flattened, typed arrays)
230
+ emails: Array<{ value: string; type?: string; primary?: boolean }>;
231
+ phones: Array<{ value: string; type?: string; primary?: boolean }>;
232
+ addresses: Array<{
233
+ formatted?: string;
234
+ street?: string;
235
+ city?: string;
236
+ region?: string;
237
+ postalCode?: string;
238
+ country?: string;
239
+ type?: string;
240
+ }>;
241
+
242
+ // Professional
243
+ organizations: Array<{
244
+ name?: string;
245
+ title?: string;
246
+ department?: string;
247
+ }>;
248
+
249
+ // Social/web
250
+ urls: Array<{ value: string; type?: string }>;
251
+
252
+ // Dates
253
+ birthday?: { year?: number; month?: number; day?: number };
254
+
255
+ // Notes
256
+ notes?: string;
257
+
258
+ // Groups
259
+ groups: string[]; // contactGroupResourceNames
260
+
261
+ // Metadata for cleanup
262
+ _meta: {
263
+ sourceType: string; // "CONTACT", "PROFILE", etc.
264
+ updateTime?: string;
265
+ isTestEntry?: boolean; // Flagged as test (m-XXXX@bob.ma pattern)
266
+ duplicateOf?: string[]; // resourceNames of potential duplicates
267
+ mergeConfidence?: number; // 0-1 confidence for merge candidates
268
+ };
269
+ }
270
+
271
+ // ============================================================
272
+ // Cleanup Analysis Types
273
+ // ============================================================
274
+
275
+ export interface DuplicateGroup {
276
+ primary: string; // resourceName of "best" contact
277
+ duplicates: string[]; // resourceNames to merge into primary
278
+ confidence: number; // 0-1 merge confidence
279
+ matchReasons: string[]; // Why these are considered duplicates
280
+ }
281
+
282
+ export interface CleanupReport {
283
+ totalContacts: number;
284
+ testEntries: LocalContact[]; // To be deleted
285
+ duplicateGroups: DuplicateGroup[];
286
+ emptyContacts: LocalContact[]; // No useful info
287
+ suggestions: string[]; // Human-readable recommendations
288
+ }
289
+
290
+ // ============================================================
291
+ // Deletion Logging (for tracking cleanup history)
292
+ // ============================================================
293
+
294
+ export interface DeletionRecord {
295
+ resourceName: string;
296
+ displayName: string;
297
+ reason: string; // "test_entry", "duplicate_of:people/cXXX", "empty", etc.
298
+ deletedAt: string; // ISO timestamp
299
+ originalData: LocalContact; // Snapshot before deletion
300
+ }
301
+
302
+ export interface DeletionLog {
303
+ deletions: DeletionRecord[];
304
+ lastUpdated: string; // ISO timestamp
305
+ }