@bobfrankston/gcards 0.1.15 → 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.
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';
@@ -752,6 +753,29 @@ function looksLikePhone(s: string): boolean {
752
753
  return digits.length >= 7 && digits.length <= 15 && /^[\d\s\-.()+]+$/.test(s);
753
754
  }
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
+
755
779
  /** Fix names: parse givenName into first/middle/last, clean fileAs, handle nameless contacts */
756
780
  async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<void> {
757
781
  const paths = getUserPaths(user);
@@ -773,6 +797,7 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
773
797
 
774
798
  const changeLogs: NameChangeLog[] = [];
775
799
  const problems: string[] = [];
800
+ const needsAction: { file: string; displayName: string; reason: string }[] = [];
776
801
  let contactsModified = 0;
777
802
 
778
803
  // Track nameless contacts for deletion
@@ -780,25 +805,51 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
780
805
  const phoneOnly: { file: string; resourceName: string; phone: string }[] = [];
781
806
  const emailOnlyFile = path.join(paths.userDir, 'emailonly.json');
782
807
  const phoneOnlyFile = path.join(paths.userDir, 'phoneonly.json');
808
+ const needsActionFile = path.join(paths.userDir, 'needsaction.txt');
783
809
 
784
810
  for (const file of files) {
785
811
  const filePath = path.join(paths.contactsDir, file);
786
- 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
+ }
787
820
 
788
821
  const name = contact.names?.[0];
789
822
 
790
- // Handle contacts with no names or phone-as-name
823
+ // Handle contacts with no names -> DELETE
791
824
  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}`);
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
+ }
795
837
  continue;
796
838
  }
797
839
 
798
- // Check if name is actually a phone number
840
+ // Check if name is actually a phone number -> DELETE
799
841
  if (looksLikePhone(name.givenName)) {
800
842
  phoneOnly.push({ file, resourceName: contact.resourceName, phone: name.givenName });
801
- console.log(` [PHONE-ONLY] ${file}: "${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})`);
802
853
  continue;
803
854
  }
804
855
 
@@ -807,47 +858,32 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
807
858
  let parsedFirst = '';
808
859
  let parsedLast = '';
809
860
 
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);
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;
817
868
  }
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;
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
+ }
823
880
  }
824
881
  }
825
882
 
826
883
  // If givenName exists but no familyName, parse it
827
884
  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
885
 
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
- }
886
+ // Note: email-in-name is now handled earlier as DELETE
851
887
 
852
888
  const parsed = parseFullName(name.givenName);
853
889
 
@@ -855,7 +891,7 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
855
891
  if (parsed.isCompany) {
856
892
  const displayName = name.displayName || name.givenName || 'Unknown';
857
893
  problems.push(`${file}: "${name.givenName}" - looks like a company name`);
858
- console.log(` [COMPANY] ${displayName} (${file}): "${name.givenName}"`);
894
+ console.log(` [SKIP:company] ${displayName} (${file})`);
859
895
  continue;
860
896
  }
861
897
 
@@ -863,7 +899,7 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
863
899
  if (parsed.ambiguous) {
864
900
  const displayName = name.displayName || name.givenName || 'Unknown';
865
901
  problems.push(`${file}: "${name.givenName}" - ambiguous (could be "Last, First" or unknown suffix)`);
866
- console.log(` [AMBIGUOUS] ${displayName} (${file}): "${name.givenName}"`);
902
+ console.log(` [SKIP:ambiguous] ${displayName} (${file})`);
867
903
  continue;
868
904
  }
869
905
 
@@ -871,7 +907,7 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
871
907
  if (!parsed.last) {
872
908
  const displayName = name.displayName || name.givenName || 'Unknown';
873
909
  problems.push(`${file}: "${name.givenName}" - no last name detected`);
874
- console.log(` [NO LAST NAME] ${displayName} (${file}): "${name.givenName}"`);
910
+ console.log(` [SKIP:no-last-name] ${displayName} (${file})`);
875
911
  continue;
876
912
  }
877
913
 
@@ -1023,6 +1059,13 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
1023
1059
  console.log(`\nProblems file: ${problemsPath}`);
1024
1060
  }
1025
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
+
1026
1069
  // Handle email-only and phone-only contacts
1027
1070
  if (mode === 'apply' && (emailOnly.length > 0 || phoneOnly.length > 0)) {
1028
1071
  if (!fs.existsSync(paths.toDeleteDir)) {
@@ -1055,13 +1098,14 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
1055
1098
  console.log(`\n${'='.repeat(50)}`);
1056
1099
  console.log(`Summary:`);
1057
1100
  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}`);
1101
+ console.log(` To delete (email/phone only): ${emailOnly.length + phoneOnly.length}`);
1102
+ console.log(` Needs manual action (see needsaction.txt): ${needsAction.length}`);
1060
1103
  console.log(` Ambiguous (see problems.txt): ${problems.length}`);
1061
1104
  if (mode === 'apply') {
1062
1105
  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)`);
1106
+ if (emailOnly.length > 0 || phoneOnly.length > 0) {
1107
+ console.log(` Moved to _delete/: ${emailOnly.length + phoneOnly.length} (recovery: emailonly.json, phoneonly.json)`);
1108
+ }
1065
1109
  }
1066
1110
  if (mode === 'inspect' && (changeLogs.length > 0 || emailOnly.length > 0 || phoneOnly.length > 0)) {
1067
1111
  console.log(`\nTo apply fixes, run: gfix names -u ${user} --apply`);
@@ -1917,26 +1961,42 @@ async function main(): Promise<void> {
1917
1961
  let targetUser = '';
1918
1962
  let applyFlag = false;
1919
1963
  const positionalArgs: string[] = [];
1964
+ const unrecognized: string[] = [];
1920
1965
 
1921
1966
  for (let i = 0; i < args.length; i++) {
1922
1967
  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
- }
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
+ }
1937
1991
  }
1938
1992
  }
1939
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
+
1940
2000
  if (!command) {
1941
2001
  showUsage();
1942
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.15",
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
  }