@bobfrankston/gcards 0.1.16 → 0.1.17

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