@bobfrankston/gcards 0.1.16 → 0.1.17
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/README.md +20 -1
- package/gcards.ts +35 -2
- package/gfix.ts +262 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,7 +40,8 @@ gfix inspect -u user # Preview all standard fixes (no changes)
|
|
|
40
40
|
gfix apply -u user # Apply all standard fixes
|
|
41
41
|
|
|
42
42
|
gfix names -u user # Preview name parsing + dup phone/email removal
|
|
43
|
-
gfix names -u user --apply # Parse names, remove dup phones/emails
|
|
43
|
+
gfix names -u user --apply # Parse names, remove dup phones/emails, move email/phone-only to _delete/
|
|
44
|
+
gfix problems -u user # Process edited problems.txt (x=delete, or replace name)
|
|
44
45
|
|
|
45
46
|
gfix birthday -u user # Preview birthday extraction
|
|
46
47
|
gfix birthday -u user --apply # Extract to CSV and remove
|
|
@@ -147,10 +148,28 @@ gcards/ # App directory (%APPDATA%\gcards or ~/.config/gcards)
|
|
|
147
148
|
|
|
148
149
|
When a contact has a full name in `givenName` but no `familyName`:
|
|
149
150
|
- Parses "First Middle Last" into separate fields
|
|
151
|
+
- Handles suffixes like Ph.D., M.D., P.E., Jr., III
|
|
150
152
|
- Updates `displayNameLastFirst` to proper "Last, First" format
|
|
151
153
|
- Cleans up `fileAses` entries (strips junk characters)
|
|
154
|
+
- Fixes repeated words (e.g., "John Smith Smith Smith" → "John Smith")
|
|
152
155
|
- Creates `changes.log` with all modifications
|
|
153
156
|
|
|
157
|
+
Contacts with no real name are moved to `_delete/`:
|
|
158
|
+
- Email-only (no name field)
|
|
159
|
+
- Phone-only (phone number as name)
|
|
160
|
+
- Empty contacts (no name, email, or phone)
|
|
161
|
+
|
|
162
|
+
Recovery lists saved to `emailonly.json` and `phoneonly.json`.
|
|
163
|
+
|
|
164
|
+
## Handling Problem Contacts (`gfix problems`)
|
|
165
|
+
|
|
166
|
+
After `gfix names`, ambiguous contacts are saved to `problems.txt`. Edit this file to fix:
|
|
167
|
+
- Add `x` at start of line to mark for deletion
|
|
168
|
+
- Replace the name with corrected "First Last" format
|
|
169
|
+
- Leave unchanged to skip
|
|
170
|
+
|
|
171
|
+
Then run `gfix problems -u user` to apply.
|
|
172
|
+
|
|
154
173
|
## Workflow
|
|
155
174
|
|
|
156
175
|
1. **Initial sync**: `gcards sync -u yourname`
|
package/gcards.ts
CHANGED
|
@@ -1100,11 +1100,44 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
1100
1100
|
console.log(`\nErrors written to: ${problemsFile}`);
|
|
1101
1101
|
}
|
|
1102
1102
|
|
|
1103
|
-
// Write notdeleted file if any contacts were skipped due to photos
|
|
1103
|
+
// Write notdeleted file if any contacts were skipped due to photos/starred
|
|
1104
|
+
// Also append to problems.txt so user can decide their fate
|
|
1104
1105
|
if (notDeleted.length > 0) {
|
|
1105
1106
|
const notDeletedFile = path.join(paths.userDir, 'notdeleted.json');
|
|
1106
1107
|
fs.writeFileSync(notDeletedFile, JSON.stringify(notDeleted, null, 2));
|
|
1107
|
-
console.log(`\n${notDeleted.length} contacts with photos not deleted from Google (see notdeleted.json)`);
|
|
1108
|
+
console.log(`\n${notDeleted.length} contacts with photos/starred not deleted from Google (see notdeleted.json)`);
|
|
1109
|
+
|
|
1110
|
+
// Append to problems.txt for user review
|
|
1111
|
+
const problemsPath = path.join(paths.userDir, 'problems.txt');
|
|
1112
|
+
let existingProblems = '';
|
|
1113
|
+
if (fs.existsSync(problemsPath)) {
|
|
1114
|
+
existingProblems = fs.readFileSync(problemsPath, 'utf-8');
|
|
1115
|
+
} else {
|
|
1116
|
+
existingProblems = `# Edit this file then run: gfix problems -u ${user}
|
|
1117
|
+
# Format: filename<TAB>name<TAB>reason
|
|
1118
|
+
# To delete: add 'x' at start of line
|
|
1119
|
+
# To mark as company: add 'c' at start of line (moves name to organization)
|
|
1120
|
+
# To fix name: replace the name with "First Last" or "First [Middle] Last"
|
|
1121
|
+
# To skip: leave unchanged
|
|
1122
|
+
#
|
|
1123
|
+
`;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Add photo/starred entries
|
|
1127
|
+
const newEntries = notDeleted.map(nd => {
|
|
1128
|
+
const id = nd.resourceName.replace('people/', '');
|
|
1129
|
+
const reason = nd.reason === '*photo' ? 'has-photo' : 'starred';
|
|
1130
|
+
return `${id}.json\t${nd.displayName}\t${reason}`;
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
// Avoid duplicates - check if entry already exists
|
|
1134
|
+
const existingLines = new Set(existingProblems.split('\n').map(l => l.split('\t')[0]));
|
|
1135
|
+
const uniqueEntries = newEntries.filter(e => !existingLines.has(e.split('\t')[0]));
|
|
1136
|
+
|
|
1137
|
+
if (uniqueEntries.length > 0) {
|
|
1138
|
+
fs.writeFileSync(problemsPath, existingProblems.trimEnd() + '\n' + uniqueEntries.join('\n') + '\n');
|
|
1139
|
+
console.log(`Added ${uniqueEntries.length} photo/starred entries to problems.txt for review`);
|
|
1140
|
+
}
|
|
1108
1141
|
}
|
|
1109
1142
|
|
|
1110
1143
|
// Remove completed deletions from queue
|
package/gfix.ts
CHANGED
|
@@ -559,16 +559,36 @@ const HONORIFIC_PREFIXES = new Set([
|
|
|
559
559
|
const NAME_SUFFIXES = new Set([
|
|
560
560
|
// Generational
|
|
561
561
|
'jr', 'jr.', 'sr', 'sr.', 'i', 'ii', 'iii', 'iv', 'v',
|
|
562
|
-
// Academic
|
|
562
|
+
// Academic - Doctorates
|
|
563
563
|
'phd', 'ph.d', 'ph.d.', 'md', 'm.d', 'm.d.', 'do', 'd.o', 'd.o.',
|
|
564
564
|
'dds', 'd.d.s', 'd.d.s.', 'dmd', 'd.m.d', 'd.m.d.',
|
|
565
565
|
'jd', 'j.d', 'j.d.', 'llb', 'll.b', 'll.b.', 'llm', 'll.m', 'll.m.',
|
|
566
|
+
'edd', 'ed.d', 'ed.d.', 'dmin', 'd.min', 'd.min.',
|
|
567
|
+
'dsc', 'd.sc', 'd.sc.', 'dphil', 'd.phil', 'd.phil.',
|
|
568
|
+
'psyd', 'psy.d', 'psy.d.', 'dnp', 'd.n.p', 'd.n.p.',
|
|
569
|
+
'pharmd', 'pharm.d', 'pharm.d.', 'dpt', 'd.p.t', 'd.p.t.',
|
|
570
|
+
'dvm', 'd.v.m', 'd.v.m.',
|
|
571
|
+
// Academic - Masters
|
|
566
572
|
'mba', 'm.b.a', 'm.b.a.', 'ma', 'm.a', 'm.a.', 'ms', 'm.s', 'm.s.',
|
|
573
|
+
'msc', 'm.sc', 'm.sc.', 'med', 'm.ed', 'm.ed.',
|
|
574
|
+
'mfa', 'm.f.a', 'm.f.a.', 'mph', 'm.p.h', 'm.p.h.',
|
|
575
|
+
'msw', 'm.s.w', 'm.s.w.', 'meng', 'm.eng', 'm.eng.',
|
|
576
|
+
// Academic - Bachelors
|
|
567
577
|
'ba', 'b.a', 'b.a.', 'bs', 'b.s', 'b.s.', 'bsc', 'b.sc', 'b.sc.',
|
|
568
|
-
'
|
|
578
|
+
'beng', 'b.eng', 'b.eng.', 'bfa', 'b.f.a', 'b.f.a.',
|
|
579
|
+
// UK/Commonwealth specific
|
|
580
|
+
'dic', 'd.i.c', 'd.i.c.', // Diploma of Imperial College
|
|
581
|
+
'frcs', 'f.r.c.s', 'f.r.c.s.', // Fellow Royal College of Surgeons
|
|
582
|
+
'frcp', 'f.r.c.p', 'f.r.c.p.', // Fellow Royal College of Physicians
|
|
583
|
+
'frs', 'f.r.s', 'f.r.s.', // Fellow Royal Society
|
|
584
|
+
'obe', 'o.b.e', 'o.b.e.', // Order of British Empire
|
|
585
|
+
'mbe', 'm.b.e', 'm.b.e.',
|
|
586
|
+
'cbe', 'c.b.e', 'c.b.e.',
|
|
569
587
|
// Professional certifications
|
|
570
588
|
'cpa', 'c.p.a', 'c.p.a.', 'cfa', 'c.f.a', 'c.f.a.',
|
|
571
589
|
'pe', 'p.e', 'p.e.', 'esq', 'esq.',
|
|
590
|
+
'ra', 'r.a', 'r.a.', // Registered Architect
|
|
591
|
+
'aia', 'a.i.a', 'a.i.a.', // American Institute of Architects
|
|
572
592
|
'rn', 'r.n', 'r.n.', 'lpn', 'l.p.n', 'l.p.n.',
|
|
573
593
|
'cae', 'c.a.e', 'c.a.e.', 'pmp', 'p.m.p', 'p.m.p.',
|
|
574
594
|
'cissp', 'ccna', 'mcse', 'aws',
|
|
@@ -577,6 +597,12 @@ const NAME_SUFFIXES = new Set([
|
|
|
577
597
|
'sj', 's.j', 's.j.', 'op', 'o.p', 'o.p.', 'osb', 'o.s.b', 'o.s.b.',
|
|
578
598
|
]);
|
|
579
599
|
|
|
600
|
+
/** Check if a string looks like a suffix (matches NAME_SUFFIXES patterns) */
|
|
601
|
+
function isSuffix(word: string): boolean {
|
|
602
|
+
const lower = word.toLowerCase().replace(/[.,]+$/, '');
|
|
603
|
+
return NAME_SUFFIXES.has(lower) || NAME_SUFFIXES.has(lower.replace(/\./g, ''));
|
|
604
|
+
}
|
|
605
|
+
|
|
580
606
|
/** Convert ALL CAPS to Title Case, preserve mixed case */
|
|
581
607
|
function fixCase(name: string): string {
|
|
582
608
|
if (!name) return name;
|
|
@@ -667,17 +693,20 @@ function parseFullName(fullName: string): ParsedName {
|
|
|
667
693
|
const beforeComma = namePart.substring(0, commaIndex).trim();
|
|
668
694
|
const afterComma = namePart.substring(commaIndex + 1).trim();
|
|
669
695
|
|
|
670
|
-
// Check if after-comma part is a known suffix
|
|
671
|
-
|
|
672
|
-
|
|
696
|
+
// Check if after-comma part is a known suffix (or multiple suffixes)
|
|
697
|
+
// Handle "John Smith, Ph.D., P.E." or "John Smith, Jr."
|
|
698
|
+
const afterParts = afterComma.split(/,\s*/);
|
|
699
|
+
const allSuffixes = afterParts.every(p => isSuffix(p.trim()));
|
|
700
|
+
|
|
701
|
+
if (allSuffixes) {
|
|
673
702
|
suffix = afterComma;
|
|
674
703
|
namePart = beforeComma;
|
|
675
704
|
} else {
|
|
676
705
|
// Could be "Last, First" format or unknown suffix
|
|
677
706
|
const beforeParts = beforeComma.split(/\s+/);
|
|
678
|
-
const
|
|
707
|
+
const afterWords = afterComma.split(/\s+/);
|
|
679
708
|
|
|
680
|
-
if (beforeParts.length === 1 &&
|
|
709
|
+
if (beforeParts.length === 1 && afterWords.length >= 1) {
|
|
681
710
|
// Ambiguous: could be "Smith, John" or "John Smith, UnknownSuffix"
|
|
682
711
|
ambiguous = true;
|
|
683
712
|
}
|
|
@@ -711,6 +740,17 @@ function parseFullName(fullName: string): ParsedName {
|
|
|
711
740
|
}
|
|
712
741
|
}
|
|
713
742
|
|
|
743
|
+
// Check for suffixes at the END of the name (no comma)
|
|
744
|
+
// E.g., "John Smith Ph.D." or "Mary Jones P.E."
|
|
745
|
+
// Collect suffixes from the end working backwards
|
|
746
|
+
const trailingSuffixes: string[] = [];
|
|
747
|
+
while (rawParts.length > 2 && isSuffix(rawParts[rawParts.length - 1])) {
|
|
748
|
+
trailingSuffixes.unshift(rawParts.pop()!);
|
|
749
|
+
}
|
|
750
|
+
if (trailingSuffixes.length > 0) {
|
|
751
|
+
suffix = suffix ? suffix + ', ' + trailingSuffixes.join(', ') : trailingSuffixes.join(', ');
|
|
752
|
+
}
|
|
753
|
+
|
|
714
754
|
// Now apply case normalization
|
|
715
755
|
const parts = rawParts.map(fixCase);
|
|
716
756
|
|
|
@@ -887,18 +927,15 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
|
|
|
887
927
|
|
|
888
928
|
const parsed = parseFullName(name.givenName);
|
|
889
929
|
|
|
890
|
-
//
|
|
930
|
+
// Company names are fine - skip without adding to problems
|
|
891
931
|
if (parsed.isCompany) {
|
|
892
|
-
const displayName = name.displayName || name.givenName || 'Unknown';
|
|
893
|
-
problems.push(`${file}: "${name.givenName}" - looks like a company name`);
|
|
894
|
-
console.log(` [SKIP:company] ${displayName} (${file})`);
|
|
895
932
|
continue;
|
|
896
933
|
}
|
|
897
934
|
|
|
898
935
|
// Check for ambiguous cases
|
|
899
936
|
if (parsed.ambiguous) {
|
|
900
937
|
const displayName = name.displayName || name.givenName || 'Unknown';
|
|
901
|
-
problems.push(`${file}
|
|
938
|
+
problems.push(`${file}\t${name.givenName}\tambiguous`);
|
|
902
939
|
console.log(` [SKIP:ambiguous] ${displayName} (${file})`);
|
|
903
940
|
continue;
|
|
904
941
|
}
|
|
@@ -906,7 +943,7 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
|
|
|
906
943
|
// Check if no parseable last name
|
|
907
944
|
if (!parsed.last) {
|
|
908
945
|
const displayName = name.displayName || name.givenName || 'Unknown';
|
|
909
|
-
problems.push(`${file}
|
|
946
|
+
problems.push(`${file}\t${name.givenName}\tno-last-name`);
|
|
910
947
|
console.log(` [SKIP:no-last-name] ${displayName} (${file})`);
|
|
911
948
|
continue;
|
|
912
949
|
}
|
|
@@ -1052,10 +1089,18 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
|
|
|
1052
1089
|
console.log(`\nLog file: ${logPath}`);
|
|
1053
1090
|
}
|
|
1054
1091
|
|
|
1055
|
-
// Write problems.txt for ambiguous cases
|
|
1092
|
+
// Write problems.txt for ambiguous cases (editable format)
|
|
1056
1093
|
if (problems.length > 0) {
|
|
1057
1094
|
const problemsPath = path.join(paths.userDir, 'problems.txt');
|
|
1058
|
-
|
|
1095
|
+
const header = `# Edit this file then run: gfix problems -u ${user}
|
|
1096
|
+
# Format: filename<TAB>name<TAB>reason
|
|
1097
|
+
# To delete: add 'x' at start of line
|
|
1098
|
+
# To mark as company: add 'c' at start of line (moves name to organization)
|
|
1099
|
+
# To fix name: replace the name with "First Last" or "First [Middle] Last"
|
|
1100
|
+
# To skip: leave unchanged
|
|
1101
|
+
#
|
|
1102
|
+
`;
|
|
1103
|
+
fs.writeFileSync(problemsPath, header + problems.join('\n'));
|
|
1059
1104
|
console.log(`\nProblems file: ${problemsPath}`);
|
|
1060
1105
|
}
|
|
1061
1106
|
|
|
@@ -1115,6 +1160,201 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
|
|
|
1115
1160
|
}
|
|
1116
1161
|
}
|
|
1117
1162
|
|
|
1163
|
+
/** Process edited problems.txt - apply name fixes or deletions */
|
|
1164
|
+
async function runProblems(user: string): Promise<void> {
|
|
1165
|
+
const paths = getUserPaths(user);
|
|
1166
|
+
const problemsPath = path.join(paths.userDir, 'problems.txt');
|
|
1167
|
+
|
|
1168
|
+
if (!fs.existsSync(problemsPath)) {
|
|
1169
|
+
console.error(`No problems.txt found for user: ${user}`);
|
|
1170
|
+
console.error(`Run 'gfix names -u ${user}' first to generate it.`);
|
|
1171
|
+
process.exit(1);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const content = fs.readFileSync(problemsPath, 'utf-8');
|
|
1175
|
+
const lines = content.split(/\r?\n/).filter(l => l.trim() && !l.startsWith('#'));
|
|
1176
|
+
|
|
1177
|
+
if (lines.length === 0) {
|
|
1178
|
+
console.log('No entries in problems.txt (or all are comments)');
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
console.log(`\nProcessing ${lines.length} entries from problems.txt\n`);
|
|
1183
|
+
|
|
1184
|
+
let deleted = 0;
|
|
1185
|
+
let fixed = 0;
|
|
1186
|
+
let skipped = 0;
|
|
1187
|
+
|
|
1188
|
+
if (!fs.existsSync(paths.toDeleteDir)) {
|
|
1189
|
+
fs.mkdirSync(paths.toDeleteDir, { recursive: true });
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
let companies = 0;
|
|
1193
|
+
|
|
1194
|
+
for (const line of lines) {
|
|
1195
|
+
// Check for deletion marker (x or x<space> at start)
|
|
1196
|
+
// Always force delete (overrides photo/starred protection)
|
|
1197
|
+
const deleteMatch = line.match(/^x\s*(.*)/i);
|
|
1198
|
+
if (deleteMatch) {
|
|
1199
|
+
const rest = deleteMatch[1];
|
|
1200
|
+
const parts = rest.split('\t');
|
|
1201
|
+
const file = parts[0]?.trim();
|
|
1202
|
+
if (!file) {
|
|
1203
|
+
console.log(` [SKIP] Can't parse: ${line.slice(0, 50)}`);
|
|
1204
|
+
skipped++;
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
const srcPath = path.join(paths.contactsDir, file);
|
|
1209
|
+
if (!fs.existsSync(srcPath)) {
|
|
1210
|
+
console.log(` [SKIP] File not found: ${file}`);
|
|
1211
|
+
skipped++;
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// Mark with _delete: 'force' to override photo/starred protection
|
|
1216
|
+
const contact = parseJsonc(fs.readFileSync(srcPath, 'utf-8')) as GooglePerson;
|
|
1217
|
+
(contact as any)._delete = 'force';
|
|
1218
|
+
fs.writeFileSync(srcPath, JSON.stringify(contact, null, 2));
|
|
1219
|
+
|
|
1220
|
+
const dstPath = path.join(paths.toDeleteDir, file);
|
|
1221
|
+
fs.renameSync(srcPath, dstPath);
|
|
1222
|
+
console.log(` [DELETE] ${file}`);
|
|
1223
|
+
deleted++;
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// Check for company marker (c or c<space> at start)
|
|
1228
|
+
const companyMatch = line.match(/^c\s*(.*)/i);
|
|
1229
|
+
if (companyMatch) {
|
|
1230
|
+
const rest = companyMatch[1];
|
|
1231
|
+
const parts = rest.split('\t');
|
|
1232
|
+
const file = parts[0]?.trim();
|
|
1233
|
+
const origName = parts[1]?.trim() || '';
|
|
1234
|
+
if (!file) {
|
|
1235
|
+
console.log(` [SKIP] Can't parse: ${line.slice(0, 50)}`);
|
|
1236
|
+
skipped++;
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const filePath = path.join(paths.contactsDir, file);
|
|
1241
|
+
if (!fs.existsSync(filePath)) {
|
|
1242
|
+
console.log(` [SKIP] File not found: ${file}`);
|
|
1243
|
+
skipped++;
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
|
|
1248
|
+
const name = contact.names?.[0];
|
|
1249
|
+
const companyName = name?.givenName || name?.displayName || origName;
|
|
1250
|
+
|
|
1251
|
+
// Move name to organization
|
|
1252
|
+
if (!contact.organizations) {
|
|
1253
|
+
contact.organizations = [];
|
|
1254
|
+
}
|
|
1255
|
+
if (contact.organizations.length === 0) {
|
|
1256
|
+
contact.organizations.push({ name: companyName });
|
|
1257
|
+
} else if (!contact.organizations[0].name) {
|
|
1258
|
+
contact.organizations[0].name = companyName;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Clear person name fields
|
|
1262
|
+
if (name) {
|
|
1263
|
+
delete name.givenName;
|
|
1264
|
+
delete name.familyName;
|
|
1265
|
+
delete name.middleName;
|
|
1266
|
+
delete name.displayNameLastFirst;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
fs.writeFileSync(filePath, JSON.stringify(contact, null, 2));
|
|
1270
|
+
console.log(` [COMPANY] ${file}: ${companyName}`);
|
|
1271
|
+
companies++;
|
|
1272
|
+
continue;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// Parse as tab-separated: filename<TAB>name<TAB>reason
|
|
1276
|
+
const parts = line.split('\t');
|
|
1277
|
+
if (parts.length < 2) {
|
|
1278
|
+
skipped++;
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const file = parts[0].trim();
|
|
1283
|
+
const newName = parts[1].trim();
|
|
1284
|
+
const originalReason = parts[2]?.trim() || '';
|
|
1285
|
+
|
|
1286
|
+
// Check if name was changed (compare with original patterns)
|
|
1287
|
+
// Original format was: "Original Name" so if it doesn't start with quote, it's edited
|
|
1288
|
+
const wasEdited = !newName.startsWith('"');
|
|
1289
|
+
|
|
1290
|
+
if (!wasEdited) {
|
|
1291
|
+
skipped++;
|
|
1292
|
+
continue;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// Parse the new name: "First Last" or "First [Middle] Last"
|
|
1296
|
+
const filePath = path.join(paths.contactsDir, file);
|
|
1297
|
+
if (!fs.existsSync(filePath)) {
|
|
1298
|
+
console.log(` [SKIP] File not found: ${file}`);
|
|
1299
|
+
skipped++;
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
|
|
1304
|
+
const name = contact.names?.[0];
|
|
1305
|
+
if (!name) {
|
|
1306
|
+
console.log(` [SKIP] No name in contact: ${file}`);
|
|
1307
|
+
skipped++;
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Parse "First [Middle] Last" format
|
|
1312
|
+
const nameMatch = newName.match(/^(\S+)(?:\s+\[([^\]]+)\])?\s+(.+)$/);
|
|
1313
|
+
if (!nameMatch) {
|
|
1314
|
+
// Try simple "First Last"
|
|
1315
|
+
const simpleParts = newName.split(/\s+/);
|
|
1316
|
+
if (simpleParts.length >= 2) {
|
|
1317
|
+
const first = simpleParts[0];
|
|
1318
|
+
const last = simpleParts.slice(1).join(' ');
|
|
1319
|
+
name.givenName = first;
|
|
1320
|
+
name.familyName = last;
|
|
1321
|
+
delete name.middleName;
|
|
1322
|
+
fs.writeFileSync(filePath, JSON.stringify(contact, null, 2));
|
|
1323
|
+
console.log(` [FIX] ${file}: ${first} ${last}`);
|
|
1324
|
+
fixed++;
|
|
1325
|
+
} else {
|
|
1326
|
+
console.log(` [SKIP] Can't parse name: "${newName}"`);
|
|
1327
|
+
skipped++;
|
|
1328
|
+
}
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
const [, first, middle, last] = nameMatch;
|
|
1333
|
+
name.givenName = first;
|
|
1334
|
+
name.familyName = last;
|
|
1335
|
+
if (middle) {
|
|
1336
|
+
name.middleName = middle;
|
|
1337
|
+
} else {
|
|
1338
|
+
delete name.middleName;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
fs.writeFileSync(filePath, JSON.stringify(contact, null, 2));
|
|
1342
|
+
console.log(` [FIX] ${file}: ${first}${middle ? ' ' + middle : ''} ${last}`);
|
|
1343
|
+
fixed++;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
console.log(`\n${'='.repeat(50)}`);
|
|
1347
|
+
console.log(`Summary:`);
|
|
1348
|
+
console.log(` Fixed: ${fixed}`);
|
|
1349
|
+
console.log(` Companies: ${companies}`);
|
|
1350
|
+
console.log(` Deleted: ${deleted}`);
|
|
1351
|
+
console.log(` Skipped: ${skipped}`);
|
|
1352
|
+
|
|
1353
|
+
if (fixed > 0 || deleted > 0 || companies > 0) {
|
|
1354
|
+
console.log(`\nRun 'gcards push -u ${user}' to apply changes to Google.`);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1118
1358
|
async function runFix(user: string, mode: 'inspect' | 'apply'): Promise<void> {
|
|
1119
1359
|
const paths = getUserPaths(user);
|
|
1120
1360
|
|
|
@@ -1896,6 +2136,7 @@ Commands:
|
|
|
1896
2136
|
birthday Extract birthdays to CSV and remove from contacts
|
|
1897
2137
|
fileas Normalize "Last, First" fileAs to "First Last"
|
|
1898
2138
|
names Parse givenName into first/middle/last, clean fileAs, remove dup phones/emails
|
|
2139
|
+
problems Process edited problems.txt (apply fixes or deletions)
|
|
1899
2140
|
undup Find duplicate contacts (same name + overlapping email) -> merger.json
|
|
1900
2141
|
merge Merge duplicates locally (then use gcards push)
|
|
1901
2142
|
reset Clear all _delete flags from index.json
|
|
@@ -1926,6 +2167,10 @@ Name parsing (when givenName has full name but no familyName):
|
|
|
1926
2167
|
- gfix names -u bob # Preview name splits
|
|
1927
2168
|
- gfix names -u bob --apply # Split names and create changes.log
|
|
1928
2169
|
|
|
2170
|
+
Processing problem contacts (from gfix names):
|
|
2171
|
+
- Edit problems.txt: add 'x' at start to delete, or replace name with "First Last"
|
|
2172
|
+
- gfix problems -u bob # Apply fixes from edited problems.txt
|
|
2173
|
+
|
|
1929
2174
|
Duplicate detection and merge:
|
|
1930
2175
|
- gfix undup -u bob # Find duplicates -> merger.json
|
|
1931
2176
|
- Edit merger.json (remove false positives, add "_delete": true for spam)
|
|
@@ -2042,6 +2287,8 @@ async function main(): Promise<void> {
|
|
|
2042
2287
|
await runMerge(resolvedUser, processLimit);
|
|
2043
2288
|
} else if (command === 'reset') {
|
|
2044
2289
|
await runReset(resolvedUser);
|
|
2290
|
+
} else if (command === 'problems') {
|
|
2291
|
+
await runProblems(resolvedUser);
|
|
2045
2292
|
} else {
|
|
2046
2293
|
console.error(`Unknown command: ${command}`);
|
|
2047
2294
|
showUsage();
|