@bobfrankston/gcards 0.1.0 → 0.1.4

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/gfix.ts ADDED
@@ -0,0 +1,1472 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gfix - One-time fix routines for gcards contacts
4
+ * Inspects and cleans up name fields with placeholder characters
5
+ *
6
+ * Usage:
7
+ * gfix inspect -u <user> Show what would be changed (no modifications)
8
+ * gfix apply -u <user> Apply changes and create log file
9
+ */
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import type { GooglePerson, GoogleName, GooglePhoneNumber, GoogleEmailAddress, GoogleBirthday } from './glib/types.ts';
14
+ import { DATA_DIR, resolveUser, getUserPaths } from './glib/gutils.ts';
15
+ import { mergeContacts as mergeContactData, collectSourcePhotos, type MergeEntry, type PhotoEntry } from './glib/gmerge.ts';
16
+
17
+ interface BirthdayEntry {
18
+ name: string;
19
+ date: string;
20
+ }
21
+
22
+ interface FileAsEntry {
23
+ givenName: string;
24
+ familyName: string;
25
+ fileAs: string;
26
+ jsonFile: string;
27
+ }
28
+
29
+ interface NameChangeLog {
30
+ jsonFile: string;
31
+ displayName: string;
32
+ changes: string[];
33
+ }
34
+
35
+ interface FieldChange {
36
+ field: string;
37
+ oldValue: string;
38
+ newValue: string; /** null = delete field */
39
+ }
40
+
41
+ interface PhoneRemoval {
42
+ field: 'phoneNumbers';
43
+ removed: string[]; /** Phone values that were removed as duplicates */
44
+ }
45
+
46
+ interface EmailRemoval {
47
+ field: 'emailAddresses';
48
+ removed: string[]; /** Email values that were removed as duplicates */
49
+ }
50
+
51
+ type Change = FieldChange | PhoneRemoval | EmailRemoval;
52
+
53
+ interface ContactChange {
54
+ resourceName: string;
55
+ displayName: string;
56
+ filePath: string;
57
+ changes: Change[];
58
+ }
59
+
60
+ interface FixLog {
61
+ timestamp: string;
62
+ user: string;
63
+ mode: 'inspect' | 'apply';
64
+ totalContacts: number;
65
+ contactsChanged: number;
66
+ changes: ContactChange[];
67
+ }
68
+
69
+ // Characters to strip from beginning/end of name fields
70
+ const STRIP_CHARS = /^[\s\-']+|[\s\-']+$/g;
71
+ // Collapse multiple dashes/spaces in middle to single space
72
+ const MIDDLE_JUNK = /[\s\-]+/g;
73
+ // Match honorific prefix (Dr., Prof., etc.) at start - may repeat many times
74
+ const HONORIFIC_PREFIX = /^((dr|prof)\.?\s*)+/i;
75
+
76
+ // Fields in GoogleName to clean (excludes honorificPrefix - handled specially)
77
+ const NAME_FIELDS: (keyof GoogleName)[] = [
78
+ 'displayName',
79
+ 'givenName',
80
+ 'familyName',
81
+ 'middleName',
82
+ 'displayNameLastFirst',
83
+ 'unstructuredName',
84
+ 'honorificSuffix'
85
+ ];
86
+
87
+ interface CleanResult {
88
+ value: string;
89
+ extractedPrefix: string;
90
+ }
91
+
92
+ function cleanNameValue(value: string, extractHonorific = false): CleanResult {
93
+ if (!value) return { value: null, extractedPrefix: null };
94
+
95
+ let extractedPrefix: string = null;
96
+ let cleaned = value;
97
+
98
+ // Extract honorific prefix if requested
99
+ if (extractHonorific) {
100
+ const match = cleaned.match(HONORIFIC_PREFIX);
101
+ if (match) {
102
+ // Find unique honorifics in the matched string (handles "dr dr dr prof dr")
103
+ const matchedStr = match[0].toLowerCase();
104
+ const hasProf = /prof/i.test(matchedStr);
105
+ const hasDr = /dr/i.test(matchedStr);
106
+ // Prof takes precedence if both present, otherwise Dr
107
+ if (hasProf) {
108
+ extractedPrefix = 'Prof.';
109
+ } else if (hasDr) {
110
+ extractedPrefix = 'Dr.';
111
+ }
112
+ cleaned = cleaned.replace(HONORIFIC_PREFIX, '');
113
+ }
114
+ }
115
+
116
+ // Strip leading/trailing junk, collapse middle dashes/spaces to single space
117
+ cleaned = cleaned.replace(STRIP_CHARS, '').replace(MIDDLE_JUNK, ' ').trim();
118
+ // If only whitespace/dashes/quotes remain, delete the field
119
+ if (!cleaned || cleaned === '-' || cleaned === "'" || cleaned === '- -') {
120
+ return { value: null, extractedPrefix };
121
+ }
122
+ return { value: cleaned, extractedPrefix };
123
+ }
124
+
125
+ /** Normalize phone number for comparison (strip non-digits) */
126
+ function normalizePhone(phone: string): string {
127
+ return (phone || '').replace(/\D/g, '');
128
+ }
129
+
130
+ /** Find duplicate phone numbers and return indices to remove */
131
+ function findDuplicatePhones(phones: GooglePhoneNumber[]): string[] {
132
+ if (!phones || phones.length < 2) return [];
133
+
134
+ const seen = new Set<string>();
135
+ const duplicates: string[] = [];
136
+
137
+ for (const phone of phones) {
138
+ const normalized = normalizePhone(phone.value || phone.canonicalForm);
139
+ if (!normalized) continue;
140
+
141
+ if (seen.has(normalized)) {
142
+ duplicates.push(phone.value || phone.canonicalForm);
143
+ } else {
144
+ seen.add(normalized);
145
+ }
146
+ }
147
+
148
+ return duplicates;
149
+ }
150
+
151
+ /** Normalize email for comparison (lowercase, trim) */
152
+ function normalizeEmail(email: string): string {
153
+ return (email || '').toLowerCase().trim();
154
+ }
155
+
156
+ /** Find duplicate email addresses and return values to remove */
157
+ function findDuplicateEmails(emails: GoogleEmailAddress[]): string[] {
158
+ if (!emails || emails.length < 2) return [];
159
+
160
+ const seen = new Set<string>();
161
+ const duplicates: string[] = [];
162
+
163
+ for (const email of emails) {
164
+ const normalized = normalizeEmail(email.value);
165
+ if (!normalized) continue;
166
+
167
+ if (seen.has(normalized)) {
168
+ duplicates.push(email.value);
169
+ } else {
170
+ seen.add(normalized);
171
+ }
172
+ }
173
+
174
+ return duplicates;
175
+ }
176
+
177
+ function processContact(contact: GooglePerson): Change[] {
178
+ const changes: Change[] = [];
179
+
180
+ // Process name fields if present
181
+ for (const name of contact.names || []) {
182
+ const currentHonorific = name.honorificPrefix;
183
+ let extractedHonorific: string = null;
184
+
185
+ // Process name fields, extracting honorific from givenName/displayName/unstructuredName
186
+ for (const field of NAME_FIELDS) {
187
+ const oldValue = name[field] as string;
188
+ if (oldValue) {
189
+ const shouldExtract = !currentHonorific &&
190
+ (field === 'givenName' || field === 'displayName' || field === 'unstructuredName');
191
+ const result = cleanNameValue(oldValue, shouldExtract);
192
+
193
+ if (result.extractedPrefix && !extractedHonorific) {
194
+ extractedHonorific = result.extractedPrefix;
195
+ }
196
+
197
+ if (result.value !== oldValue) {
198
+ changes.push({ field, oldValue, newValue: result.value });
199
+ }
200
+ }
201
+ }
202
+
203
+ // Clean existing honorificPrefix
204
+ if (currentHonorific) {
205
+ const result = cleanNameValue(currentHonorific, false);
206
+ if (result.value !== currentHonorific) {
207
+ changes.push({ field: 'honorificPrefix', oldValue: currentHonorific, newValue: result.value });
208
+ }
209
+ } else if (extractedHonorific) {
210
+ // Set honorificPrefix from extracted value
211
+ changes.push({ field: 'honorificPrefix', oldValue: '', newValue: extractedHonorific });
212
+ }
213
+ }
214
+
215
+ // Check for duplicate phone numbers
216
+ const duplicatePhones = findDuplicatePhones(contact.phoneNumbers);
217
+ if (duplicatePhones.length > 0) {
218
+ changes.push({ field: 'phoneNumbers', removed: duplicatePhones });
219
+ }
220
+
221
+ // Check for duplicate email addresses
222
+ const duplicateEmails = findDuplicateEmails(contact.emailAddresses);
223
+ if (duplicateEmails.length > 0) {
224
+ changes.push({ field: 'emailAddresses', removed: duplicateEmails });
225
+ }
226
+
227
+ return changes;
228
+ }
229
+
230
+ function applyChangesToContact(contact: GooglePerson, changes: Change[]): void {
231
+ for (const change of changes) {
232
+ if (change.field === 'phoneNumbers') {
233
+ // Remove duplicate phones
234
+ if (contact.phoneNumbers) {
235
+ const seen = new Set<string>();
236
+ contact.phoneNumbers = contact.phoneNumbers.filter(phone => {
237
+ const normalized = normalizePhone(phone.value || phone.canonicalForm);
238
+ if (seen.has(normalized)) {
239
+ return false; // Duplicate - remove
240
+ }
241
+ seen.add(normalized);
242
+ return true;
243
+ });
244
+ }
245
+ } else if (change.field === 'emailAddresses') {
246
+ // Remove duplicate emails
247
+ if (contact.emailAddresses) {
248
+ const seen = new Set<string>();
249
+ contact.emailAddresses = contact.emailAddresses.filter(email => {
250
+ const normalized = normalizeEmail(email.value);
251
+ if (seen.has(normalized)) {
252
+ return false; // Duplicate - remove
253
+ }
254
+ seen.add(normalized);
255
+ return true;
256
+ });
257
+ }
258
+ } else {
259
+ // Name field change
260
+ const fieldChange = change as FieldChange;
261
+ if (!contact.names) continue;
262
+ for (const name of contact.names) {
263
+ const field = fieldChange.field as keyof GoogleName;
264
+ if (!fieldChange.newValue) {
265
+ delete name[field];
266
+ } else {
267
+ (name as any)[field] = fieldChange.newValue;
268
+ }
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+
275
+ /** Format birthday date as string (with or without year) */
276
+ function formatBirthdayDate(birthday: GoogleBirthday): string {
277
+ if (birthday.text) return birthday.text;
278
+ if (!birthday.date) return '';
279
+
280
+ const { year, month, day } = birthday.date;
281
+ if (!month || !day) return '';
282
+
283
+ const mm = String(month).padStart(2, '0');
284
+ const dd = String(day).padStart(2, '0');
285
+
286
+ if (year) {
287
+ return `${year}-${mm}-${dd}`;
288
+ }
289
+ return `${mm}-${dd}`;
290
+ }
291
+
292
+ /** Extract and remove birthdays from contacts, return sorted CSV data */
293
+ async function runBirthdayExtract(user: string, mode: 'inspect' | 'apply'): Promise<void> {
294
+ const paths = getUserPaths(user);
295
+
296
+ if (!fs.existsSync(paths.contactsDir)) {
297
+ console.error(`No contacts directory for user: ${user}`);
298
+ console.error(`Expected: ${paths.contactsDir}`);
299
+ process.exit(1);
300
+ }
301
+
302
+ const files = fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'));
303
+ console.log(`\n${mode === 'inspect' ? 'INSPECT' : 'APPLY'} birthday extraction for user: ${user}`);
304
+ console.log(`Found ${files.length} contact files\n`);
305
+
306
+ const birthdays: BirthdayEntry[] = [];
307
+ let contactsModified = 0;
308
+
309
+ for (const file of files) {
310
+ const filePath = path.join(paths.contactsDir, file);
311
+ const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
312
+
313
+ if (!contact.birthdays?.length) continue;
314
+
315
+ const displayName = contact.names?.[0]?.displayName || 'Unknown';
316
+
317
+ for (const birthday of contact.birthdays) {
318
+ const dateStr = formatBirthdayDate(birthday);
319
+ if (dateStr) {
320
+ birthdays.push({ name: displayName, date: dateStr });
321
+ console.log(` ${displayName}: ${dateStr}`);
322
+ }
323
+ }
324
+
325
+ if (mode === 'apply') {
326
+ delete contact.birthdays;
327
+ fs.writeFileSync(filePath, JSON.stringify(contact, null, 2));
328
+ contactsModified++;
329
+ }
330
+ }
331
+
332
+ // Write CSV (append to existing)
333
+ const csvPath = path.join(paths.userDir, 'birthdays.csv');
334
+
335
+ // Load existing birthdays
336
+ const existingBirthdays: { name: string; date: string }[] = [];
337
+ if (fs.existsSync(csvPath)) {
338
+ const lines = fs.readFileSync(csvPath, 'utf-8').split('\n').slice(1); // skip header
339
+ for (const line of lines) {
340
+ const match = line.match(/^"(.+)",(.+)$/);
341
+ if (match) {
342
+ existingBirthdays.push({ name: match[1].replace(/""/g, '"'), date: match[2] });
343
+ }
344
+ }
345
+ }
346
+
347
+ // Merge, avoiding duplicates (by name)
348
+ const existingNames = new Set(existingBirthdays.map(b => b.name.toLowerCase()));
349
+ let newCount = 0;
350
+ for (const b of birthdays) {
351
+ if (!existingNames.has(b.name.toLowerCase())) {
352
+ existingBirthdays.push(b);
353
+ newCount++;
354
+ }
355
+ }
356
+
357
+ // Sort by name
358
+ existingBirthdays.sort((a, b) => a.name.localeCompare(b.name));
359
+
360
+ const csvContent = 'name,date\n' + existingBirthdays.map(b => `"${b.name.replace(/"/g, '""')}",${b.date}`).join('\n');
361
+
362
+ if (mode === 'apply') {
363
+ fs.writeFileSync(csvPath, csvContent);
364
+ console.log(` New birthdays added: ${newCount} (${existingBirthdays.length} total)`);
365
+ }
366
+
367
+ console.log(`\n${'='.repeat(50)}`);
368
+ console.log(`Summary:`);
369
+ console.log(` Contacts with birthdays: ${birthdays.length}`);
370
+ if (mode === 'apply') {
371
+ console.log(` Contacts modified: ${contactsModified}`);
372
+ console.log(` CSV file: ${csvPath}`);
373
+ console.log(`\nAfter review, run: gcards push -u ${user}`);
374
+ } else {
375
+ console.log(`\nTo extract birthdays, run: gfix birthday -u ${user} --apply`);
376
+ }
377
+ }
378
+
379
+ /** Fix fileAs fields: normalize "Last, First" to "First Last"
380
+ * Also fix displayNameLastFirst to proper "Last, First" format */
381
+ async function runFileAsFix(user: string, mode: 'inspect' | 'apply'): Promise<void> {
382
+ const paths = getUserPaths(user);
383
+
384
+ if (!fs.existsSync(paths.contactsDir)) {
385
+ console.error(`No contacts directory for user: ${user}`);
386
+ console.error(`Expected: ${paths.contactsDir}`);
387
+ process.exit(1);
388
+ }
389
+
390
+ const files = fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'));
391
+ console.log(`\n${mode === 'inspect' ? 'INSPECT' : 'APPLY'} fileAs/displayNameLastFirst normalization for user: ${user}`);
392
+ console.log(`Found ${files.length} contact files\n`);
393
+
394
+ const needsReview: FileAsEntry[] = [];
395
+ let fileAsFixedCount = 0;
396
+ let displayNameLastFirstFixedCount = 0;
397
+ let skippedCount = 0;
398
+
399
+ for (const file of files) {
400
+ const filePath = path.join(paths.contactsDir, file);
401
+ const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
402
+
403
+ const name = contact.names?.[0];
404
+ const givenName = name?.givenName || '';
405
+ const familyName = name?.familyName || '';
406
+ const displayName = name?.displayName || '';
407
+ let modified = false;
408
+
409
+ // Fix displayNameLastFirst if we have givenName and familyName
410
+ if (name && givenName && familyName) {
411
+ const expectedLastFirst = `${familyName}, ${givenName}`;
412
+ if (name.displayNameLastFirst !== expectedLastFirst) {
413
+ console.log(` ${displayName}: displayNameLastFirst "${name.displayNameLastFirst || ''}" -> "${expectedLastFirst}"`);
414
+ if (mode === 'apply') {
415
+ name.displayNameLastFirst = expectedLastFirst;
416
+ modified = true;
417
+ }
418
+ displayNameLastFirstFixedCount++;
419
+ }
420
+ }
421
+
422
+ // Fix fileAs: "Last, First" -> "First Last"
423
+ if (contact.fileAses?.length) {
424
+ const fileAs = contact.fileAses[0].value || '';
425
+
426
+ // Check if fileAs is "Last, First" format
427
+ const commaMatch = fileAs.match(/^(.+),\s*(.+)$/);
428
+ if (commaMatch) {
429
+ const [, lastPart, firstPart] = commaMatch;
430
+
431
+ // If we have givenName and familyName, we can auto-fix
432
+ if (givenName && familyName) {
433
+ // Verify the parts match (case-insensitive)
434
+ const firstMatches = firstPart.toLowerCase().startsWith(givenName.toLowerCase()) ||
435
+ givenName.toLowerCase().startsWith(firstPart.toLowerCase());
436
+ const lastMatches = lastPart.toLowerCase() === familyName.toLowerCase();
437
+
438
+ if (firstMatches && lastMatches) {
439
+ const newFileAs = `${givenName} ${familyName}`;
440
+ console.log(` ${displayName}: fileAs "${fileAs}" -> "${newFileAs}"`);
441
+
442
+ if (mode === 'apply') {
443
+ contact.fileAses[0].value = newFileAs;
444
+ modified = true;
445
+ }
446
+ fileAsFixedCount++;
447
+ } else {
448
+ // Mismatch - add to review
449
+ needsReview.push({ givenName, familyName, fileAs, jsonFile: file });
450
+ skippedCount++;
451
+ }
452
+ } else {
453
+ // No familyName - need manual review
454
+ needsReview.push({ givenName: givenName || displayName, familyName, fileAs, jsonFile: file });
455
+ skippedCount++;
456
+ }
457
+ }
458
+ }
459
+
460
+ if (modified) {
461
+ fs.writeFileSync(filePath, JSON.stringify(contact, null, 2));
462
+ }
463
+ }
464
+
465
+ // Write CSV for entries needing review
466
+ if (needsReview.length > 0) {
467
+ needsReview.sort((a, b) => a.fileAs.localeCompare(b.fileAs));
468
+ const csvPath = path.join(paths.userDir, 'namefix.csv');
469
+ const csvContent = 'givenName,familyName,fileAs,jsonFile\n' +
470
+ needsReview.map(e =>
471
+ `"${e.givenName.replace(/"/g, '""')}","${e.familyName.replace(/"/g, '""')}","${e.fileAs.replace(/"/g, '""')}","${e.jsonFile}"`
472
+ ).join('\n');
473
+
474
+ if (mode === 'apply') {
475
+ fs.writeFileSync(csvPath, csvContent);
476
+ }
477
+
478
+ console.log(`\nEntries needing manual review:`);
479
+ for (const entry of needsReview) {
480
+ console.log(` ${entry.fileAs} (${entry.jsonFile})`);
481
+ }
482
+ if (mode === 'apply') {
483
+ console.log(`\nCSV for review: ${csvPath}`);
484
+ }
485
+ }
486
+
487
+ console.log(`\n${'='.repeat(50)}`);
488
+ console.log(`Summary:`);
489
+ console.log(` fileAs fixed: ${fileAsFixedCount}`);
490
+ console.log(` displayNameLastFirst fixed: ${displayNameLastFirstFixedCount}`);
491
+ console.log(` Needs review: ${skippedCount}`);
492
+ if (mode === 'inspect' && (fileAsFixedCount > 0 || displayNameLastFirstFixedCount > 0)) {
493
+ console.log(`\nTo apply fixes, run: gfix fileas -u ${user} --apply`);
494
+ }
495
+ if (mode === 'apply' && (fileAsFixedCount > 0 || displayNameLastFirstFixedCount > 0)) {
496
+ console.log(`\nAfter review, run: gcards push -u ${user}`);
497
+ }
498
+ }
499
+
500
+ /** Clean fileAs value - strip leading/trailing junk */
501
+ function cleanFileAs(value: string): string {
502
+ if (!value) return null;
503
+ return value.replace(/^[\s\-,]+|[\s\-,]+$/g, '').replace(/[\s\-,]+/g, ' ').trim() || null;
504
+ }
505
+
506
+ /** Surname particles that are part of the family name */
507
+ const SURNAME_PARTICLES = new Set([
508
+ 'de', 'da', 'di', 'del', 'della', 'dei', 'dos', 'das', 'do', // Romance
509
+ 'von', 'van', 'vander', 'vanden', 'ver', // Germanic
510
+ 'le', 'la', 'les', 'du', "d'", // French
511
+ 'el', 'al', 'bin', 'ibn', 'ben', 'bint', // Arabic/Hebrew
512
+ 'mac', 'mc', "o'", // Celtic
513
+ 'ter', 'ten', 'op', 'den', 'het', // Dutch
514
+ 'af', 'av', // Scandinavian
515
+ ]);
516
+
517
+ /** Honorific prefixes (Mr., Dr., etc.)
518
+ * IMPORTANT: Only include unambiguous honorifics that are unlikely to be first names.
519
+ * Excluded: don (common name Don), sir (could be name), etc.
520
+ */
521
+ const HONORIFIC_PREFIXES = new Set([
522
+ // English - require period to avoid false positives on names
523
+ 'mr.', 'mrs.', 'ms.', 'miss', 'mx.',
524
+ 'dr.', 'prof.', 'professor',
525
+ 'dame', 'lady', 'lord',
526
+ 'rev.', 'reverend', 'fr.', 'father', 'pastor', 'bishop',
527
+ 'rabbi', 'imam', 'elder',
528
+ 'judge', 'justice', 'hon.', 'honorable',
529
+ 'sen.', 'senator', 'rep.', 'representative',
530
+ 'gov.', 'governor', 'mayor', 'pres.', 'president',
531
+ 'gen.', 'general', 'col.', 'colonel',
532
+ 'capt.', 'captain', 'lt.', 'lieutenant',
533
+ 'sgt.', 'sergeant', 'cpl.', 'corporal',
534
+ 'adm.', 'admiral', 'cmdr.', 'commander',
535
+ // Other languages
536
+ 'herr', 'frau', 'fräulein', // German
537
+ 'monsieur', 'm.', 'madame', 'mme.', 'mademoiselle', 'mlle.', // French
538
+ 'señor', 'sr.', 'señora', 'sra.', 'señorita', 'srta.', // Spanish
539
+ 'signor', 'sig.', 'signora', 'signore', // Italian
540
+ 'doña', 'dona', // Spanish/Italian (but NOT 'don' - it's a common name)
541
+ ]);
542
+
543
+ /** Known name suffixes (after comma) */
544
+ const NAME_SUFFIXES = new Set([
545
+ // Generational
546
+ 'jr', 'jr.', 'sr', 'sr.', 'i', 'ii', 'iii', 'iv', 'v',
547
+ // Academic
548
+ 'phd', 'ph.d', 'ph.d.', 'md', 'm.d', 'm.d.', 'do', 'd.o', 'd.o.',
549
+ 'dds', 'd.d.s', 'd.d.s.', 'dmd', 'd.m.d', 'd.m.d.',
550
+ 'jd', 'j.d', 'j.d.', 'llb', 'll.b', 'll.b.', 'llm', 'll.m', 'll.m.',
551
+ 'mba', 'm.b.a', 'm.b.a.', 'ma', 'm.a', 'm.a.', 'ms', 'm.s', 'm.s.',
552
+ 'ba', 'b.a', 'b.a.', 'bs', 'b.s', 'b.s.', 'bsc', 'b.sc', 'b.sc.',
553
+ 'edd', 'ed.d', 'ed.d.',
554
+ // Professional certifications
555
+ 'cpa', 'c.p.a', 'c.p.a.', 'cfa', 'c.f.a', 'c.f.a.',
556
+ 'pe', 'p.e', 'p.e.', 'esq', 'esq.',
557
+ 'rn', 'r.n', 'r.n.', 'lpn', 'l.p.n', 'l.p.n.',
558
+ 'cae', 'c.a.e', 'c.a.e.', 'pmp', 'p.m.p', 'p.m.p.',
559
+ 'cissp', 'ccna', 'mcse', 'aws',
560
+ // Military/Religious
561
+ 'ret', 'ret.', 'usn', 'usa', 'usaf', 'usmc',
562
+ 'sj', 's.j', 's.j.', 'op', 'o.p', 'o.p.', 'osb', 'o.s.b', 'o.s.b.',
563
+ ]);
564
+
565
+ /** Convert ALL CAPS to Title Case, preserve mixed case */
566
+ function fixCase(name: string): string {
567
+ if (!name) return name;
568
+ // Only fix if entirely uppercase (allow for McName, O'Brien etc.)
569
+ if (name === name.toUpperCase() && name.length > 1) {
570
+ return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
571
+ }
572
+ return name;
573
+ }
574
+
575
+ /** Words that suggest a company name rather than a person */
576
+ const COMPANY_INDICATORS = new Set([
577
+ 'inc', 'inc.', 'incorporated', 'llc', 'l.l.c', 'l.l.c.',
578
+ 'corp', 'corp.', 'corporation', 'co', 'co.', 'company',
579
+ 'ltd', 'ltd.', 'limited', 'plc', 'p.l.c', 'p.l.c.',
580
+ 'gmbh', 'ag', 'sa', 'nv', 'bv', 'pty',
581
+ 'associates', 'association', 'assoc', 'assoc.',
582
+ 'foundation', 'institute', 'university', 'college', 'school',
583
+ 'hospital', 'clinic', 'medical', 'health', 'healthcare',
584
+ 'services', 'service', 'solutions', 'consulting', 'consultants',
585
+ 'group', 'partners', 'partnership', 'holdings', 'enterprises',
586
+ 'systems', 'technologies', 'technology', 'tech', 'software',
587
+ 'bank', 'insurance', 'financial', 'investments', 'capital',
588
+ 'international', 'global', 'national', 'regional',
589
+ 'support', 'helpdesk', 'customer',
590
+ 'department', 'dept', 'dept.', 'division', 'office', 'bureau',
591
+ 'council', 'committee', 'board', 'commission', 'agency',
592
+ 'church', 'ministry', 'ministries', 'temple', 'mosque', 'synagogue',
593
+ ]);
594
+
595
+ /** Check if a name looks like a company/organization rather than a person */
596
+ function looksLikeCompany(name: string): boolean {
597
+ const words = name.toLowerCase().split(/\s+/);
598
+
599
+ // Check for company indicator words
600
+ for (const word of words) {
601
+ if (COMPANY_INDICATORS.has(word.replace(/[.,]/g, ''))) {
602
+ return true;
603
+ }
604
+ }
605
+
606
+ // Note: We don't flag all-caps names as companies anymore.
607
+ // "MR DAVID MORRIS" is a valid person name that just needs case conversion.
608
+ // fixCase() will handle converting ALL CAPS to Title Case.
609
+
610
+ return false;
611
+ }
612
+
613
+ interface ParsedName {
614
+ prefix: string;
615
+ first: string;
616
+ middle: string;
617
+ last: string;
618
+ suffix: string;
619
+ ambiguous: boolean;
620
+ isCompany: boolean;
621
+ }
622
+
623
+ /** Normalize honorific prefix - just capitalize first letter, don't expand */
624
+ function normalizePrefix(prefix: string): string {
625
+ // Capitalize first letter, preserve rest as-is
626
+ const normalized = prefix.charAt(0).toUpperCase() + prefix.slice(1).toLowerCase();
627
+ // Ensure period at end for abbreviations (but not full words like "Miss")
628
+ const fullWords = new Set(['miss', 'dame', 'lady', 'lord', 'father', 'pastor', 'bishop',
629
+ 'rabbi', 'imam', 'elder', 'judge', 'justice', 'honorable', 'senator', 'representative',
630
+ 'governor', 'mayor', 'president', 'general', 'colonel', 'captain', 'lieutenant',
631
+ 'sergeant', 'corporal', 'admiral', 'commander', 'professor', 'reverend',
632
+ 'herr', 'frau', 'fräulein', 'monsieur', 'madame', 'mademoiselle',
633
+ 'señor', 'señora', 'señorita', 'signor', 'signora', 'signore', 'doña', 'dona']);
634
+ const lower = prefix.toLowerCase().replace(/\./g, '');
635
+ if (fullWords.has(lower)) {
636
+ return normalized.replace(/\.$/, ''); // No trailing period for full words
637
+ }
638
+ return normalized.endsWith('.') ? normalized : normalized + '.';
639
+ }
640
+
641
+ /** Parse a full name into prefix, first, middle, last, suffix components */
642
+ function parseFullName(fullName: string): ParsedName {
643
+ let prefix = '';
644
+ let suffix = '';
645
+ let namePart = fullName.trim();
646
+ let ambiguous = false;
647
+ const isCompany = looksLikeCompany(fullName);
648
+
649
+ // Check for comma - could be "Last, First" or "First Last, Suffix"
650
+ const commaIndex = namePart.indexOf(',');
651
+ if (commaIndex > 0) {
652
+ const beforeComma = namePart.substring(0, commaIndex).trim();
653
+ const afterComma = namePart.substring(commaIndex + 1).trim();
654
+
655
+ // Check if after-comma part is a known suffix
656
+ const afterLower = afterComma.toLowerCase().replace(/\./g, '');
657
+ if (NAME_SUFFIXES.has(afterLower) || NAME_SUFFIXES.has(afterLower.replace(/\s+/g, ''))) {
658
+ suffix = afterComma;
659
+ namePart = beforeComma;
660
+ } else {
661
+ // Could be "Last, First" format or unknown suffix
662
+ const beforeParts = beforeComma.split(/\s+/);
663
+ const afterParts = afterComma.split(/\s+/);
664
+
665
+ if (beforeParts.length === 1 && afterParts.length >= 1) {
666
+ // Ambiguous: could be "Smith, John" or "John Smith, UnknownSuffix"
667
+ ambiguous = true;
668
+ }
669
+ // For now, assume it's a suffix if short and all caps
670
+ if (afterComma.length <= 10 && afterComma === afterComma.toUpperCase()) {
671
+ suffix = afterComma;
672
+ namePart = beforeComma;
673
+ } else {
674
+ ambiguous = true;
675
+ }
676
+ }
677
+ }
678
+
679
+ let rawParts = namePart.split(/\s+/);
680
+
681
+ // Check for honorific prefix at the start BEFORE case normalization
682
+ // Recognize if: has a period (e.g., "Mr."), is a full word (e.g., "Miss"),
683
+ // or is ALL CAPS short abbreviation (e.g., "MR" -> "Mr.")
684
+ if (rawParts.length > 1) {
685
+ const firstWord = rawParts[0];
686
+ const firstLower = firstWord.toLowerCase();
687
+ // Check with period (e.g., "mr." matches "mr.")
688
+ // Or without period for full words (e.g., "miss")
689
+ // Or ALL CAPS short abbreviation (e.g., "MR" -> recognized as "mr.")
690
+ const withPeriod = firstLower.endsWith('.') ? firstLower : firstLower + '.';
691
+ if (HONORIFIC_PREFIXES.has(firstLower) ||
692
+ HONORIFIC_PREFIXES.has(firstLower.replace(/\.$/, '')) ||
693
+ (firstWord === firstWord.toUpperCase() && firstWord.length <= 4 && HONORIFIC_PREFIXES.has(withPeriod))) {
694
+ prefix = normalizePrefix(firstWord);
695
+ rawParts = rawParts.slice(1);
696
+ }
697
+ }
698
+
699
+ // Now apply case normalization
700
+ const parts = rawParts.map(fixCase);
701
+
702
+ if (parts.length === 0) {
703
+ return { prefix, first: '', middle: '', last: '', suffix, ambiguous, isCompany };
704
+ }
705
+ if (parts.length === 1) {
706
+ return { prefix, first: parts[0], middle: '', last: '', suffix, ambiguous, isCompany };
707
+ }
708
+ if (parts.length === 2) {
709
+ return { prefix, first: parts[0], middle: '', last: parts[1], suffix, ambiguous, isCompany };
710
+ }
711
+
712
+ // 3+ parts: check for surname particles
713
+ let familyStartIndex = parts.length - 1;
714
+
715
+ for (let i = parts.length - 2; i >= 1; i--) {
716
+ const lower = parts[i].toLowerCase().replace(/[.']/g, '');
717
+ if (SURNAME_PARTICLES.has(lower)) {
718
+ familyStartIndex = i;
719
+ } else {
720
+ break;
721
+ }
722
+ }
723
+
724
+ return {
725
+ prefix,
726
+ first: parts[0],
727
+ middle: parts.slice(1, familyStartIndex).join(' '),
728
+ last: parts.slice(familyStartIndex).join(' '),
729
+ suffix,
730
+ ambiguous,
731
+ isCompany
732
+ };
733
+ }
734
+
735
+ /** Fix names: parse givenName into first/middle/last, clean fileAs */
736
+ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<void> {
737
+ const paths = getUserPaths(user);
738
+
739
+ if (!fs.existsSync(paths.contactsDir)) {
740
+ console.error(`No contacts directory for user: ${user}`);
741
+ console.error(`Expected: ${paths.contactsDir}`);
742
+ process.exit(1);
743
+ }
744
+
745
+ let files = fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'));
746
+ console.log(`\n${mode === 'inspect' ? 'INSPECT' : 'APPLY'} name parsing for user: ${user}`);
747
+ console.log(`Found ${files.length} contact files\n`);
748
+
749
+ // Apply limit if set
750
+ if (processLimit > 0 && files.length > processLimit) {
751
+ files = files.slice(0, processLimit);
752
+ }
753
+
754
+ const changeLogs: NameChangeLog[] = [];
755
+ const problems: string[] = [];
756
+ let contactsModified = 0;
757
+
758
+ for (const file of files) {
759
+ const filePath = path.join(paths.contactsDir, file);
760
+ const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
761
+
762
+ const name = contact.names?.[0];
763
+ if (!name) continue;
764
+
765
+ const changes: string[] = [];
766
+ let modified = false;
767
+ let parsedFirst = '';
768
+ let parsedLast = '';
769
+
770
+ // Check displayName for corruption (repeated words like "Dr. Dr. Dr. Dr.")
771
+ const displayName = name.displayName || name.givenName || 'Unknown';
772
+ const displayWords = displayName.split(/\s+/);
773
+ if (displayWords.length > 4) {
774
+ const wordCounts = new Map<string, number>();
775
+ for (const w of displayWords) {
776
+ wordCounts.set(w.toLowerCase(), (wordCounts.get(w.toLowerCase()) || 0) + 1);
777
+ }
778
+ const maxCount = Math.max(...wordCounts.values());
779
+ if (maxCount > 3) {
780
+ problems.push(`${file}: repeated words in displayName (${maxCount}x) - corrupted data`);
781
+ console.log(` [CORRUPTED] ${displayName.slice(0, 60)}... (${file})`);
782
+ continue;
783
+ }
784
+ }
785
+
786
+ // If givenName exists but no familyName, parse it
787
+ if (name.givenName && !name.familyName) {
788
+ // Check for repeated words (corrupted data like "Smith Smith Smith...")
789
+ const words = name.givenName.split(/\s+/);
790
+ if (words.length > 4) {
791
+ const wordCounts = new Map<string, number>();
792
+ for (const w of words) {
793
+ wordCounts.set(w.toLowerCase(), (wordCounts.get(w.toLowerCase()) || 0) + 1);
794
+ }
795
+ const maxCount = Math.max(...wordCounts.values());
796
+ if (maxCount > 3) {
797
+ const displayName = name.displayName || name.givenName.slice(0, 50) + '...' || 'Unknown';
798
+ problems.push(`${file}: repeated words (${maxCount}x) - corrupted data`);
799
+ console.log(` [CORRUPTED] ${displayName} (${file})`);
800
+ continue;
801
+ }
802
+ }
803
+
804
+ // Check for email addresses
805
+ if (name.givenName.includes('@')) {
806
+ const displayName = name.displayName || name.givenName || 'Unknown';
807
+ problems.push(`${file}: "${name.givenName}" - email address, not a name`);
808
+ console.log(` [EMAIL] ${displayName} (${file}): "${name.givenName}"`);
809
+ continue;
810
+ }
811
+
812
+ const parsed = parseFullName(name.givenName);
813
+
814
+ // Check for company names first
815
+ if (parsed.isCompany) {
816
+ const displayName = name.displayName || name.givenName || 'Unknown';
817
+ problems.push(`${file}: "${name.givenName}" - looks like a company name`);
818
+ console.log(` [COMPANY] ${displayName} (${file}): "${name.givenName}"`);
819
+ continue;
820
+ }
821
+
822
+ // Check for ambiguous cases
823
+ if (parsed.ambiguous) {
824
+ const displayName = name.displayName || name.givenName || 'Unknown';
825
+ problems.push(`${file}: "${name.givenName}" - ambiguous (could be "Last, First" or unknown suffix)`);
826
+ console.log(` [AMBIGUOUS] ${displayName} (${file}): "${name.givenName}"`);
827
+ continue;
828
+ }
829
+
830
+ // Check if no parseable last name
831
+ if (!parsed.last) {
832
+ const displayName = name.displayName || name.givenName || 'Unknown';
833
+ problems.push(`${file}: "${name.givenName}" - no last name detected`);
834
+ console.log(` [NO LAST NAME] ${displayName} (${file}): "${name.givenName}"`);
835
+ continue;
836
+ }
837
+
838
+ parsedFirst = parsed.first;
839
+ parsedLast = parsed.last;
840
+
841
+ // We can split this name
842
+ const oldGiven = name.givenName;
843
+ changes.push(`givenName: "${oldGiven}" -> "${parsed.first}"`);
844
+ if (parsed.prefix) {
845
+ changes.push(`honorificPrefix: "${name.honorificPrefix || ''}" -> "${parsed.prefix}"`);
846
+ }
847
+ if (parsed.middle) {
848
+ changes.push(`middleName: "" -> "${parsed.middle}"`);
849
+ }
850
+ changes.push(`familyName: "" -> "${parsed.last}"`);
851
+ if (parsed.suffix) {
852
+ changes.push(`honorificSuffix: "" -> "${parsed.suffix}"`);
853
+ }
854
+
855
+ if (mode === 'apply') {
856
+ name.givenName = parsed.first;
857
+ if (parsed.prefix) {
858
+ name.honorificPrefix = parsed.prefix;
859
+ }
860
+ if (parsed.middle) {
861
+ name.middleName = parsed.middle;
862
+ }
863
+ name.familyName = parsed.last;
864
+ if (parsed.suffix) {
865
+ name.honorificSuffix = parsed.suffix;
866
+ }
867
+ // Update displayNameLastFirst
868
+ name.displayNameLastFirst = `${parsed.last}, ${parsed.first}${parsed.middle ? ' ' + parsed.middle : ''}`;
869
+ modified = true;
870
+ }
871
+ }
872
+
873
+ // Get the final familyName and givenName (may have been set above or already exist)
874
+ const finalGivenName = mode === 'apply' ? name.givenName : (parsedFirst || name.givenName);
875
+ const finalFamilyName = mode === 'apply' ? name.familyName : (parsedLast || name.familyName);
876
+
877
+ // Clean and fix fileAs entries - should be "Last, First" format
878
+ if (contact.fileAses?.length) {
879
+ for (let i = 0; i < contact.fileAses.length; i++) {
880
+ const fileAs = contact.fileAses[i];
881
+ const oldValue = fileAs.value || '';
882
+
883
+ // If we have givenName and familyName, set fileAs to proper "Last, First"
884
+ if (finalFamilyName && finalGivenName) {
885
+ const expectedFileAs = `${finalFamilyName}, ${finalGivenName}`;
886
+ if (oldValue !== expectedFileAs) {
887
+ changes.push(`fileAs[${i}]: "${oldValue}" -> "${expectedFileAs}"`);
888
+ if (mode === 'apply') {
889
+ fileAs.value = expectedFileAs;
890
+ modified = true;
891
+ }
892
+ }
893
+ } else {
894
+ // No proper names - just clean junk
895
+ const cleanedValue = cleanFileAs(oldValue);
896
+ if (cleanedValue !== oldValue) {
897
+ if (cleanedValue) {
898
+ changes.push(`fileAs[${i}]: "${oldValue}" -> "${cleanedValue}"`);
899
+ if (mode === 'apply') {
900
+ fileAs.value = cleanedValue;
901
+ modified = true;
902
+ }
903
+ } else {
904
+ changes.push(`fileAs[${i}]: "${oldValue}" -> [DELETE]`);
905
+ if (mode === 'apply') {
906
+ contact.fileAses.splice(i, 1);
907
+ i--;
908
+ modified = true;
909
+ }
910
+ }
911
+ }
912
+ }
913
+ }
914
+
915
+ // Remove empty fileAses array
916
+ if (mode === 'apply' && contact.fileAses.length === 0) {
917
+ delete contact.fileAses;
918
+ }
919
+ }
920
+
921
+ // Check for duplicate phone numbers
922
+ const duplicatePhones = findDuplicatePhones(contact.phoneNumbers);
923
+ if (duplicatePhones.length > 0) {
924
+ changes.push(`phoneNumbers: remove duplicates: ${duplicatePhones.join(', ')}`);
925
+ if (mode === 'apply') {
926
+ const seen = new Set<string>();
927
+ contact.phoneNumbers = contact.phoneNumbers.filter(phone => {
928
+ const normalized = normalizePhone(phone.value || phone.canonicalForm);
929
+ if (seen.has(normalized)) return false;
930
+ seen.add(normalized);
931
+ return true;
932
+ });
933
+ modified = true;
934
+ }
935
+ }
936
+
937
+ // Check for duplicate email addresses
938
+ const duplicateEmails = findDuplicateEmails(contact.emailAddresses);
939
+ if (duplicateEmails.length > 0) {
940
+ changes.push(`emailAddresses: remove duplicates: ${duplicateEmails.join(', ')}`);
941
+ if (mode === 'apply') {
942
+ const seen = new Set<string>();
943
+ contact.emailAddresses = contact.emailAddresses.filter(email => {
944
+ const normalized = normalizeEmail(email.value);
945
+ if (seen.has(normalized)) return false;
946
+ seen.add(normalized);
947
+ return true;
948
+ });
949
+ modified = true;
950
+ }
951
+ }
952
+
953
+ if (changes.length > 0) {
954
+ const displayName = name.displayName || name.givenName || 'Unknown';
955
+ changeLogs.push({ jsonFile: file, displayName, changes });
956
+
957
+ console.log(`\n${displayName} (${file})`);
958
+ for (const change of changes) {
959
+ console.log(` ${change}`);
960
+ }
961
+
962
+ if (modified) {
963
+ fs.writeFileSync(filePath, JSON.stringify(contact, null, 2));
964
+ contactsModified++;
965
+ }
966
+ }
967
+ }
968
+
969
+ // Write changes.log
970
+ if (changeLogs.length > 0 && mode === 'apply') {
971
+ const logPath = path.join(paths.userDir, 'changes.log');
972
+ const logContent = changeLogs.map(log =>
973
+ `=== ${log.jsonFile} (${log.displayName}) ===\n${log.changes.map(c => ' ' + c).join('\n')}`
974
+ ).join('\n\n');
975
+ fs.writeFileSync(logPath, logContent);
976
+ console.log(`\nLog file: ${logPath}`);
977
+ }
978
+
979
+ // Write problems.txt for ambiguous cases
980
+ if (problems.length > 0) {
981
+ const problemsPath = path.join(paths.userDir, 'problems.txt');
982
+ fs.writeFileSync(problemsPath, problems.join('\n'));
983
+ console.log(`\nProblems file: ${problemsPath}`);
984
+ }
985
+
986
+ console.log(`\n${'='.repeat(50)}`);
987
+ console.log(`Summary:`);
988
+ console.log(` Contacts with changes: ${changeLogs.length}`);
989
+ console.log(` Ambiguous (see problems.txt): ${problems.length}`);
990
+ if (mode === 'apply') {
991
+ console.log(` Contacts modified: ${contactsModified}`);
992
+ }
993
+ if (mode === 'inspect' && changeLogs.length > 0) {
994
+ console.log(`\nTo apply fixes, run: gfix names -u ${user} --apply`);
995
+ }
996
+ if (mode === 'apply' && contactsModified > 0) {
997
+ console.log(`\nAfter review, run: gcards push -u ${user}`);
998
+ }
999
+ }
1000
+
1001
+ async function runFix(user: string, mode: 'inspect' | 'apply'): Promise<void> {
1002
+ const paths = getUserPaths(user);
1003
+
1004
+ if (!fs.existsSync(paths.contactsDir)) {
1005
+ console.error(`No contacts directory for user: ${user}`);
1006
+ console.error(`Expected: ${paths.contactsDir}`);
1007
+ process.exit(1);
1008
+ }
1009
+
1010
+ const files = fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'));
1011
+
1012
+ console.log(`\n${mode === 'inspect' ? 'INSPECT' : 'APPLY'} mode for user: ${user}`);
1013
+ console.log(`Found ${files.length} contact files\n`);
1014
+
1015
+ const allChanges: ContactChange[] = [];
1016
+
1017
+ for (const file of files) {
1018
+ const filePath = path.join(paths.contactsDir, file);
1019
+ const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1020
+ const changes = processContact(contact);
1021
+
1022
+ if (changes.length > 0) {
1023
+ const displayName = contact.names?.[0]?.displayName || 'Unknown';
1024
+ allChanges.push({
1025
+ resourceName: contact.resourceName,
1026
+ displayName,
1027
+ filePath,
1028
+ changes
1029
+ });
1030
+
1031
+ console.log(`\n${displayName} (${contact.resourceName})`);
1032
+ for (const change of changes) {
1033
+ if (change.field === 'phoneNumbers') {
1034
+ const phoneChange = change as PhoneRemoval;
1035
+ console.log(` phoneNumbers: remove duplicates: ${phoneChange.removed.join(', ')}`);
1036
+ } else if (change.field === 'emailAddresses') {
1037
+ const emailChange = change as EmailRemoval;
1038
+ console.log(` emailAddresses: remove duplicates: ${emailChange.removed.join(', ')}`);
1039
+ } else {
1040
+ const fieldChange = change as FieldChange;
1041
+ if (!fieldChange.newValue) {
1042
+ console.log(` ${fieldChange.field}: "${fieldChange.oldValue}" -> [DELETE]`);
1043
+ } else {
1044
+ console.log(` ${fieldChange.field}: "${fieldChange.oldValue}" -> "${fieldChange.newValue}"`);
1045
+ }
1046
+ }
1047
+ }
1048
+
1049
+ if (mode === 'apply') {
1050
+ applyChangesToContact(contact, changes);
1051
+ fs.writeFileSync(filePath, JSON.stringify(contact, null, 2));
1052
+ console.log(` ✅ Applied`);
1053
+ }
1054
+ }
1055
+ }
1056
+
1057
+ // Create log file
1058
+ const log: FixLog = {
1059
+ timestamp: new Date().toISOString(),
1060
+ user,
1061
+ mode,
1062
+ totalContacts: files.length,
1063
+ contactsChanged: allChanges.length,
1064
+ changes: allChanges
1065
+ };
1066
+
1067
+ if (!fs.existsSync(paths.fixLogDir)) {
1068
+ fs.mkdirSync(paths.fixLogDir, { recursive: true });
1069
+ }
1070
+
1071
+ const logFileName = `fix-names-${mode}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
1072
+ const logPath = path.join(paths.fixLogDir, logFileName);
1073
+ fs.writeFileSync(logPath, JSON.stringify(log, null, 2));
1074
+
1075
+ console.log(`\n${'='.repeat(50)}`);
1076
+ console.log(`Summary:`);
1077
+ console.log(` Total contacts: ${files.length}`);
1078
+ console.log(` Contacts with changes: ${allChanges.length}`);
1079
+ console.log(` Total field changes: ${allChanges.reduce((sum, c) => sum + c.changes.length, 0)}`);
1080
+ console.log(` Log file: ${logPath}`);
1081
+
1082
+ if (mode === 'inspect' && allChanges.length > 0) {
1083
+ console.log(`\nTo apply changes, run: gfix apply -u ${user}`);
1084
+ console.log(`After applying, run: gcards push -u ${user}`);
1085
+ }
1086
+ }
1087
+
1088
+
1089
+ /** Find duplicate contacts by name + email overlap */
1090
+ async function runUndup(user: string): Promise<void> {
1091
+ const paths = getUserPaths(user);
1092
+
1093
+ if (!fs.existsSync(paths.contactsDir)) {
1094
+ console.error(`No contacts directory for user: ${user}`);
1095
+ process.exit(1);
1096
+ }
1097
+
1098
+ const files = fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'));
1099
+ console.log(`\nScanning ${files.length} contacts for duplicates...\n`);
1100
+
1101
+ // Build map of name -> contacts with that name
1102
+ const byName = new Map<string, { resourceName: string; emails: string[]; displayName: string }[]>();
1103
+
1104
+ for (const file of files) {
1105
+ const filePath = path.join(paths.contactsDir, file);
1106
+ const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1107
+
1108
+ const name = contact.names?.[0];
1109
+ if (!name) continue;
1110
+
1111
+ // Normalize name for matching: lowercase, remove punctuation
1112
+ const displayName = name.displayName || `${name.givenName || ''} ${name.familyName || ''}`.trim();
1113
+ const normalizedName = displayName.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim();
1114
+
1115
+ if (!normalizedName) continue;
1116
+
1117
+ // Get all emails
1118
+ const emails = (contact.emailAddresses || [])
1119
+ .map(e => e.value?.toLowerCase().trim())
1120
+ .filter((e): e is string => !!e);
1121
+
1122
+ if (!byName.has(normalizedName)) {
1123
+ byName.set(normalizedName, []);
1124
+ }
1125
+ byName.get(normalizedName)!.push({
1126
+ resourceName: contact.resourceName,
1127
+ emails,
1128
+ displayName
1129
+ });
1130
+ }
1131
+
1132
+ // Find merge candidates: same name AND overlapping email
1133
+ const mergeEntries: MergeEntry[] = [];
1134
+
1135
+ for (const [normalizedName, contacts] of byName) {
1136
+ if (contacts.length < 2) continue;
1137
+
1138
+ // Group contacts that share at least one email (union-find style)
1139
+ const clusters: typeof contacts[] = [];
1140
+
1141
+ for (const contact of contacts) {
1142
+ // Find clusters that share an email with this contact
1143
+ const matchingClusters: number[] = [];
1144
+ for (let i = 0; i < clusters.length; i++) {
1145
+ const cluster = clusters[i];
1146
+ const clusterEmails = new Set(cluster.flatMap(c => c.emails));
1147
+ if (contact.emails.some(e => clusterEmails.has(e))) {
1148
+ matchingClusters.push(i);
1149
+ }
1150
+ }
1151
+
1152
+ if (matchingClusters.length === 0) {
1153
+ // No overlap - start new cluster
1154
+ clusters.push([contact]);
1155
+ } else if (matchingClusters.length === 1) {
1156
+ // Add to existing cluster
1157
+ clusters[matchingClusters[0]].push(contact);
1158
+ } else {
1159
+ // Merge multiple clusters + add contact
1160
+ const merged = [contact];
1161
+ for (const idx of matchingClusters.sort((a, b) => b - a)) {
1162
+ merged.push(...clusters[idx]);
1163
+ clusters.splice(idx, 1);
1164
+ }
1165
+ clusters.push(merged);
1166
+ }
1167
+ }
1168
+
1169
+ // Only keep clusters with 2+ contacts (duplicates)
1170
+ for (const cluster of clusters) {
1171
+ if (cluster.length >= 2) {
1172
+ const allEmails = [...new Set(cluster.flatMap(c => c.emails))].sort();
1173
+ mergeEntries.push({
1174
+ name: cluster[0].displayName,
1175
+ emails: allEmails,
1176
+ resourceNames: cluster.map(c => c.resourceName)
1177
+ });
1178
+ }
1179
+ }
1180
+ }
1181
+
1182
+ // Sort by name, then first email
1183
+ mergeEntries.sort((a, b) => {
1184
+ const nameComp = a.name.localeCompare(b.name);
1185
+ if (nameComp !== 0) return nameComp;
1186
+ return (a.emails[0] || '').localeCompare(b.emails[0] || '');
1187
+ });
1188
+
1189
+ // Write merger.json
1190
+ const mergerPath = path.join(paths.userDir, 'merger.json');
1191
+ fs.writeFileSync(mergerPath, JSON.stringify(mergeEntries, null, 2));
1192
+
1193
+ console.log(`Found ${mergeEntries.length} merge groups:`);
1194
+ for (const entry of mergeEntries) {
1195
+ console.log(` ${entry.name} (${entry.resourceNames.length} contacts, ${entry.emails.length} emails)`);
1196
+ }
1197
+
1198
+ console.log(`\n${'='.repeat(50)}`);
1199
+ console.log(`Merger file: ${mergerPath}`);
1200
+ console.log(`\nEdit merger.json to remove entries you don't want to merge.`);
1201
+ console.log(`Then run: gfix merge -u ${user}`);
1202
+ }
1203
+
1204
+ /** Merge duplicate contacts locally (prepares for gcards push) */
1205
+ async function runMerge(user: string, limit: number): Promise<void> {
1206
+ const paths = getUserPaths(user);
1207
+ const mergerPath = path.join(paths.userDir, 'merger.json');
1208
+ const mergedPath = path.join(paths.userDir, 'merged.json');
1209
+ const photosPath = path.join(paths.userDir, 'photos.json');
1210
+
1211
+ if (!fs.existsSync(mergerPath)) {
1212
+ console.error(`No merger.json found. Run 'gfix undup -u ${user}' first.`);
1213
+ process.exit(1);
1214
+ }
1215
+
1216
+ let entries: MergeEntry[] = JSON.parse(fs.readFileSync(mergerPath, 'utf-8'));
1217
+
1218
+ if (entries.length === 0) {
1219
+ console.log('No entries in merger.json');
1220
+ return;
1221
+ }
1222
+
1223
+ // Apply limit if specified
1224
+ if (limit > 0 && entries.length > limit) {
1225
+ console.log(`[DEBUG] Limiting to first ${limit} of ${entries.length} entries\n`);
1226
+ entries = entries.slice(0, limit);
1227
+ }
1228
+
1229
+ const toMerge = entries.filter(e => !e._delete);
1230
+ const toDelete = entries.filter(e => e._delete);
1231
+
1232
+ console.log(`\nProcessing ${entries.length} groups for user: ${user}\n`);
1233
+ if (toMerge.length > 0) {
1234
+ console.log(`Merge (${toMerge.length}):`);
1235
+ for (const entry of toMerge) {
1236
+ console.log(` ${entry.name}: ${entry.resourceNames.length} contacts -> 1`);
1237
+ }
1238
+ }
1239
+ if (toDelete.length > 0) {
1240
+ console.log(`Delete (${toDelete.length}):`);
1241
+ for (const entry of toDelete) {
1242
+ console.log(` ${entry.name}: ${entry.resourceNames.length} contacts`);
1243
+ }
1244
+ }
1245
+
1246
+ // Load existing photos.json and merged.json
1247
+ let savedPhotos: PhotoEntry[] = [];
1248
+ if (fs.existsSync(photosPath)) {
1249
+ savedPhotos = JSON.parse(fs.readFileSync(photosPath, 'utf-8'));
1250
+ }
1251
+
1252
+ let mergedEntries: MergeEntry[] = [];
1253
+ if (fs.existsSync(mergedPath)) {
1254
+ mergedEntries = JSON.parse(fs.readFileSync(mergedPath, 'utf-8'));
1255
+ }
1256
+
1257
+ let mergeSuccess = 0;
1258
+ let deleteSuccess = 0;
1259
+
1260
+ // Process merges - update local files only
1261
+ for (const entry of toMerge) {
1262
+ console.log(`\nMerging ${entry.name}...`);
1263
+
1264
+ try {
1265
+ // Load all contacts from local files
1266
+ const contacts: GooglePerson[] = [];
1267
+ for (const rn of entry.resourceNames) {
1268
+ const id = rn.replace('people/', '');
1269
+ const filePath = path.join(paths.contactsDir, `${id}.json`);
1270
+ if (fs.existsSync(filePath)) {
1271
+ contacts.push(JSON.parse(fs.readFileSync(filePath, 'utf-8')));
1272
+ } else {
1273
+ throw new Error(`Contact file not found: ${filePath}`);
1274
+ }
1275
+ }
1276
+
1277
+ if (contacts.length < 2) {
1278
+ console.log(' Skipped (need 2+ contacts)');
1279
+ continue;
1280
+ }
1281
+
1282
+ // Target is first, sources are rest
1283
+ const [target, ...sources] = contacts;
1284
+
1285
+ // Collect photos from sources before marking for deletion
1286
+ const sourcePhotoUrls = collectSourcePhotos(sources);
1287
+ if (sourcePhotoUrls.length > 0) {
1288
+ const displayName = target.names?.[0]?.displayName || entry.name;
1289
+ savedPhotos.push({
1290
+ contactId: target.resourceName,
1291
+ name: displayName,
1292
+ photos: sourcePhotoUrls
1293
+ });
1294
+ console.log(` Saved ${sourcePhotoUrls.length} photo(s) to photos.json`);
1295
+ }
1296
+
1297
+ // Merge data into target
1298
+ const mergedData = mergeContactData(target, sources);
1299
+ const updatedTarget = { ...target, ...mergedData };
1300
+
1301
+ // Save updated target
1302
+ const targetId = target.resourceName.replace('people/', '');
1303
+ const targetPath = path.join(paths.contactsDir, `${targetId}.json`);
1304
+ fs.writeFileSync(targetPath, JSON.stringify(updatedTarget, null, 2));
1305
+ console.log(` Updated ${targetPath}`);
1306
+
1307
+ // Mark sources for deletion
1308
+ for (const source of sources) {
1309
+ const sourceId = source.resourceName.replace('people/', '');
1310
+ const sourcePath = path.join(paths.contactsDir, `${sourceId}.json`);
1311
+ const markedSource = { _delete: true, ...source };
1312
+ fs.writeFileSync(sourcePath, JSON.stringify(markedSource, null, 2));
1313
+ console.log(` Marked ${sourceId} for deletion`);
1314
+ }
1315
+
1316
+ mergeSuccess++;
1317
+ mergedEntries.push(entry);
1318
+
1319
+ } catch (error: any) {
1320
+ console.log(` ERROR: ${error.message}`);
1321
+ }
1322
+ }
1323
+
1324
+ // Process deletes - just mark files
1325
+ for (const entry of toDelete) {
1326
+ console.log(`\nMarking for deletion: ${entry.name}...`);
1327
+
1328
+ for (const rn of entry.resourceNames) {
1329
+ const id = rn.replace('people/', '');
1330
+ const filePath = path.join(paths.contactsDir, `${id}.json`);
1331
+ if (fs.existsSync(filePath)) {
1332
+ const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
1333
+ const markedContact = { _delete: true, ...contact };
1334
+ fs.writeFileSync(filePath, JSON.stringify(markedContact, null, 2));
1335
+ console.log(` Marked ${id} for deletion`);
1336
+ deleteSuccess++;
1337
+ }
1338
+ }
1339
+ mergedEntries.push(entry);
1340
+ }
1341
+
1342
+ // Save photos.json
1343
+ if (savedPhotos.length > 0) {
1344
+ fs.writeFileSync(photosPath, JSON.stringify(savedPhotos, null, 2));
1345
+ }
1346
+
1347
+ // Save merged.json (history of processed entries)
1348
+ fs.writeFileSync(mergedPath, JSON.stringify(mergedEntries, null, 2));
1349
+
1350
+ // Remove processed entries from merger.json
1351
+ const remainingEntries = JSON.parse(fs.readFileSync(mergerPath, 'utf-8')) as MergeEntry[];
1352
+ const processedNames = new Set(entries.map(e => e.name));
1353
+ const newMerger = remainingEntries.filter(e => !processedNames.has(e.name));
1354
+ fs.writeFileSync(mergerPath, JSON.stringify(newMerger, null, 2));
1355
+
1356
+ console.log(`\n${'='.repeat(50)}`);
1357
+ console.log(`Summary:`);
1358
+ console.log(` Merged: ${mergeSuccess}`);
1359
+ console.log(` Marked for deletion: ${deleteSuccess}`);
1360
+ console.log(` Remaining in merger.json: ${newMerger.length}`);
1361
+ console.log(`\nRun 'gcards push -u ${user}' to apply changes to Google.`);
1362
+ }
1363
+
1364
+ function showUsage(): void {
1365
+ console.log(`
1366
+ gfix - One-time fix routines for gcards contacts
1367
+
1368
+ Usage: gfix <command> -u <user> [options]
1369
+
1370
+ Commands:
1371
+ inspect Show what would be changed (no modifications)
1372
+ apply Apply changes and create log file
1373
+ birthday Extract birthdays to CSV and remove from contacts
1374
+ fileas Normalize "Last, First" fileAs to "First Last"
1375
+ names Parse givenName into first/middle/last, clean fileAs, remove dup phones/emails
1376
+ undup Find duplicate contacts (same name + overlapping email) -> merger.json
1377
+ merge Merge duplicates locally (then use gcards push)
1378
+
1379
+ Options:
1380
+ -u, --user <name> User profile to process
1381
+ --apply For birthday/fileas/names: actually apply changes
1382
+ -limit <n> Process only first n contacts (for testing)
1383
+
1384
+ Fixes applied (inspect/apply):
1385
+ - Remove leading/trailing dashes, quotes, spaces from names
1386
+ - Extract repeated Dr./Prof. prefixes to honorificPrefix field
1387
+ - Remove duplicate phone numbers
1388
+ - Remove duplicate email addresses
1389
+ - Example: "Dr. Dr. Sandy Rotter - -" → "Sandy Rotter" + honorificPrefix: "Dr."
1390
+
1391
+ Birthday extraction:
1392
+ - gfix birthday -u bob # Preview birthdays found
1393
+ - gfix birthday -u bob --apply # Extract to birthdays.csv and remove
1394
+
1395
+ FileAs normalization:
1396
+ - gfix fileas -u bob # Preview changes
1397
+ - gfix fileas -u bob --apply # Fix and create namefix.csv for manual review
1398
+
1399
+ Name parsing (when givenName has full name but no familyName):
1400
+ - gfix names -u bob # Preview name splits
1401
+ - gfix names -u bob --apply # Split names and create changes.log
1402
+
1403
+ Duplicate detection and merge:
1404
+ - gfix undup -u bob # Find duplicates -> merger.json
1405
+ - Edit merger.json (remove false positives, add "_delete": true for spam)
1406
+ - gfix merge -u bob # Merge locally (updates files, marks for deletion)
1407
+ - gcards push -u bob # Push changes to Google
1408
+ - gcards sync -u bob --full # Resync to get updated contacts
1409
+
1410
+ Workflow:
1411
+ 1. gfix inspect -u bob # Review proposed changes
1412
+ 2. gfix apply -u bob # Apply changes to local files
1413
+ 3. gcards push -u bob # Push to Google
1414
+ `);
1415
+ }
1416
+
1417
+ // Global limit for testing
1418
+ let processLimit = 0;
1419
+
1420
+ async function main(): Promise<void> {
1421
+ const args = process.argv.slice(2);
1422
+
1423
+ let command = '';
1424
+ let user = '';
1425
+ let applyFlag = false;
1426
+
1427
+ for (let i = 0; i < args.length; i++) {
1428
+ const arg = args[i];
1429
+ if ((arg === '-u' || arg === '--user' || arg === '-user') && i + 1 < args.length) {
1430
+ user = args[++i];
1431
+ } else if (arg === '--apply') {
1432
+ applyFlag = true;
1433
+ } else if ((arg === '-limit' || arg === '--limit') && i + 1 < args.length) {
1434
+ processLimit = parseInt(args[++i], 10) || 0;
1435
+ } else if (!arg.startsWith('-') && !command) {
1436
+ command = arg;
1437
+ }
1438
+ }
1439
+
1440
+ if (!command) {
1441
+ showUsage();
1442
+ return;
1443
+ }
1444
+
1445
+ const resolvedUser = resolveUser(user);
1446
+
1447
+ if (processLimit > 0) {
1448
+ console.log(`[DEBUG] Processing limited to first ${processLimit} contacts\n`);
1449
+ }
1450
+
1451
+ if (command === 'birthday') {
1452
+ await runBirthdayExtract(resolvedUser, applyFlag ? 'apply' : 'inspect');
1453
+ } else if (command === 'fileas') {
1454
+ await runFileAsFix(resolvedUser, applyFlag ? 'apply' : 'inspect');
1455
+ } else if (command === 'names') {
1456
+ await runNamesFix(resolvedUser, applyFlag ? 'apply' : 'inspect');
1457
+ } else if (command === 'inspect' || command === 'apply') {
1458
+ await runFix(resolvedUser, command);
1459
+ } else if (command === 'undup') {
1460
+ await runUndup(resolvedUser);
1461
+ } else if (command === 'merge') {
1462
+ await runMerge(resolvedUser, processLimit);
1463
+ } else {
1464
+ console.error(`Unknown command: ${command}`);
1465
+ showUsage();
1466
+ process.exit(1);
1467
+ }
1468
+ }
1469
+
1470
+ if (import.meta.main) {
1471
+ await main();
1472
+ }