@bobfrankston/gcards 0.1.14 → 0.1.16

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.
@@ -22,7 +22,8 @@
22
22
  "Bash(npx tsx:*)",
23
23
  "WebFetch(domain:lh3.googleusercontent.com)",
24
24
  "Bash(curl:*)",
25
- "Bash(bun build:*)"
25
+ "Bash(bun build:*)",
26
+ "Bash(xargs -I {} sh -c 'echo \"\"=== {} ===\"\" && head -20 {}')"
26
27
  ],
27
28
  "deny": [],
28
29
  "ask": []
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()) {
@@ -1107,6 +1121,12 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
1107
1121
  const skipped = skippedPhoto + skippedStarred;
1108
1122
  const skippedDetails = skipped > 0 ? ` (${skippedPhoto} photo, ${skippedStarred} starred)` : '';
1109
1123
  console.log(`\nPush complete: ${successCount} succeeded, ${errorCount} failed, ${skipped} skipped${skippedDetails}`);
1124
+
1125
+ // Check if any errors were etag related
1126
+ const etagErrors = problems.filter(p => p.includes('FAILED_PRECONDITION') || p.includes('etag'));
1127
+ if (etagErrors.length > 0) {
1128
+ console.log(`\n${etagErrors.length} contact(s) had etag conflicts. Run 'gcards sync' to refresh, then retry push.`);
1129
+ }
1110
1130
  }
1111
1131
 
1112
1132
  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';
@@ -746,7 +747,36 @@ function parseFullName(fullName: string): ParsedName {
746
747
  };
747
748
  }
748
749
 
