@bobfrankston/gcards 0.1.15 → 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 +56 -3
- package/gfix.ts +383 -76
- package/glib/gutils.ts +11 -2
- package/package.json +3 -2
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
|
@@ -79,6 +79,7 @@ function cleanupEscapeHandler(): void {
|
|
|
79
79
|
process.stdin.pause();
|
|
80
80
|
process.stdin.removeAllListeners('data');
|
|
81
81
|
}
|
|
82
|
+
process.stdin.unref(); // Allow Node to exit even if stdin is open
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
async function getAccessToken(user: string, writeAccess = false, forceRefresh = false): Promise<string> {
|
|
@@ -257,7 +258,14 @@ function saveSyncToken(paths: UserPaths, token: string): void {
|
|
|
257
258
|
}
|
|
258
259
|
|
|
259
260
|
async function sleep(ms: number): Promise<void> {
|
|
260
|
-
return new Promise(resolve =>
|
|
261
|
+
return new Promise(resolve => {
|
|
262
|
+
const timeout = setTimeout(resolve, ms);
|
|
263
|
+
// Allow escape to interrupt sleep
|
|
264
|
+
if (escapePressed) {
|
|
265
|
+
clearTimeout(timeout);
|
|
266
|
+
resolve();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
261
269
|
}
|
|
262
270
|
|
|
263
271
|
async function fetchContactsWithRetry(
|
|
@@ -1053,6 +1061,12 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
1053
1061
|
problems.push(errorMsg);
|
|
1054
1062
|
errorCount++;
|
|
1055
1063
|
|
|
1064
|
+
// Check for etag mismatch - needs resync
|
|
1065
|
+
if (error.message?.includes('FAILED_PRECONDITION') || error.message?.includes('etag')) {
|
|
1066
|
+
console.log(` → Contact changed on Google. Run 'gcards sync' then retry.`);
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1056
1070
|
// Get AI explanation if available and error is from API
|
|
1057
1071
|
if (error instanceof ApiError && error.payload) {
|
|
1058
1072
|
if (isAIAvailable()) {
|
|
@@ -1086,11 +1100,44 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
1086
1100
|
console.log(`\nErrors written to: ${problemsFile}`);
|
|
1087
1101
|
}
|
|
1088
1102
|
|
|
1089
|
-
// 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
|
|
1090
1105
|
if (notDeleted.length > 0) {
|
|
1091
1106
|
const notDeletedFile = path.join(paths.userDir, 'notdeleted.json');
|
|
1092
1107
|
fs.writeFileSync(notDeletedFile, JSON.stringify(notDeleted, null, 2));
|
|
1093
|
-
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
|
+
}
|
|
1094
1141
|
}
|
|
1095
1142
|
|
|
1096
1143
|
// Remove completed deletions from queue
|
|
@@ -1107,6 +1154,12 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
1107
1154
|
const skipped = skippedPhoto + skippedStarred;
|
|
1108
1155
|
const skippedDetails = skipped > 0 ? ` (${skippedPhoto} photo, ${skippedStarred} starred)` : '';
|
|
1109
1156
|
console.log(`\nPush complete: ${successCount} succeeded, ${errorCount} failed, ${skipped} skipped${skippedDetails}`);
|
|
1157
|
+
|
|
1158
|
+
// Check if any errors were etag related
|
|
1159
|
+
const etagErrors = problems.filter(p => p.includes('FAILED_PRECONDITION') || p.includes('etag'));
|
|
1160
|
+
if (etagErrors.length > 0) {
|
|
1161
|
+
console.log(`\n${etagErrors.length} contact(s) had etag conflicts. Run 'gcards sync' to refresh, then retry push.`);
|
|
1162
|
+
}
|
|
1110
1163
|
}
|
|
1111
1164
|
|
|
1112
1165
|
async function main(): Promise<void> {
|
package/gfix.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import fs from 'fs';
|
|
12
12
|
import path from 'path';
|
|
13
13
|
import readline from 'readline';
|
|
14
|
+
import { parse as parseJsonc } from 'jsonc-parser';
|
|
14
15
|
import type { GooglePerson, GoogleName, GooglePhoneNumber, GoogleEmailAddress, GoogleBirthday } from './glib/types.ts';
|
|
15
16
|
import type { DeleteQueue, DeleteQueueEntry, UserPaths } from './glib/gctypes.ts';
|
|
16
17
|
import { DATA_DIR, resolveUser, getUserPaths, loadIndex, getAllUsers, normalizeUser, matchUsers } from './glib/gutils.ts';
|
|
@@ -558,16 +559,36 @@ const HONORIFIC_PREFIXES = new Set([
|
|
|
558
559
|
const NAME_SUFFIXES = new Set([
|
|
559
560
|
// Generational
|
|
560
561
|
'jr', 'jr.', 'sr', 'sr.', 'i', 'ii', 'iii', 'iv', 'v',
|
|
561
|
-
// Academic
|
|
562
|
+
// Academic - Doctorates
|
|
562
563
|
'phd', 'ph.d', 'ph.d.', 'md', 'm.d', 'm.d.', 'do', 'd.o', 'd.o.',
|
|
563
564
|
'dds', 'd.d.s', 'd.d.s.', 'dmd', 'd.m.d', 'd.m.d.',
|
|
564
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
|
|
565
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
|
|
566
577
|
'ba', 'b.a', 'b.a.', 'bs', 'b.s', 'b.s.', 'bsc', 'b.sc', 'b.sc.',
|
|
567
|
-
'
|
|
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.',
|
|
568
587
|
// Professional certifications
|
|
569
588
|
'cpa', 'c.p.a', 'c.p.a.', 'cfa', 'c.f.a', 'c.f.a.',
|
|
570
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
|
|
571
592
|
'rn', 'r.n', 'r.n.', 'lpn', 'l.p.n', 'l.p.n.',
|
|
572
593
|
'cae', 'c.a.e', 'c.a.e.', 'pmp', 'p.m.p', 'p.m.p.',
|
|
573
594
|
'cissp', 'ccna', 'mcse', 'aws',
|
|
@@ -576,6 +597,12 @@ const NAME_SUFFIXES = new Set([
|
|
|
576
597
|
'sj', 's.j', 's.j.', 'op', 'o.p', 'o.p.', 'osb', 'o.s.b', 'o.s.b.',
|
|
577
598
|
]);
|
|
578
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
|
+
|
|
579
606
|
/** Convert ALL CAPS to Title Case, preserve mixed case */
|
|
580
607
|
function fixCase(name: string): string {
|
|
581
608
|
if (!name) return name;
|
|
@@ -666,17 +693,20 @@ function parseFullName(fullName: string): ParsedName {
|
|
|
666
693
|
const beforeComma = namePart.substring(0, commaIndex).trim();
|
|
667
694
|
const afterComma = namePart.substring(commaIndex + 1).trim();
|
|
668
695
|
|
|
669
|
-
// Check if after-comma part is a known suffix
|
|
670
|
-
|
|
671
|
-
|
|
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) {
|
|
672
702
|
suffix = afterComma;
|
|
673
703
|
namePart = beforeComma;
|
|
674
704
|
} else {
|
|
675
705
|
// Could be "Last, First" format or unknown suffix
|
|
676
706
|
const beforeParts = beforeComma.split(/\s+/);
|
|
677
|
-
const
|
|
707
|
+
const afterWords = afterComma.split(/\s+/);
|
|
678
708
|
|
|
679
|
-
if (beforeParts.length === 1 &&
|
|
709
|
+
if (beforeParts.length === 1 && afterWords.length >= 1) {
|
|
680
710
|
// Ambiguous: could be "Smith, John" or "John Smith, UnknownSuffix"
|
|
681
711
|
ambiguous = true;
|
|
682
712
|
}
|
|
@@ -710,6 +740,17 @@ function parseFullName(fullName: string): ParsedName {
|
|
|
710
740
|
}
|
|
711
741
|
}
|
|
712
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
|
+
|
|
713
754
|
// Now apply case normalization
|
|
714
755
|
const parts = rawParts.map(fixCase);
|
|
715
756
|
|
|
@@ -752,6 +793,29 @@ function looksLikePhone(s: string): boolean {
|
|
|
752
793
|
return digits.length >= 7 && digits.length <= 15 && /^[\d\s\-.()+]+$/.test(s);
|
|
753
794
|
}
|
|
754
795
|
|
|
796
|
+
/** Fix repeated words in a string - returns cleaned string or null if no fix needed */
|
|
797
|
+
function fixRepeatedWords(s: string): string | null {
|
|
798
|
+
const words = s.split(/\s+/);
|
|
799
|
+
if (words.length < 3) return null;
|
|
800
|
+
|
|
801
|
+
// Count consecutive repeated words
|
|
802
|
+
const seen = new Set<string>();
|
|
803
|
+
const result: string[] = [];
|
|
804
|
+
for (const word of words) {
|
|
805
|
+
const lower = word.toLowerCase().replace(/[.,]+$/, ''); // normalize
|
|
806
|
+
if (!seen.has(lower)) {
|
|
807
|
+
seen.add(lower);
|
|
808
|
+
result.push(word.replace(/[.]+$/, '')); // remove trailing dots
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Only return if we actually removed something
|
|
813
|
+
if (result.length < words.length) {
|
|
814
|
+
return result.join(' ').trim();
|
|
815
|
+
}
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
|
|
755
819
|
/** Fix names: parse givenName into first/middle/last, clean fileAs, handle nameless contacts */
|
|
756
820
|
async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<void> {
|
|
757
821
|
const paths = getUserPaths(user);
|
|
@@ -773,6 +837,7 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
|
|
|
773
837
|
|
|
774
838
|
const changeLogs: NameChangeLog[] = [];
|
|
775
839
|
const problems: string[] = [];
|
|
840
|
+
const needsAction: { file: string; displayName: string; reason: string }[] = [];
|
|
776
841
|
let contactsModified = 0;
|
|
777
842
|
|
|
778
843
|
// Track nameless contacts for deletion
|
|
@@ -780,25 +845,51 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
|
|
|
780
845
|
const phoneOnly: { file: string; resourceName: string; phone: string }[] = [];
|
|
781
846
|
const emailOnlyFile = path.join(paths.userDir, 'emailonly.json');
|
|
782
847
|
const phoneOnlyFile = path.join(paths.userDir, 'phoneonly.json');
|
|
848
|
+
const needsActionFile = path.join(paths.userDir, 'needsaction.txt');
|
|
783
849
|
|
|
784
850
|
for (const file of files) {
|
|
785
851
|
const filePath = path.join(paths.contactsDir, file);
|
|
786
|
-
|
|
852
|
+
let contact: GooglePerson;
|
|
853
|
+
try {
|
|
854
|
+
contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
|
|
855
|
+
} catch (e: any) {
|
|
856
|
+
needsAction.push({ file, displayName: '(parse error)', reason: e.message });
|
|
857
|
+
console.log(` [NEEDS-ACTION:parse-error] ${file}: ${e.message}`);
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
787
860
|
|
|
788
861
|
const name = contact.names?.[0];
|
|
789
862
|
|
|
790
|
-
// Handle contacts with no names
|
|
863
|
+
// Handle contacts with no names -> DELETE
|
|
791
864
|
if (!name || !name.givenName) {
|
|
792
|
-
const email = contact.emailAddresses?.[0]?.value
|
|
793
|
-
|
|
794
|
-
|
|
865
|
+
const email = contact.emailAddresses?.[0]?.value;
|
|
866
|
+
const phone = contact.phoneNumbers?.[0]?.value;
|
|
867
|
+
if (email) {
|
|
868
|
+
emailOnly.push({ file, resourceName: contact.resourceName, email });
|
|
869
|
+
console.log(` [DELETE:email-only] ${email} (${file})`);
|
|
870
|
+
} else if (phone) {
|
|
871
|
+
phoneOnly.push({ file, resourceName: contact.resourceName, phone });
|
|
872
|
+
console.log(` [DELETE:phone-only] ${phone} (${file})`);
|
|
873
|
+
} else {
|
|
874
|
+
emailOnly.push({ file, resourceName: contact.resourceName, email: '(empty contact)' });
|
|
875
|
+
console.log(` [DELETE:empty] no name/email/phone (${file})`);
|
|
876
|
+
}
|
|
795
877
|
continue;
|
|
796
878
|
}
|
|
797
879
|
|
|
798
|
-
// Check if name is actually a phone number
|
|
880
|
+
// Check if name is actually a phone number -> DELETE
|
|
799
881
|
if (looksLikePhone(name.givenName)) {
|
|
800
882
|
phoneOnly.push({ file, resourceName: contact.resourceName, phone: name.givenName });
|
|
801
|
-
console.log(` [
|
|
883
|
+
console.log(` [DELETE:phone-only] "${name.givenName}" (${file})`);
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Check if name is actually an email address (including malformed like "foo. bar@x. y. com") -> DELETE
|
|
888
|
+
const nameNormalized = name.givenName.replace(/\.\s+/g, '.').replace(/\s+\./g, '.');
|
|
889
|
+
if (nameNormalized.includes('@') && nameNormalized.match(/^[^\s]+@[^\s]+\.[^\s]+$/)) {
|
|
890
|
+
const cleanEmail = nameNormalized.toLowerCase();
|
|
891
|
+
emailOnly.push({ file, resourceName: contact.resourceName, email: cleanEmail });
|
|
892
|
+
console.log(` [DELETE:email-in-name] "${name.givenName}" -> ${cleanEmail} (${file})`);
|
|
802
893
|
continue;
|
|
803
894
|
}
|
|
804
895
|
|
|
@@ -807,71 +898,53 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
|
|
|
807
898
|
let parsedFirst = '';
|
|
808
899
|
let parsedLast = '';
|
|
809
900
|
|
|
810
|
-
//
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
901
|
+
// Fix repeated words in givenName (like "Brian Taraci Taraci Taraci...")
|
|
902
|
+
const fixedGivenName = fixRepeatedWords(name.givenName);
|
|
903
|
+
if (fixedGivenName) {
|
|
904
|
+
changes.push(`givenName: "${name.givenName.slice(0, 40)}..." -> "${fixedGivenName}" (removed repeated words)`);
|
|
905
|
+
if (mode === 'apply') {
|
|
906
|
+
name.givenName = fixedGivenName;
|
|
907
|
+
modified = true;
|
|
817
908
|
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Fix repeated words in familyName
|
|
912
|
+
if (name.familyName) {
|
|
913
|
+
const fixedFamilyName = fixRepeatedWords(name.familyName);
|
|
914
|
+
if (fixedFamilyName) {
|
|
915
|
+
changes.push(`familyName: "${name.familyName.slice(0, 40)}..." -> "${fixedFamilyName}" (removed repeated words)`);
|
|
916
|
+
if (mode === 'apply') {
|
|
917
|
+
name.familyName = fixedFamilyName;
|
|
918
|
+
modified = true;
|
|
919
|
+
}
|
|
823
920
|
}
|
|
824
921
|
}
|
|
825
922
|
|
|
826
923
|
// If givenName exists but no familyName, parse it
|
|
827
924
|
if (name.givenName && !name.familyName) {
|
|
828
|
-
// Check for repeated words (corrupted data like "Smith Smith Smith...")
|
|
829
|
-
const words = name.givenName.split(/\s+/);
|
|
830
|
-
if (words.length > 4) {
|
|
831
|
-
const wordCounts = new Map<string, number>();
|
|
832
|
-
for (const w of words) {
|
|
833
|
-
wordCounts.set(w.toLowerCase(), (wordCounts.get(w.toLowerCase()) || 0) + 1);
|
|
834
|
-
}
|
|
835
|
-
const maxCount = Math.max(...wordCounts.values());
|
|
836
|
-
if (maxCount > 3) {
|
|
837
|
-
const displayName = name.displayName || name.givenName.slice(0, 50) + '...' || 'Unknown';
|
|
838
|
-
problems.push(`${file}: repeated words (${maxCount}x) - corrupted data`);
|
|
839
|
-
console.log(` [CORRUPTED] ${displayName} (${file})`);
|
|
840
|
-
continue;
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
925
|
|
|
844
|
-
//
|
|
845
|
-
if (name.givenName.includes('@')) {
|
|
846
|
-
const displayName = name.displayName || name.givenName || 'Unknown';
|
|
847
|
-
problems.push(`${file}: "${name.givenName}" - email address, not a name`);
|
|
848
|
-
console.log(` [EMAIL] ${displayName} (${file}): "${name.givenName}"`);
|
|
849
|
-
continue;
|
|
850
|
-
}
|
|
926
|
+
// Note: email-in-name is now handled earlier as DELETE
|
|
851
927
|
|
|
852
928
|
const parsed = parseFullName(name.givenName);
|
|
853
929
|
|
|
854
|
-
//
|
|
930
|
+
// Company names are fine - skip without adding to problems
|
|
855
931
|
if (parsed.isCompany) {
|
|
856
|
-
const displayName = name.displayName || name.givenName || 'Unknown';
|
|
857
|
-
problems.push(`${file}: "${name.givenName}" - looks like a company name`);
|
|
858
|
-
console.log(` [COMPANY] ${displayName} (${file}): "${name.givenName}"`);
|
|
859
932
|
continue;
|
|
860
933
|
}
|
|
861
934
|
|
|
862
935
|
// Check for ambiguous cases
|
|
863
936
|
if (parsed.ambiguous) {
|
|
864
937
|
const displayName = name.displayName || name.givenName || 'Unknown';
|
|
865
|
-
problems.push(`${file}
|
|
866
|
-
console.log(` [
|
|
938
|
+
problems.push(`${file}\t${name.givenName}\tambiguous`);
|
|
939
|
+
console.log(` [SKIP:ambiguous] ${displayName} (${file})`);
|
|
867
940
|
continue;
|
|
868
941
|
}
|
|
869
942
|
|
|
870
943
|
// Check if no parseable last name
|
|
871
944
|
if (!parsed.last) {
|
|
872
945
|
const displayName = name.displayName || name.givenName || 'Unknown';
|
|
873
|
-
problems.push(`${file}
|
|
874
|
-
console.log(` [
|
|
946
|
+
problems.push(`${file}\t${name.givenName}\tno-last-name`);
|
|
947
|
+
console.log(` [SKIP:no-last-name] ${displayName} (${file})`);
|
|
875
948
|
continue;
|
|
876
949
|
}
|
|
877
950
|
|
|
@@ -1016,13 +1089,28 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
|
|
|
1016
1089
|
console.log(`\nLog file: ${logPath}`);
|
|
1017
1090
|
}
|
|
1018
1091
|
|
|
1019
|
-
// Write problems.txt for ambiguous cases
|
|
1092
|
+
// Write problems.txt for ambiguous cases (editable format)
|
|
1020
1093
|
if (problems.length > 0) {
|
|
1021
1094
|
const problemsPath = path.join(paths.userDir, 'problems.txt');
|
|
1022
|
-
|
|
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'));
|
|
1023
1104
|
console.log(`\nProblems file: ${problemsPath}`);
|
|
1024
1105
|
}
|
|
1025
1106
|
|
|
1107
|
+
// Write needsaction.txt for manual fixing
|
|
1108
|
+
if (needsAction.length > 0) {
|
|
1109
|
+
const content = needsAction.map(c => `${c.file}\t${c.displayName}\t${c.reason}`).join('\n');
|
|
1110
|
+
fs.writeFileSync(needsActionFile, content);
|
|
1111
|
+
console.log(`\nNeeds action file: ${needsActionFile}`);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1026
1114
|
// Handle email-only and phone-only contacts
|
|
1027
1115
|
if (mode === 'apply' && (emailOnly.length > 0 || phoneOnly.length > 0)) {
|
|
1028
1116
|
if (!fs.existsSync(paths.toDeleteDir)) {
|
|
@@ -1055,13 +1143,14 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
|
|
|
1055
1143
|
console.log(`\n${'='.repeat(50)}`);
|
|
1056
1144
|
console.log(`Summary:`);
|
|
1057
1145
|
console.log(` Contacts with changes: ${changeLogs.length}`);
|
|
1058
|
-
console.log(`
|
|
1059
|
-
console.log(`
|
|
1146
|
+
console.log(` To delete (email/phone only): ${emailOnly.length + phoneOnly.length}`);
|
|
1147
|
+
console.log(` Needs manual action (see needsaction.txt): ${needsAction.length}`);
|
|
1060
1148
|
console.log(` Ambiguous (see problems.txt): ${problems.length}`);
|
|
1061
1149
|
if (mode === 'apply') {
|
|
1062
1150
|
console.log(` Contacts modified: ${contactsModified}`);
|
|
1063
|
-
if (emailOnly.length > 0
|
|
1064
|
-
|
|
1151
|
+
if (emailOnly.length > 0 || phoneOnly.length > 0) {
|
|
1152
|
+
console.log(` Moved to _delete/: ${emailOnly.length + phoneOnly.length} (recovery: emailonly.json, phoneonly.json)`);
|
|
1153
|
+
}
|
|
1065
1154
|
}
|
|
1066
1155
|
if (mode === 'inspect' && (changeLogs.length > 0 || emailOnly.length > 0 || phoneOnly.length > 0)) {
|
|
1067
1156
|
console.log(`\nTo apply fixes, run: gfix names -u ${user} --apply`);
|
|
@@ -1071,6 +1160,201 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
|
|
|
1071
1160
|
}
|
|
1072
1161
|
}
|
|
1073
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
|
+
|
|
1074
1358
|
async function runFix(user: string, mode: 'inspect' | 'apply'): Promise<void> {
|
|
1075
1359
|
const paths = getUserPaths(user);
|
|
1076
1360
|
|
|
@@ -1852,6 +2136,7 @@ Commands:
|
|
|
1852
2136
|
birthday Extract birthdays to CSV and remove from contacts
|
|
1853
2137
|
fileas Normalize "Last, First" fileAs to "First Last"
|
|
1854
2138
|
names Parse givenName into first/middle/last, clean fileAs, remove dup phones/emails
|
|
2139
|
+
problems Process edited problems.txt (apply fixes or deletions)
|
|
1855
2140
|
undup Find duplicate contacts (same name + overlapping email) -> merger.json
|
|
1856
2141
|
merge Merge duplicates locally (then use gcards push)
|
|
1857
2142
|
reset Clear all _delete flags from index.json
|
|
@@ -1882,6 +2167,10 @@ Name parsing (when givenName has full name but no familyName):
|
|
|
1882
2167
|
- gfix names -u bob # Preview name splits
|
|
1883
2168
|
- gfix names -u bob --apply # Split names and create changes.log
|
|
1884
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
|
+
|
|
1885
2174
|
Duplicate detection and merge:
|
|
1886
2175
|
- gfix undup -u bob # Find duplicates -> merger.json
|
|
1887
2176
|
- Edit merger.json (remove false positives, add "_delete": true for spam)
|
|
@@ -1917,26 +2206,42 @@ async function main(): Promise<void> {
|
|
|
1917
2206
|
let targetUser = '';
|
|
1918
2207
|
let applyFlag = false;
|
|
1919
2208
|
const positionalArgs: string[] = [];
|
|
2209
|
+
const unrecognized: string[] = [];
|
|
1920
2210
|
|
|
1921
2211
|
for (let i = 0; i < args.length; i++) {
|
|
1922
2212
|
const arg = args[i];
|
|
1923
|
-
|
|
1924
|
-
user
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
2213
|
+
switch (arg) {
|
|
2214
|
+
case '-u': case '--user': case '-user':
|
|
2215
|
+
user = args[++i] || '';
|
|
2216
|
+
break;
|
|
2217
|
+
case '-to': case '--to':
|
|
2218
|
+
targetUser = args[++i] || '';
|
|
2219
|
+
break;
|
|
2220
|
+
case '--apply':
|
|
2221
|
+
applyFlag = true;
|
|
2222
|
+
break;
|
|
2223
|
+
case '-limit': case '--limit':
|
|
2224
|
+
processLimit = parseInt(args[++i], 10) || 0;
|
|
2225
|
+
break;
|
|
2226
|
+
default:
|
|
2227
|
+
if (!arg.startsWith('-')) {
|
|
2228
|
+
if (!command) {
|
|
2229
|
+
command = arg;
|
|
2230
|
+
} else {
|
|
2231
|
+
positionalArgs.push(arg);
|
|
2232
|
+
}
|
|
2233
|
+
} else {
|
|
2234
|
+
unrecognized.push(arg);
|
|
2235
|
+
}
|
|
1937
2236
|
}
|
|
1938
2237
|
}
|
|
1939
2238
|
|
|
2239
|
+
if (unrecognized.length > 0) {
|
|
2240
|
+
console.error(`Unrecognized argument(s): ${unrecognized.join(', ')}`);
|
|
2241
|
+
console.error(`Use 'gfix' for usage information.`);
|
|
2242
|
+
process.exit(1);
|
|
2243
|
+
}
|
|
2244
|
+
|
|
1940
2245
|
if (!command) {
|
|
1941
2246
|
showUsage();
|
|
1942
2247
|
return;
|
|
@@ -1982,6 +2287,8 @@ async function main(): Promise<void> {
|
|
|
1982
2287
|
await runMerge(resolvedUser, processLimit);
|
|
1983
2288
|
} else if (command === 'reset') {
|
|
1984
2289
|
await runReset(resolvedUser);
|
|
2290
|
+
} else if (command === 'problems') {
|
|
2291
|
+
await runProblems(resolvedUser);
|
|
1985
2292
|
} else {
|
|
1986
2293
|
console.error(`Unknown command: ${command}`);
|
|
1987
2294
|
showUsage();
|
package/glib/gutils.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import fs from 'fs';
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import os from 'os';
|
|
8
|
+
import { parse as parseJsonc } from 'jsonc-parser';
|
|
8
9
|
import type { GcardsConfig, ContactIndex, UserPaths } from './gctypes.ts';
|
|
9
10
|
|
|
10
11
|
export const APP_DIR = path.dirname(import.meta.dirname); // Parent of glib/
|
|
@@ -34,7 +35,11 @@ export const CREDENTIALS_FILE = path.join(APP_DIR, 'credentials.json');
|
|
|
34
35
|
|
|
35
36
|
export function loadConfig(): GcardsConfig {
|
|
36
37
|
if (fs.existsSync(CONFIG_FILE)) {
|
|
37
|
-
|
|
38
|
+
try {
|
|
39
|
+
return parseJsonc(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
|
40
|
+
} catch (e: any) {
|
|
41
|
+
throw new Error(`Failed to parse ${CONFIG_FILE}: ${e.message}`);
|
|
42
|
+
}
|
|
38
43
|
}
|
|
39
44
|
return {};
|
|
40
45
|
}
|
|
@@ -76,7 +81,11 @@ export function ensureUserDir(user: string): void {
|
|
|
76
81
|
|
|
77
82
|
export function loadIndex(paths: UserPaths): ContactIndex {
|
|
78
83
|
if (fs.existsSync(paths.indexFile)) {
|
|
79
|
-
|
|
84
|
+
try {
|
|
85
|
+
return parseJsonc(fs.readFileSync(paths.indexFile, 'utf-8'));
|
|
86
|
+
} catch (e: any) {
|
|
87
|
+
throw new Error(`Failed to parse ${paths.indexFile}: ${e.message}`);
|
|
88
|
+
}
|
|
80
89
|
}
|
|
81
90
|
return { contacts: {}, lastSync: '' };
|
|
82
91
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/gcards",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"description": "Google Contacts cleanup and management tool",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "gcards.ts",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"tsx": "^4.21.0"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@bobfrankston/miscassists": "file:../../../projects/NodeJS/miscassists"
|
|
35
|
+
"@bobfrankston/miscassists": "file:../../../projects/NodeJS/miscassists",
|
|
36
|
+
"jsonc-parser": "^3.3.1"
|
|
36
37
|
}
|
|
37
38
|
}
|