@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 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 => setTimeout(resolve, ms));
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
- 'edd', 'ed.d', 'ed.d.',
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
- const afterLower = afterComma.toLowerCase().replace(/\./g, '');
671
- if (NAME_SUFFIXES.has(afterLower) || NAME_SUFFIXES.has(afterLower.replace(/\s+/g, ''))) {
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 afterParts = afterComma.split(/\s+/);
707
+ const afterWords = afterComma.split(/\s+/);
678
708
 
679
- if (beforeParts.length === 1 && afterParts.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
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
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 or phone-as-name
863
+ // Handle contacts with no names -> DELETE
791
864
  if (!name || !name.givenName) {
792
- const email = contact.emailAddresses?.[0]?.value || '(no email)';
793
- emailOnly.push({ file, resourceName: contact.resourceName, email });
794
- console.log(` [EMAIL-ONLY] ${file}: ${email}`);
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(` [PHONE-ONLY] ${file}: "${name.givenName}"`);
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
- // Check displayName for corruption (repeated words like "Dr. Dr. Dr. Dr.")
811
- const displayName = name.displayName || name.givenName || 'Unknown';
812
- const displayWords = displayName.split(/\s+/);
813
- if (displayWords.length > 4) {
814
- const wordCounts = new Map<string, number>();
815
- for (const w of displayWords) {
816
- wordCounts.set(w.toLowerCase(), (wordCounts.get(w.toLowerCase()) || 0) + 1);
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
- const maxCount = Math.max(...wordCounts.values());
819
- if (maxCount > 3) {
820
- problems.push(`${file}: repeated words in displayName (${maxCount}x) - corrupted data`);
821
- console.log(` [CORRUPTED] ${displayName.slice(0, 60)}... (${file})`);
822
- continue;
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
- // Check for email addresses
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
- // Check for company names first
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}: "${name.givenName}" - ambiguous (could be "Last, First" or unknown suffix)`);
866
- console.log(` [AMBIGUOUS] ${displayName} (${file}): "${name.givenName}"`);
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}: "${name.givenName}" - no last name detected`);
874
- console.log(` [NO LAST NAME] ${displayName} (${file}): "${name.givenName}"`);
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
- fs.writeFileSync(problemsPath, problems.join('\n'));
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(` Email-only (no name): ${emailOnly.length}`);
1059
- console.log(` Phone-only (phone as name): ${phoneOnly.length}`);
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) console.log(` Email-only moved to _delete/ (recovery: emailonly.json)`);
1064
- if (phoneOnly.length > 0) console.log(` Phone-only moved to _delete/ (recovery: phoneonly.json)`);
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
- if ((arg === '-u' || arg === '--user' || arg === '-user') && i + 1 < args.length) {
1924
- user = args[++i];
1925
- } else if ((arg === '-to' || arg === '--to') && i + 1 < args.length) {
1926
- targetUser = args[++i];
1927
- } else if (arg === '--apply') {
1928
- applyFlag = true;
1929
- } else if ((arg === '-limit' || arg === '--limit') && i + 1 < args.length) {
1930
- processLimit = parseInt(args[++i], 10) || 0;
1931
- } else if (!arg.startsWith('-')) {
1932
- if (!command) {
1933
- command = arg;
1934
- } else {
1935
- positionalArgs.push(arg);
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
- return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
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
- return JSON.parse(fs.readFileSync(paths.indexFile, 'utf-8'));
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.15",
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
  }