749
- /** Fix names: parse givenName into first/middle/last, clean fileAs */
750
+ /** Check if string looks like a phone number */
751
+ function looksLikePhone(s: string): boolean {
752
+ const digits = s.replace(/\D/g, '');
753
+ return digits.length >= 7 && digits.length <= 15 && /^[\d\s\-.()+]+$/.test(s);
754
+ }
755
+
756
+ /** Fix repeated words in a string - returns cleaned string or null if no fix needed */
757
+ function fixRepeatedWords(s: string): string | null {
758
+ const words = s.split(/\s+/);
759
+ if (words.length < 3) return null;
760
+
761
+ // Count consecutive repeated words
762
+ const seen = new Set<string>();
763
+ const result: string[] = [];
764
+ for (const word of words) {
765
+ const lower = word.toLowerCase().replace(/[.,]+$/, ''); // normalize
766
+ if (!seen.has(lower)) {
767
+ seen.add(lower);
768
+ result.push(word.replace(/[.]+$/, '')); // remove trailing dots
769
+ }
770
+ }
771
+
772
+ // Only return if we actually removed something
773
+ if (result.length < words.length) {
774
+ return result.join(' ').trim();
775
+ }
776
+ return null;
777
+ }
778
+
779
+ /** Fix names: parse givenName into first/middle/last, clean fileAs, handle nameless contacts */
750
780
  async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<void> {
751
781
  const paths = getUserPaths(user);
752
782
 
@@ -767,61 +797,93 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
767
797
 
768
798
  const changeLogs: NameChangeLog[] = [];
769
799
  const problems: string[] = [];
800
+ const needsAction: { file: string; displayName: string; reason: string }[] = [];
770
801
  let contactsModified = 0;
771
802
 
803
+ // Track nameless contacts for deletion
804
+ const emailOnly: { file: string; resourceName: string; email: string }[] = [];
805
+ const phoneOnly: { file: string; resourceName: string; phone: string }[] = [];
806
+ const emailOnlyFile = path.join(paths.userDir, 'emailonly.json');
807
+ const phoneOnlyFile = path.join(paths.userDir, 'phoneonly.json');
808
+ const needsActionFile = path.join(paths.userDir, 'needsaction.txt');
809
+
772
810
  for (const file of files) {
773
811
  const filePath = path.join(paths.contactsDir, file);
774
- const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
812
+ let contact: GooglePerson;
813
+ try {
814
+ contact = parseJsonc(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
815
+ } catch (e: any) {
816
+ needsAction.push({ file, displayName: '(parse error)', reason: e.message });
817
+ console.log(` [NEEDS-ACTION:parse-error] ${file}: ${e.message}`);
818
+ continue;
819
+ }
775
820
 
776
821
  const name = contact.names?.[0];
777
- if (!name) continue;
822
+
823
+ // Handle contacts with no names -> DELETE
824
+ if (!name || !name.givenName) {
825
+ const email = contact.emailAddresses?.[0]?.value;
826
+ const phone = contact.phoneNumbers?.[0]?.value;
827
+ if (email) {
828
+ emailOnly.push({ file, resourceName: contact.resourceName, email });
829
+ console.log(` [DELETE:email-only] ${email} (${file})`);
830
+ } else if (phone) {
831
+ phoneOnly.push({ file, resourceName: contact.resourceName, phone });
832
+ console.log(` [DELETE:phone-only] ${phone} (${file})`);
833
+ } else {
834
+ emailOnly.push({ file, resourceName: contact.resourceName, email: '(empty contact)' });
835
+ console.log(` [DELETE:empty] no name/email/phone (${file})`);
836
+ }
837
+ continue;
838
+ }
839
+
840
+ // Check if name is actually a phone number -> DELETE
841
+ if (looksLikePhone(name.givenName)) {
842
+ phoneOnly.push({ file, resourceName: contact.resourceName, phone: name.givenName });
843
+ console.log(` [DELETE:phone-only] "${name.givenName}" (${file})`);
844
+ continue;
845
+ }
846
+
847
+ // Check if name is actually an email address (including malformed like "foo. bar@x. y. com") -> DELETE
848
+ const nameNormalized = name.givenName.replace(/\.\s+/g, '.').replace(/\s+\./g, '.');
849
+ if (nameNormalized.includes('@') && nameNormalized.match(/^[^\s]+@[^\s]+\.[^\s]+$/)) {
850
+ const cleanEmail = nameNormalized.toLowerCase();
851
+ emailOnly.push({ file, resourceName: contact.resourceName, email: cleanEmail });
852
+ console.log(` [DELETE:email-in-name] "${name.givenName}" -> ${cleanEmail} (${file})`);
853
+ continue;
854
+ }
778
855
 
779
856
  const changes: string[] = [];
780
857
  let modified = false;
781
858
  let parsedFirst = '';
782
859
  let parsedLast = '';
783
860
 
784
- // Check displayName for corruption (repeated words like "Dr. Dr. Dr. Dr.")
785
- const displayName = name.displayName || name.givenName || 'Unknown';
786
- const displayWords = displayName.split(/\s+/);
787
- if (displayWords.length > 4) {
788
- const wordCounts = new Map<string, number>();
789
- for (const w of displayWords) {
790
- wordCounts.set(w.toLowerCase(), (wordCounts.get(w.toLowerCase()) || 0) + 1);
861
+ // Fix repeated words in givenName (like "Brian Taraci Taraci Taraci...")
862
+ const fixedGivenName = fixRepeatedWords(name.givenName);
863
+ if (fixedGivenName) {
864
+ changes.push(`givenName: "${name.givenName.slice(0, 40)}..." -> "${fixedGivenName}" (removed repeated words)`);
865
+ if (mode === 'apply') {
866
+ name.givenName = fixedGivenName;
867
+ modified = true;
791
868
  }
792
- const maxCount = Math.max(...wordCounts.values());
793
- if (maxCount > 3) {
794
- problems.push(`${file}: repeated words in displayName (${maxCount}x) - corrupted data`);
795
- console.log(` [CORRUPTED] ${displayName.slice(0, 60)}... (${file})`);
796
- continue;
869
+ }
870
+
871
+ // Fix repeated words in familyName
872
+ if (name.familyName) {
873
+ const fixedFamilyName = fixRepeatedWords(name.familyName);
874
+ if (fixedFamilyName) {
875
+ changes.push(`familyName: "${name.familyName.slice(0, 40)}..." -> "${fixedFamilyName}" (removed repeated words)`);
876
+ if (mode === 'apply') {
877
+ name.familyName = fixedFamilyName;
878
+ modified = true;
879
+ }
797
880
  }
798
881
  }
799
882
 
800
883
  // If givenName exists but no familyName, parse it
801
884
  if (name.givenName && !name.familyName) {
802
- // Check for repeated words (corrupted data like "Smith Smith Smith...")
803
- const words = name.givenName.split(/\s+/);
804
- if (words.length > 4) {
805
- const wordCounts = new Map<string, number>();
806
- for (const w of words) {
807
- wordCounts.set(w.toLowerCase(), (wordCounts.get(w.toLowerCase()) || 0) + 1);
808
- }
809
- const maxCount = Math.max(...wordCounts.values());
810
- if (maxCount > 3) {
811
- const displayName = name.displayName || name.givenName.slice(0, 50) + '...' || 'Unknown';
812
- problems.push(`${file}: repeated words (${maxCount}x) - corrupted data`);
813
- console.log(` [CORRUPTED] ${displayName} (${file})`);
814
- continue;
815
- }
816
- }
817
885
 
818
- // Check for email addresses
819
- if (name.givenName.includes('@')) {
820
- const displayName = name.displayName || name.givenName || 'Unknown';
821
- problems.push(`${file}: "${name.givenName}" - email address, not a name`);
822
- console.log(` [EMAIL] ${displayName} (${file}): "${name.givenName}"`);
823
- continue;
824
- }
886
+ // Note: email-in-name is now handled earlier as DELETE
825
887
 
826
888
  const parsed = parseFullName(name.givenName);
827
889
 
@@ -829,7 +891,7 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
829
891
  if (parsed.isCompany) {
830
892
  const displayName = name.displayName || name.givenName || 'Unknown';
831
893
  problems.push(`${file}: "${name.givenName}" - looks like a company name`);
832
- console.log(` [COMPANY] ${displayName} (${file}): "${name.givenName}"`);
894
+ console.log(` [SKIP:company] ${displayName} (${file})`);
833
895
  continue;
834
896
  }
835
897
 
@@ -837,7 +899,7 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
837
899
  if (parsed.ambiguous) {
838
900
  const displayName = name.displayName || name.givenName || 'Unknown';
839
901
  problems.push(`${file}: "${name.givenName}" - ambiguous (could be "Last, First" or unknown suffix)`);
840
- console.log(` [AMBIGUOUS] ${displayName} (${file}): "${name.givenName}"`);
902
+ console.log(` [SKIP:ambiguous] ${displayName} (${file})`);
841
903
  continue;
842
904
  }
843
905
 
@@ -845,7 +907,7 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
845
907
  if (!parsed.last) {
846
908
  const displayName = name.displayName || name.givenName || 'Unknown';
847
909
  problems.push(`${file}: "${name.givenName}" - no last name detected`);
848
- console.log(` [NO LAST NAME] ${displayName} (${file}): "${name.givenName}"`);
910
+ console.log(` [SKIP:no-last-name] ${displayName} (${file})`);
849
911
  continue;
850
912
  }
851
913
 
@@ -997,17 +1059,58 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
997
1059
  console.log(`\nProblems file: ${problemsPath}`);
998
1060
  }
999
1061
 
1062
+ // Write needsaction.txt for manual fixing
1063
+ if (needsAction.length > 0) {
1064
+ const content = needsAction.map(c => `${c.file}\t${c.displayName}\t${c.reason}`).join('\n');
1065
+ fs.writeFileSync(needsActionFile, content);
1066
+ console.log(`\nNeeds action file: ${needsActionFile}`);
1067
+ }
1068
+
1069
+ // Handle email-only and phone-only contacts
1070
+ if (mode === 'apply' && (emailOnly.length > 0 || phoneOnly.length > 0)) {
1071
+ if (!fs.existsSync(paths.toDeleteDir)) {
1072
+ fs.mkdirSync(paths.toDeleteDir, { recursive: true });
1073
+ }
1074
+
1075
+ if (emailOnly.length > 0) {
1076
+ for (const entry of emailOnly) {
1077
+ const src = path.join(paths.contactsDir, entry.file);
1078
+ const dst = path.join(paths.toDeleteDir, entry.file);
1079
+ fs.renameSync(src, dst);
1080
+ }
1081
+ const existing = fs.existsSync(emailOnlyFile) ? JSON.parse(fs.readFileSync(emailOnlyFile, 'utf-8')) : [];
1082
+ existing.push(...emailOnly.map(e => ({ file: e.file, resourceName: e.resourceName, email: e.email, movedAt: new Date().toISOString() })));
1083
+ fs.writeFileSync(emailOnlyFile, JSON.stringify(existing, null, 2));
1084
+ }
1085
+
1086
+ if (phoneOnly.length > 0) {
1087
+ for (const entry of phoneOnly) {
1088
+ const src = path.join(paths.contactsDir, entry.file);
1089
+ const dst = path.join(paths.toDeleteDir, entry.file);
1090
+ fs.renameSync(src, dst);
1091
+ }
1092
+ const existing = fs.existsSync(phoneOnlyFile) ? JSON.parse(fs.readFileSync(phoneOnlyFile, 'utf-8')) : [];
1093
+ existing.push(...phoneOnly.map(e => ({ file: e.file, resourceName: e.resourceName, phone: e.phone, movedAt: new Date().toISOString() })));
1094
+ fs.writeFileSync(phoneOnlyFile, JSON.stringify(existing, null, 2));
1095
+ }
1096
+ }
1097
+
1000
1098
  console.log(`\n${'='.repeat(50)}`);
1001
1099
  console.log(`Summary:`);
1002
1100
  console.log(` Contacts with changes: ${changeLogs.length}`);
1101
+ console.log(` To delete (email/phone only): ${emailOnly.length + phoneOnly.length}`);
1102
+ console.log(` Needs manual action (see needsaction.txt): ${needsAction.length}`);
1003
1103
  console.log(` Ambiguous (see problems.txt): ${problems.length}`);
1004
1104
  if (mode === 'apply') {
1005
1105
  console.log(` Contacts modified: ${contactsModified}`);
1106
+ if (emailOnly.length > 0 || phoneOnly.length > 0) {
1107
+ console.log(` Moved to _delete/: ${emailOnly.length + phoneOnly.length} (recovery: emailonly.json, phoneonly.json)`);
1108
+ }
1006
1109
  }
1007
- if (mode === 'inspect' && changeLogs.length > 0) {
1110
+ if (mode === 'inspect' && (changeLogs.length > 0 || emailOnly.length > 0 || phoneOnly.length > 0)) {
1008
1111
  console.log(`\nTo apply fixes, run: gfix names -u ${user} --apply`);
1009
1112
  }
1010
- if (mode === 'apply' && contactsModified > 0) {
1113
+ if (mode === 'apply' && (contactsModified > 0 || emailOnly.length > 0 || phoneOnly.length > 0)) {
1011
1114
  console.log(`\nAfter review, run: gcards push -u ${user}`);
1012
1115
  }
1013
1116
  }
@@ -1858,26 +1961,42 @@ async function main(): Promise<void> {
1858
1961
  let targetUser = '';
1859
1962
  let applyFlag = false;
1860
1963
  const positionalArgs: string[] = [];
1964
+ const unrecognized: string[] = [];
1861
1965
 
1862
1966
  for (let i = 0; i < args.length; i++) {
1863
1967
  const arg = args[i];
1864
- if ((arg === '-u' || arg === '--user' || arg === '-user') && i + 1 < args.length) {
1865
- user = args[++i];
1866
- } else if ((arg === '-to' || arg === '--to') && i + 1 < args.length) {
1867
- targetUser = args[++i];
1868
- } else if (arg === '--apply') {
1869
- applyFlag = true;
1870
- } else if ((arg === '-limit' || arg === '--limit') && i + 1 < args.length) {
1871
- processLimit = parseInt(args[++i], 10) || 0;
1872
- } else if (!arg.startsWith('-')) {
1873
- if (!command) {
1874
- command = arg;
1875
- } else {
1876
- positionalArgs.push(arg);
1877
- }
1968
+ switch (arg) {
1969
+ case '-u': case '--user': case '-user':
1970
+ user = args[++i] || '';
1971
+ break;
1972
+ case '-to': case '--to':
1973
+ targetUser = args[++i] || '';
1974
+ break;
1975
+ case '--apply':
1976
+ applyFlag = true;
1977
+ break;
1978
+ case '-limit': case '--limit':
1979
+ processLimit = parseInt(args[++i], 10) || 0;
1980
+ break;
1981
+ default:
1982
+ if (!arg.startsWith('-')) {
1983
+ if (!command) {
1984
+ command = arg;
1985
+ } else {
1986
+ positionalArgs.push(arg);
1987
+ }
1988
+ } else {
1989
+ unrecognized.push(arg);
1990
+ }
1878
1991
  }
1879
1992
  }
1880
1993
 
1994
+ if (unrecognized.length > 0) {
1995
+ console.error(`Unrecognized argument(s): ${unrecognized.join(', ')}`);
1996
+ console.error(`Use 'gfix' for usage information.`);
1997
+ process.exit(1);
1998
+ }
1999
+
1881
2000
  if (!command) {
1882
2001
  showUsage();
1883
2002
  return;
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.14",
3
+ "version": "0.1.16",
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
  }