@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 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
- // Check if contact has a real photo or is starred - skip deletion if so
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
- console.log(`${ts()} [${String(processed).padStart(5)}/${total}] SKIPPED ${change.displayName} (${skipReason})`);
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
- '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.',
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
- const afterLower = afterComma.toLowerCase().replace(/\./g, '');
672
- 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) {
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 afterParts = afterComma.split(/\s+/);
707
+ const afterWords = afterComma.split(/\s+/);
679
708
 
680
- if (beforeParts.length === 1 && afterParts.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
- // Check for company names first
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
- problems.push(`${file}: "${name.givenName}" - ambiguous (could be "Last, First" or unknown suffix)`);
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
- problems.push(`${file}: "${name.givenName}" - no last name detected`);
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
- fs.writeFileSync(problemsPath, problems.join('\n'));
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/gcards",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Google Contacts cleanup and management tool",
5
5
  "type": "module",
6
6
  "main": "gcards.ts",