@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/.claude/settings.local.json +14 -1
- package/.gitattributes +1 -0
- package/.vscode/settings.json +22 -2
- package/README.md +215 -61
- package/gcards.ts +498 -185
- package/gfix.ts +1472 -0
- package/glib/gctypes.ts +110 -0
- package/glib/gmerge.ts +290 -0
- package/glib/gutils.ts +141 -0
- package/{cli.ts → glib/parsecli.ts} +41 -5
- package/{types.ts → glib/types.ts} +305 -305
- package/package.json +6 -4
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
|
+
}
|