@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 +21 -1
- package/gfix.ts +122 -62
- package/glib/gutils.ts +11 -2
- package/package.json +3 -2
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 =>
|
|
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
|
-
|
|
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
|
|
823
|
+
// Handle contacts with no names -> DELETE
|
|
791
824
|
if (!name || !name.givenName) {
|
|
792
|
-
const email = contact.emailAddresses?.[0]?.value
|
|
793
|
-
|
|
794
|
-
|
|
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(` [
|
|
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
|
-
//
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
//
|
|
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(` [
|
|
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(` [
|
|
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(` [
|
|
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(`
|
|
1059
|
-
console.log(`
|
|
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
|
|
1064
|
-
|
|
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
|
-
|
|
1924
|
-
user
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|