@bobfrankston/gcards 0.1.16 → 0.1.18
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 +61 -9
- package/gfix.ts +106 -16
- package/glib/gutils.ts +256 -0
- 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
|
@@ -13,7 +13,7 @@ import { authenticateOAuth } from '../../../projects/oauth/oauthsupport/index.ts
|
|
|
13
13
|
import type { GooglePerson, GoogleConnectionsResponse } from './glib/types.ts';
|
|
14
14
|
import { GCARDS_GUID_KEY, extractGuids } from './glib/gctypes.ts';
|
|
15
15
|
import type { ContactIndex, IndexEntry, DeletedEntry, DeleteQueue, DeleteQueueEntry, PushStatus, PendingChange, UserPaths } from './glib/gctypes.ts';
|
|
16
|
-
import { DATA_DIR, CREDENTIALS_FILE, loadConfig, saveConfig, getUserPaths, ensureUserDir, loadIndex, normalizeUser, getAllUsers, resolveUser, FileLogger } from './glib/gutils.ts';
|
|
16
|
+
import { DATA_DIR, CREDENTIALS_FILE, loadConfig, saveConfig, getUserPaths, ensureUserDir, loadIndex, normalizeUser, getAllUsers, resolveUser, FileLogger, processProblemsFile } from './glib/gutils.ts';
|
|
17
17
|
import { explainApiError, isAIAvailable, showAISetupHint } from './glib/aihelper.ts';
|
|
18
18
|
|
|
19
19
|
const PEOPLE_API_BASE = 'https://people.googleapis.com/v1';
|
|
@@ -563,6 +563,12 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?:
|
|
|
563
563
|
console.log(`Using -since override: ${since}`);
|
|
564
564
|
}
|
|
565
565
|
|
|
566
|
+
// Process problems.txt first (applies deletions, fixes, etc.)
|
|
567
|
+
const problemsResult = processProblemsFile(paths, false);
|
|
568
|
+
if (problemsResult.deleted > 0 || problemsResult.fixed > 0 || problemsResult.companies > 0) {
|
|
569
|
+
console.log(`Processed problems.txt: ${problemsResult.deleted} deleted, ${problemsResult.fixed} fixed, ${problemsResult.companies} companies`);
|
|
570
|
+
}
|
|
571
|
+
|
|
566
572
|
// Check _delete.json for deletion requests
|
|
567
573
|
const deleteQueue = loadDeleteQueue(paths);
|
|
568
574
|
for (const entry of deleteQueue.entries) {
|
|
@@ -629,7 +635,7 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?:
|
|
|
629
635
|
const files = fs.readdirSync(paths.toDeleteDir).filter(f => f.endsWith('.json'));
|
|
630
636
|
for (const file of files) {
|
|
631
637
|
const filePath = path.join(paths.toDeleteDir, file);
|
|
632
|
-
let content: GooglePerson;
|
|
638
|
+
let content: GooglePerson & { _delete?: string | boolean };
|
|
633
639
|
try {
|
|
634
640
|
content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
|
|
635
641
|
} catch (e: any) {
|
|
@@ -644,7 +650,8 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?:
|
|
|
644
650
|
type: 'delete',
|
|
645
651
|
resourceName: content.resourceName,
|
|
646
652
|
displayName,
|
|
647
|
-
filePath
|
|
653
|
+
filePath,
|
|
654
|
+
_delete: content._delete === 'force' ? 'force' : undefined
|
|
648
655
|
});
|
|
649
656
|
}
|
|
650
657
|
}
|
|
@@ -975,21 +982,21 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
975
982
|
const contactFile = path.join(paths.contactsDir, `${change.resourceName.replace('people/', '')}.json`);
|
|
976
983
|
const fileToRead = fs.existsSync(contactFile) ? contactFile : change.filePath;
|
|
977
984
|
|
|
978
|
-
//
|
|
985
|
+
// Read contact to check photo/starred/force status
|
|
979
986
|
let contactHasPhoto = false;
|
|
980
987
|
let contactIsStarred = false;
|
|
988
|
+
let isForced = change._delete === 'force' || index.contacts[change.resourceName]?._delete === 'force';
|
|
981
989
|
if (fs.existsSync(fileToRead)) {
|
|
982
990
|
try {
|
|
983
991
|
const contact = JSON.parse(fs.readFileSync(fileToRead, 'utf-8')) as GooglePerson;
|
|
984
992
|
contactHasPhoto = hasNonDefaultPhoto(contact);
|
|
985
993
|
contactIsStarred = isStarred(contact);
|
|
994
|
+
if ((contact as any)._delete === 'force') isForced = true;
|
|
986
995
|
} catch { /* ignore read errors */ }
|
|
987
996
|
}
|
|
988
997
|
|
|
989
998
|
// Determine skip reason (photo takes precedence over starred)
|
|
990
|
-
// Use * prefix to indicate "skipped, not deleted"
|
|
991
999
|
// 'force' overrides photo/starred protection
|
|
992
|
-
const isForced = change._delete === 'force' || index.contacts[change.resourceName]?._delete === 'force';
|
|
993
1000
|
const skipReason = isForced ? null : (contactHasPhoto ? '*photo' : (contactIsStarred ? '*starred' : null));
|
|
994
1001
|
|
|
995
1002
|
if (skipReason) {
|
|
@@ -1002,7 +1009,8 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
1002
1009
|
});
|
|
1003
1010
|
if (skipReason === '*photo') skippedPhoto++;
|
|
1004
1011
|
else if (skipReason === '*starred') skippedStarred++;
|
|
1005
|
-
|
|
1012
|
+
const fileId = change.resourceName.replace('people/', '');
|
|
1013
|
+
console.log(`${ts()} [${String(processed).padStart(5)}/${total}] SKIPPED ${change.displayName} (${skipReason}) ${fileId}.json`);
|
|
1006
1014
|
|
|
1007
1015
|
// Update _delete to show skip reason
|
|
1008
1016
|
if (index.contacts[change.resourceName]) {
|
|
@@ -1100,11 +1108,53 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
1100
1108
|
console.log(`\nErrors written to: ${problemsFile}`);
|
|
1101
1109
|
}
|
|
1102
1110
|
|
|
1103
|
-
// Write notdeleted file if any contacts were skipped due to photos
|
|
1111
|
+
// Write notdeleted file if any contacts were skipped due to photos/starred
|
|
1112
|
+
// Also append to problems.txt so user can decide their fate
|
|
1104
1113
|
if (notDeleted.length > 0) {
|
|
1105
1114
|
const notDeletedFile = path.join(paths.userDir, 'notdeleted.json');
|
|
1106
1115
|
fs.writeFileSync(notDeletedFile, JSON.stringify(notDeleted, null, 2));
|
|
1107
|
-
console.log(`\n${notDeleted.length} contacts with photos not deleted from Google (see notdeleted.json)`);
|
|
1116
|
+
console.log(`\n${notDeleted.length} contacts with photos/starred not deleted from Google (see notdeleted.json)`);
|
|
1117
|
+
|
|
1118
|
+
// Append to problems.txt for user review
|
|
1119
|
+
const problemsPath = path.join(paths.userDir, 'problems.txt');
|
|
1120
|
+
let existingProblems = '';
|
|
1121
|
+
if (fs.existsSync(problemsPath)) {
|
|
1122
|
+
existingProblems = fs.readFileSync(problemsPath, 'utf-8');
|
|
1123
|
+
} else {
|
|
1124
|
+
existingProblems = `# Edit this file then run: gfix problems -u ${user}
|
|
1125
|
+
# Format: filename ; name ; reason ; email
|
|
1126
|
+
# To delete: add 'x' at start of line
|
|
1127
|
+
# To mark as company: add 'c' at start of line (moves name to organization)
|
|
1128
|
+
# To fix name: replace the name with "First Last" or "First [Middle] Last"
|
|
1129
|
+
# Fields: nickname: title: company: suffix: (quote values with spaces, "" to remove)
|
|
1130
|
+
# To skip: leave unchanged
|
|
1131
|
+
# You can manually add entries to this file
|
|
1132
|
+
#
|
|
1133
|
+
`;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Add photo/starred entries with email
|
|
1137
|
+
const newEntries = notDeleted.map(nd => {
|
|
1138
|
+
const id = nd.resourceName.replace('people/', '');
|
|
1139
|
+
const reason = nd.reason === '*photo' ? 'has-photo' : 'starred';
|
|
1140
|
+
let email = '';
|
|
1141
|
+
if (nd.filePath && fs.existsSync(nd.filePath)) {
|
|
1142
|
+
try {
|
|
1143
|
+
const contact = JSON.parse(fs.readFileSync(nd.filePath, 'utf-8'));
|
|
1144
|
+
email = contact.emailAddresses?.[0]?.value || '';
|
|
1145
|
+
} catch { /* ignore */ }
|
|
1146
|
+
}
|
|
1147
|
+
return `${id}.json ; ${nd.displayName} ; ${reason} ; ${email}`;
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
// Avoid duplicates - check if entry already exists
|
|
1151
|
+
const existingLines = new Set(existingProblems.split('\n').map(l => l.split(';')[0].trim()));
|
|
1152
|
+
const uniqueEntries = newEntries.filter(e => !existingLines.has(e.split(';')[0].trim()));
|
|
1153
|
+
|
|
1154
|
+
if (uniqueEntries.length > 0) {
|
|
1155
|
+
fs.writeFileSync(problemsPath, existingProblems.trimEnd() + '\n' + uniqueEntries.join('\n') + '\n');
|
|
1156
|
+
console.log(`Added ${uniqueEntries.length} photo/starred entries to problems.txt for review`);
|
|
1157
|
+
}
|
|
1108
1158
|
}
|
|
1109
1159
|
|
|
1110
1160
|
// Remove completed deletions from queue
|
|
@@ -1127,6 +1177,8 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
1127
1177
|
if (etagErrors.length > 0) {
|
|
1128
1178
|
console.log(`\n${etagErrors.length} contact(s) had etag conflicts. Run 'gcards sync' to refresh, then retry push.`);
|
|
1129
1179
|
}
|
|
1180
|
+
|
|
1181
|
+
process.exit(0);
|
|
1130
1182
|
}
|
|
1131
1183
|
|
|
1132
1184
|
async function main(): Promise<void> {
|
package/gfix.ts
CHANGED
|
@@ -14,7 +14,7 @@ import readline from 'readline';
|
|
|
14
14
|
import { parse as parseJsonc } from 'jsonc-parser';
|
|
15
15
|
import type { GooglePerson, GoogleName, GooglePhoneNumber, GoogleEmailAddress, GoogleBirthday } from './glib/types.ts';
|
|
16
16
|
import type { DeleteQueue, DeleteQueueEntry, UserPaths } from './glib/gctypes.ts';
|
|
17
|
-
import { DATA_DIR, resolveUser, getUserPaths, loadIndex, getAllUsers, normalizeUser, matchUsers } from './glib/gutils.ts';
|
|
17
|
+
import { DATA_DIR, resolveUser, getUserPaths, loadIndex, getAllUsers, normalizeUser, matchUsers, processProblemsFile } from './glib/gutils.ts';
|
|
18
18
|
import { mergeContacts as mergeContactData, collectSourcePhotos, type MergeEntry, type PhotoEntry } from './glib/gmerge.ts';
|
|
19
19
|
|
|
20
20
|
function loadDeleteQueue(paths: UserPaths): DeleteQueue {
|
|
@@ -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
|
|
|
@@ -885,20 +925,23 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
|
|
|
885
925
|
|
|
886
926
|
// Note: email-in-name is now handled earlier as DELETE
|
|
887
927
|
|
|
928
|
+
// Skip CJK names (Chinese, Japanese, Korean) - they don't use spaces
|
|
929
|
+
if (/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+$/u.test(name.givenName)) {
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
|
|
888
933
|
const parsed = parseFullName(name.givenName);
|
|
889
934
|
|
|
890
|
-
//
|
|
935
|
+
// Company names are fine - skip without adding to problems
|
|
891
936
|
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
937
|
continue;
|
|
896
938
|
}
|
|
897
939
|
|
|
898
940
|
// Check for ambiguous cases
|
|
899
941
|
if (parsed.ambiguous) {
|
|
900
942
|
const displayName = name.displayName || name.givenName || 'Unknown';
|
|
901
|
-
|
|
943
|
+
const email = contact.emailAddresses?.[0]?.value || '';
|
|
944
|
+
problems.push(`${file} ; ${name.givenName} ; ambiguous ; ${email}`);
|
|
902
945
|
console.log(` [SKIP:ambiguous] ${displayName} (${file})`);
|
|
903
946
|
continue;
|
|
904
947
|
}
|
|
@@ -906,7 +949,8 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
|
|
|
906
949
|
// Check if no parseable last name
|
|
907
950
|
if (!parsed.last) {
|
|
908
951
|
const displayName = name.displayName || name.givenName || 'Unknown';
|
|
909
|
-
|
|
952
|
+
const email = contact.emailAddresses?.[0]?.value || '';
|
|
953
|
+
problems.push(`${file} ; ${name.givenName} ; no-last-name ; ${email}`);
|
|
910
954
|
console.log(` [SKIP:no-last-name] ${displayName} (${file})`);
|
|
911
955
|
continue;
|
|
912
956
|
}
|
|
@@ -1052,10 +1096,20 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
|
|
|
1052
1096
|
console.log(`\nLog file: ${logPath}`);
|
|
1053
1097
|
}
|
|
1054
1098
|
|
|
1055
|
-
// Write problems.txt for ambiguous cases
|
|
1099
|
+
// Write problems.txt for ambiguous cases (editable format)
|
|
1056
1100
|
if (problems.length > 0) {
|
|
1057
1101
|
const problemsPath = path.join(paths.userDir, 'problems.txt');
|
|
1058
|
-
|
|
1102
|
+
const header = `# Edit this file then run: gfix problems -u ${user}
|
|
1103
|
+
# Format: filename ; name ; reason ; email
|
|
1104
|
+
# To delete: add 'x' at start of line
|
|
1105
|
+
# To mark as company: add 'c' at start of line (moves name to organization)
|
|
1106
|
+
# To fix name: replace the name with "First Last" or "First [Middle] Last"
|
|
1107
|
+
# Fields: nickname: title: company: suffix: (quote values with spaces, "" to remove)
|
|
1108
|
+
# To skip: leave unchanged
|
|
1109
|
+
# You can manually add entries to this file
|
|
1110
|
+
#
|
|
1111
|
+
`;
|
|
1112
|
+
fs.writeFileSync(problemsPath, header + problems.join('\n'));
|
|
1059
1113
|
console.log(`\nProblems file: ${problemsPath}`);
|
|
1060
1114
|
}
|
|
1061
1115
|
|
|
@@ -1115,6 +1169,35 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
|
|
|
1115
1169
|
}
|
|
1116
1170
|
}
|
|
1117
1171
|
|
|
1172
|
+
/** Process edited problems.txt - apply name fixes or deletions */
|
|
1173
|
+
async function runProblems(user: string): Promise<void> {
|
|
1174
|
+
const paths = getUserPaths(user);
|
|
1175
|
+
const problemsPath = path.join(paths.userDir, 'problems.txt');
|
|
1176
|
+
|
|
1177
|
+
if (!fs.existsSync(problemsPath)) {
|
|
1178
|
+
console.error(`No problems.txt found for user: ${user}`);
|
|
1179
|
+
console.error(`Run 'gfix names -u ${user}' first to generate it.`);
|
|
1180
|
+
process.exit(1);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
console.log(`\nProcessing problems.txt for user: ${user}\n`);
|
|
1184
|
+
|
|
1185
|
+
const result = processProblemsFile(paths);
|
|
1186
|
+
|
|
1187
|
+
console.log(`\n${'='.repeat(50)}`);
|
|
1188
|
+
console.log(`Summary:`);
|
|
1189
|
+
console.log(` Fixed: ${result.fixed}`);
|
|
1190
|
+
console.log(` Companies: ${result.companies}`);
|
|
1191
|
+
console.log(` Deleted: ${result.deleted}`);
|
|
1192
|
+
console.log(` Skipped: ${result.skipped}`);
|
|
1193
|
+
|
|
1194
|
+
if (result.fixed > 0 || result.deleted > 0 || result.companies > 0) {
|
|
1195
|
+
console.log(`\nRun 'gcards push -u ${user}' to apply changes to Google.`);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
process.exit(0);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1118
1201
|
async function runFix(user: string, mode: 'inspect' | 'apply'): Promise<void> {
|
|
1119
1202
|
const paths = getUserPaths(user);
|
|
1120
1203
|
|
|
@@ -1896,6 +1979,7 @@ Commands:
|
|
|
1896
1979
|
birthday Extract birthdays to CSV and remove from contacts
|
|
1897
1980
|
fileas Normalize "Last, First" fileAs to "First Last"
|
|
1898
1981
|
names Parse givenName into first/middle/last, clean fileAs, remove dup phones/emails
|
|
1982
|
+
problems Process edited problems.txt (apply fixes or deletions)
|
|
1899
1983
|
undup Find duplicate contacts (same name + overlapping email) -> merger.json
|
|
1900
1984
|
merge Merge duplicates locally (then use gcards push)
|
|
1901
1985
|
reset Clear all _delete flags from index.json
|
|
@@ -1926,6 +2010,10 @@ Name parsing (when givenName has full name but no familyName):
|
|
|
1926
2010
|
- gfix names -u bob # Preview name splits
|
|
1927
2011
|
- gfix names -u bob --apply # Split names and create changes.log
|
|
1928
2012
|
|
|
2013
|
+
Processing problem contacts (from gfix names):
|
|
2014
|
+
- Edit problems.txt: add 'x' at start to delete, or replace name with "First Last"
|
|
2015
|
+
- gfix problems -u bob # Apply fixes from edited problems.txt
|
|
2016
|
+
|
|
1929
2017
|
Duplicate detection and merge:
|
|
1930
2018
|
- gfix undup -u bob # Find duplicates -> merger.json
|
|
1931
2019
|
- Edit merger.json (remove false positives, add "_delete": true for spam)
|
|
@@ -2042,6 +2130,8 @@ async function main(): Promise<void> {
|
|
|
2042
2130
|
await runMerge(resolvedUser, processLimit);
|
|
2043
2131
|
} else if (command === 'reset') {
|
|
2044
2132
|
await runReset(resolvedUser);
|
|
2133
|
+
} else if (command === 'problems') {
|
|
2134
|
+
await runProblems(resolvedUser);
|
|
2045
2135
|
} else {
|
|
2046
2136
|
console.error(`Unknown command: ${command}`);
|
|
2047
2137
|
showUsage();
|
package/glib/gutils.ts
CHANGED
|
@@ -197,3 +197,259 @@ export class FileLogger {
|
|
|
197
197
|
fs.appendFileSync(this.filePath, message + '\n');
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
|
+
|
|
201
|
+
export interface ProblemsResult {
|
|
202
|
+
deleted: number;
|
|
203
|
+
fixed: number;
|
|
204
|
+
companies: number;
|
|
205
|
+
skipped: number;
|
|
206
|
+
remaining: string[]; // Lines that weren't processed (unchanged)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Process problems.txt file - apply deletions, company marks, name fixes, field updates
|
|
211
|
+
* Returns counts and remaining unprocessed lines
|
|
212
|
+
*/
|
|
213
|
+
export function processProblemsFile(paths: UserPaths, verbose = true): ProblemsResult {
|
|
214
|
+
const problemsPath = path.join(paths.userDir, 'problems.txt');
|
|
215
|
+
const result: ProblemsResult = { deleted: 0, fixed: 0, companies: 0, skipped: 0, remaining: [] };
|
|
216
|
+
|
|
217
|
+
if (!fs.existsSync(problemsPath)) {
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const content = fs.readFileSync(problemsPath, 'utf-8');
|
|
222
|
+
const lines = content.split(/\r?\n/);
|
|
223
|
+
const headerLines: string[] = [];
|
|
224
|
+
|
|
225
|
+
if (!fs.existsSync(paths.toDeleteDir)) {
|
|
226
|
+
fs.mkdirSync(paths.toDeleteDir, { recursive: true });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const line of lines) {
|
|
230
|
+
// Preserve comments and blank lines for remaining file
|
|
231
|
+
if (!line.trim() || line.startsWith('#')) {
|
|
232
|
+
headerLines.push(line);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Check for deletion marker (x or x<space> at start)
|
|
237
|
+
const deleteMatch = line.match(/^x\s*(.*)/i);
|
|
238
|
+
if (deleteMatch) {
|
|
239
|
+
const rest = deleteMatch[1];
|
|
240
|
+
const parts = rest.split(';');
|
|
241
|
+
const file = parts[0]?.trim();
|
|
242
|
+
if (!file) {
|
|
243
|
+
if (verbose) console.log(` [SKIP] Can't parse: ${line.slice(0, 50)}`);
|
|
244
|
+
result.skipped++;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const srcPath = path.join(paths.contactsDir, file);
|
|
249
|
+
if (!fs.existsSync(srcPath)) {
|
|
250
|
+
result.skipped++;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Mark with _delete: 'force' to override photo/starred protection
|
|
255
|
+
const contact = parseJsonc(fs.readFileSync(srcPath, 'utf-8'));
|
|
256
|
+
(contact as any)._delete = 'force';
|
|
257
|
+
fs.writeFileSync(srcPath, JSON.stringify(contact, null, 2));
|
|
258
|
+
|
|
259
|
+
const dstPath = path.join(paths.toDeleteDir, file);
|
|
260
|
+
fs.renameSync(srcPath, dstPath);
|
|
261
|
+
if (verbose) console.log(` [DELETE] ${file}`);
|
|
262
|
+
result.deleted++;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Check for company marker (c or c<space> at start)
|
|
267
|
+
const companyMatch = line.match(/^c\s*(.*)/i);
|
|
268
|
+
if (companyMatch) {
|
|
269
|
+
const rest = companyMatch[1];
|
|
270
|
+
const parts = rest.split(';');
|
|
271
|
+
const file = parts[0]?.trim();
|
|
272
|
+
const origName = parts[1]?.trim() || '';
|
|
273
|
+
if (!file) {
|
|
274
|
+
if (verbose) console.log(` [SKIP] Can't parse: ${line.slice(0, 50)}`);
|
|
275
|
+
result.skipped++;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const filePath = path.join(paths.contactsDir, file);
|
|
280
|
+
if (!fs.existsSync(filePath)) {
|
|
281
|
+
result.skipped++;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as any;
|
|
286
|
+
const name = contact.names?.[0];
|
|
287
|
+
const companyName = name?.givenName || name?.displayName || origName;
|
|
288
|
+
|
|
289
|
+
// Move name to organization
|
|
290
|
+
if (!contact.organizations) contact.organizations = [];
|
|
291
|
+
if (contact.organizations.length === 0) {
|
|
292
|
+
contact.organizations.push({ name: companyName });
|
|
293
|
+
} else if (!contact.organizations[0].name) {
|
|
294
|
+
contact.organizations[0].name = companyName;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Clear person name fields
|
|
298
|
+
if (name) {
|
|
299
|
+
delete name.givenName;
|
|
300
|
+
delete name.familyName;
|
|
301
|
+
delete name.middleName;
|
|
302
|
+
delete name.displayNameLastFirst;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
fs.writeFileSync(filePath, JSON.stringify(contact, null, 2));
|
|
306
|
+
if (verbose) console.log(` [COMPANY] ${file}: ${companyName}`);
|
|
307
|
+
result.companies++;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Parse as semicolon-separated: filename ; name ; reason ; email
|
|
312
|
+
const parts = line.split(';');
|
|
313
|
+
if (parts.length < 2) {
|
|
314
|
+
result.remaining.push(line);
|
|
315
|
+
result.skipped++;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const file = parts[0].trim();
|
|
320
|
+
const fieldsPart = parts[1].trim();
|
|
321
|
+
|
|
322
|
+
// Check if anything was changed (original starts with quote)
|
|
323
|
+
if (fieldsPart.startsWith('"')) {
|
|
324
|
+
result.remaining.push(line);
|
|
325
|
+
result.skipped++;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const filePath = path.join(paths.contactsDir, file);
|
|
330
|
+
if (!fs.existsSync(filePath)) {
|
|
331
|
+
result.skipped++;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as any;
|
|
336
|
+
|
|
337
|
+
// Parse field:value pairs and name
|
|
338
|
+
const fieldRegex = /(\w+):(?:"([^"]+)"|(\S+))/g;
|
|
339
|
+
const fields: Record<string, string> = {};
|
|
340
|
+
let remaining = fieldsPart;
|
|
341
|
+
|
|
342
|
+
let match: RegExpExecArray | null;
|
|
343
|
+
while ((match = fieldRegex.exec(fieldsPart)) !== null) {
|
|
344
|
+
const fieldName = match[1].toLowerCase();
|
|
345
|
+
const value = match[2] ?? match[3]; // quoted or unquoted
|
|
346
|
+
fields[fieldName] = value;
|
|
347
|
+
remaining = remaining.replace(match[0], '').trim();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const changes: string[] = [];
|
|
351
|
+
|
|
352
|
+
// Apply field updates (empty string "" means remove)
|
|
353
|
+
if ('nickname' in fields) {
|
|
354
|
+
if (fields.nickname === '') {
|
|
355
|
+
delete contact.nicknames;
|
|
356
|
+
changes.push(`nickname:[removed]`);
|
|
357
|
+
} else {
|
|
358
|
+
if (!contact.nicknames) contact.nicknames = [];
|
|
359
|
+
if (contact.nicknames.length === 0) {
|
|
360
|
+
contact.nicknames.push({ value: fields.nickname });
|
|
361
|
+
} else {
|
|
362
|
+
contact.nicknames[0].value = fields.nickname;
|
|
363
|
+
}
|
|
364
|
+
changes.push(`nickname:${fields.nickname}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if ('title' in fields) {
|
|
369
|
+
if (fields.title === '') {
|
|
370
|
+
if (contact.organizations?.[0]) delete contact.organizations[0].title;
|
|
371
|
+
changes.push(`title:[removed]`);
|
|
372
|
+
} else {
|
|
373
|
+
if (!contact.organizations) contact.organizations = [];
|
|
374
|
+
if (contact.organizations.length === 0) {
|
|
375
|
+
contact.organizations.push({ title: fields.title });
|
|
376
|
+
} else {
|
|
377
|
+
contact.organizations[0].title = fields.title;
|
|
378
|
+
}
|
|
379
|
+
changes.push(`title:${fields.title}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if ('company' in fields) {
|
|
384
|
+
if (fields.company === '') {
|
|
385
|
+
if (contact.organizations?.[0]) delete contact.organizations[0].name;
|
|
386
|
+
changes.push(`company:[removed]`);
|
|
387
|
+
} else {
|
|
388
|
+
if (!contact.organizations) contact.organizations = [];
|
|
389
|
+
if (contact.organizations.length === 0) {
|
|
390
|
+
contact.organizations.push({ name: fields.company });
|
|
391
|
+
} else {
|
|
392
|
+
contact.organizations[0].name = fields.company;
|
|
393
|
+
}
|
|
394
|
+
changes.push(`company:${fields.company}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if ('suffix' in fields) {
|
|
399
|
+
if (fields.suffix === '') {
|
|
400
|
+
if (contact.names?.[0]) delete contact.names[0].honorificSuffix;
|
|
401
|
+
changes.push(`suffix:[removed]`);
|
|
402
|
+
} else {
|
|
403
|
+
if (!contact.names) contact.names = [{}];
|
|
404
|
+
contact.names[0].honorificSuffix = fields.suffix;
|
|
405
|
+
changes.push(`suffix:${fields.suffix}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Parse name from remaining text
|
|
410
|
+
const newName = remaining.trim();
|
|
411
|
+
if (newName) {
|
|
412
|
+
const name = contact.names?.[0];
|
|
413
|
+
if (name) {
|
|
414
|
+
const nameMatch = newName.match(/^(\S+)(?:\s+\[([^\]]+)\])?\s+(.+)$/);
|
|
415
|
+
if (!nameMatch) {
|
|
416
|
+
const simpleParts = newName.split(/\s+/);
|
|
417
|
+
if (simpleParts.length >= 2) {
|
|
418
|
+
name.givenName = simpleParts[0];
|
|
419
|
+
name.familyName = simpleParts.slice(1).join(' ');
|
|
420
|
+
delete name.middleName;
|
|
421
|
+
changes.push(`${simpleParts[0]} ${simpleParts.slice(1).join(' ')}`);
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
const [, first, middle, last] = nameMatch;
|
|
425
|
+
name.givenName = first;
|
|
426
|
+
name.familyName = last;
|
|
427
|
+
if (middle) {
|
|
428
|
+
name.middleName = middle;
|
|
429
|
+
} else {
|
|
430
|
+
delete name.middleName;
|
|
431
|
+
}
|
|
432
|
+
changes.push(`${first}${middle ? ' ' + middle : ''} ${last}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (changes.length === 0) {
|
|
438
|
+
result.remaining.push(line);
|
|
439
|
+
result.skipped++;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
fs.writeFileSync(filePath, JSON.stringify(contact, null, 2));
|
|
444
|
+
if (verbose) console.log(` [FIX] ${file}: ${changes.join(', ')}`);
|
|
445
|
+
result.fixed++;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Rewrite problems.txt with only remaining unprocessed lines
|
|
449
|
+
if (result.deleted > 0 || result.fixed > 0 || result.companies > 0) {
|
|
450
|
+
const newContent = [...headerLines, ...result.remaining].join('\n');
|
|
451
|
+
fs.writeFileSync(problemsPath, newContent);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return result;
|
|
455
|
+
}
|