@bobfrankston/gcards 0.1.17 → 0.1.19
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.code-workspace +33 -0
- package/gcards.ts +42 -14
- package/gfix.ts +22 -179
- package/glib/gutils.ts +256 -0
- package/package.json +1 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"folders": [
|
|
3
|
+
{
|
|
4
|
+
"path": "."
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
"path": "../../../projects/OAuth/OauthSupport"
|
|
8
|
+
}
|
|
9
|
+
],
|
|
10
|
+
"settings": {
|
|
11
|
+
"workbench.colorTheme": "Visual Studio 2019 Dark",
|
|
12
|
+
"workbench.colorCustomizations": {
|
|
13
|
+
"activityBar.activeBackground": "#0ae04a",
|
|
14
|
+
"activityBar.background": "#0ae04a",
|
|
15
|
+
"activityBar.foreground": "#15202b",
|
|
16
|
+
"activityBar.inactiveForeground": "#15202b99",
|
|
17
|
+
"activityBarBadge.background": "#9266f8",
|
|
18
|
+
"activityBarBadge.foreground": "#15202b",
|
|
19
|
+
"commandCenter.border": "#e7e7e799",
|
|
20
|
+
"sash.hoverBorder": "#0ae04a",
|
|
21
|
+
"statusBar.background": "#08af3a",
|
|
22
|
+
"statusBar.foreground": "#e7e7e7",
|
|
23
|
+
"statusBarItem.hoverBackground": "#0ae04a",
|
|
24
|
+
"statusBarItem.remoteBackground": "#08af3a",
|
|
25
|
+
"statusBarItem.remoteForeground": "#e7e7e7",
|
|
26
|
+
"titleBar.activeBackground": "#08af3a",
|
|
27
|
+
"titleBar.activeForeground": "#e7e7e7",
|
|
28
|
+
"titleBar.inactiveBackground": "#08af3a99",
|
|
29
|
+
"titleBar.inactiveForeground": "#e7e7e799"
|
|
30
|
+
},
|
|
31
|
+
"peacock.color": "#08af3a"
|
|
32
|
+
}
|
|
33
|
+
}
|
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';
|
|
@@ -297,8 +297,17 @@ async function fetchContactsWithRetry(
|
|
|
297
297
|
});
|
|
298
298
|
|
|
299
299
|
if (response.status === 410) {
|
|
300
|
-
console.log('Sync token expired, performing full sync...');
|
|
301
|
-
return fetchContactsWithRetry(accessToken, undefined,
|
|
300
|
+
console.log('Sync token expired (410), performing full sync...');
|
|
301
|
+
return fetchContactsWithRetry(accessToken, undefined, undefined, 0);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (response.status === 400) {
|
|
305
|
+
const text = await response.text();
|
|
306
|
+
if (text.includes('EXPIRED_SYNC_TOKEN')) {
|
|
307
|
+
console.log('Sync token expired (400), performing full sync...');
|
|
308
|
+
return fetchContactsWithRetry(accessToken, undefined, undefined, 0);
|
|
309
|
+
}
|
|
310
|
+
throw new Error(`API error: ${response.status} ${text}`);
|
|
302
311
|
}
|
|
303
312
|
|
|
304
313
|
if (response.status === 429) {
|
|
@@ -563,6 +572,12 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?:
|
|
|
563
572
|
console.log(`Using -since override: ${since}`);
|
|
564
573
|
}
|
|
565
574
|
|
|
575
|
+
// Process problems.txt first (applies deletions, fixes, etc.)
|
|
576
|
+
const problemsResult = processProblemsFile(paths, false);
|
|
577
|
+
if (problemsResult.deleted > 0 || problemsResult.fixed > 0 || problemsResult.companies > 0) {
|
|
578
|
+
console.log(`Processed problems.txt: ${problemsResult.deleted} deleted, ${problemsResult.fixed} fixed, ${problemsResult.companies} companies`);
|
|
579
|
+
}
|
|
580
|
+
|
|
566
581
|
// Check _delete.json for deletion requests
|
|
567
582
|
const deleteQueue = loadDeleteQueue(paths);
|
|
568
583
|
for (const entry of deleteQueue.entries) {
|
|
@@ -629,7 +644,7 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?:
|
|
|
629
644
|
const files = fs.readdirSync(paths.toDeleteDir).filter(f => f.endsWith('.json'));
|
|
630
645
|
for (const file of files) {
|
|
631
646
|
const filePath = path.join(paths.toDeleteDir, file);
|
|
632
|
-
let content: GooglePerson;
|
|
647
|
+
let content: GooglePerson & { _delete?: string | boolean };
|
|
633
648
|
try {
|
|
634
649
|
content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
|
|
635
650
|
} catch (e: any) {
|
|
@@ -644,7 +659,8 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?:
|
|
|
644
659
|
type: 'delete',
|
|
645
660
|
resourceName: content.resourceName,
|
|
646
661
|
displayName,
|
|
647
|
-
filePath
|
|
662
|
+
filePath,
|
|
663
|
+
_delete: content._delete === 'force' ? 'force' : undefined
|
|
648
664
|
});
|
|
649
665
|
}
|
|
650
666
|
}
|
|
@@ -975,21 +991,21 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
975
991
|
const contactFile = path.join(paths.contactsDir, `${change.resourceName.replace('people/', '')}.json`);
|
|
976
992
|
const fileToRead = fs.existsSync(contactFile) ? contactFile : change.filePath;
|
|
977
993
|
|
|
978
|
-
//
|
|
994
|
+
// Read contact to check photo/starred/force status
|
|
979
995
|
let contactHasPhoto = false;
|
|
980
996
|
let contactIsStarred = false;
|
|
997
|
+
let isForced = change._delete === 'force' || index.contacts[change.resourceName]?._delete === 'force';
|
|
981
998
|
if (fs.existsSync(fileToRead)) {
|
|
982
999
|
try {
|
|
983
1000
|
const contact = JSON.parse(fs.readFileSync(fileToRead, 'utf-8')) as GooglePerson;
|
|
984
1001
|
contactHasPhoto = hasNonDefaultPhoto(contact);
|
|
985
1002
|
contactIsStarred = isStarred(contact);
|
|
1003
|
+
if ((contact as any)._delete === 'force') isForced = true;
|
|
986
1004
|
} catch { /* ignore read errors */ }
|
|
987
1005
|
}
|
|
988
1006
|
|
|
989
1007
|
// Determine skip reason (photo takes precedence over starred)
|
|
990
|
-
// Use * prefix to indicate "skipped, not deleted"
|
|
991
1008
|
// 'force' overrides photo/starred protection
|
|
992
|
-
const isForced = change._delete === 'force' || index.contacts[change.resourceName]?._delete === 'force';
|
|
993
1009
|
const skipReason = isForced ? null : (contactHasPhoto ? '*photo' : (contactIsStarred ? '*starred' : null));
|
|
994
1010
|
|
|
995
1011
|
if (skipReason) {
|
|
@@ -1002,7 +1018,8 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
1002
1018
|
});
|
|
1003
1019
|
if (skipReason === '*photo') skippedPhoto++;
|
|
1004
1020
|
else if (skipReason === '*starred') skippedStarred++;
|
|
1005
|
-
|
|
1021
|
+
const fileId = change.resourceName.replace('people/', '');
|
|
1022
|
+
console.log(`${ts()} [${String(processed).padStart(5)}/${total}] SKIPPED ${change.displayName} (${skipReason}) ${fileId}.json`);
|
|
1006
1023
|
|
|
1007
1024
|
// Update _delete to show skip reason
|
|
1008
1025
|
if (index.contacts[change.resourceName]) {
|
|
@@ -1114,25 +1131,34 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
1114
1131
|
existingProblems = fs.readFileSync(problemsPath, 'utf-8');
|
|
1115
1132
|
} else {
|
|
1116
1133
|
existingProblems = `# Edit this file then run: gfix problems -u ${user}
|
|
1117
|
-
# Format: filename
|
|
1134
|
+
# Format: filename ; name ; reason ; email
|
|
1118
1135
|
# To delete: add 'x' at start of line
|
|
1119
1136
|
# To mark as company: add 'c' at start of line (moves name to organization)
|
|
1120
1137
|
# To fix name: replace the name with "First Last" or "First [Middle] Last"
|
|
1138
|
+
# Fields: nickname: title: company: suffix: (quote values with spaces, "" to remove)
|
|
1121
1139
|
# To skip: leave unchanged
|
|
1140
|
+
# You can manually add entries to this file
|
|
1122
1141
|
#
|
|
1123
1142
|
`;
|
|
1124
1143
|
}
|
|
1125
1144
|
|
|
1126
|
-
// Add photo/starred entries
|
|
1145
|
+
// Add photo/starred entries with email
|
|
1127
1146
|
const newEntries = notDeleted.map(nd => {
|
|
1128
1147
|
const id = nd.resourceName.replace('people/', '');
|
|
1129
1148
|
const reason = nd.reason === '*photo' ? 'has-photo' : 'starred';
|
|
1130
|
-
|
|
1149
|
+
let email = '';
|
|
1150
|
+
if (nd.filePath && fs.existsSync(nd.filePath)) {
|
|
1151
|
+
try {
|
|
1152
|
+
const contact = JSON.parse(fs.readFileSync(nd.filePath, 'utf-8'));
|
|
1153
|
+
email = contact.emailAddresses?.[0]?.value || '';
|
|
1154
|
+
} catch { /* ignore */ }
|
|
1155
|
+
}
|
|
1156
|
+
return `${id}.json ; ${nd.displayName} ; ${reason} ; ${email}`;
|
|
1131
1157
|
});
|
|
1132
1158
|
|
|
1133
1159
|
// Avoid duplicates - check if entry already exists
|
|
1134
|
-
const existingLines = new Set(existingProblems.split('\n').map(l => l.split('
|
|
1135
|
-
const uniqueEntries = newEntries.filter(e => !existingLines.has(e.split('
|
|
1160
|
+
const existingLines = new Set(existingProblems.split('\n').map(l => l.split(';')[0].trim()));
|
|
1161
|
+
const uniqueEntries = newEntries.filter(e => !existingLines.has(e.split(';')[0].trim()));
|
|
1136
1162
|
|
|
1137
1163
|
if (uniqueEntries.length > 0) {
|
|
1138
1164
|
fs.writeFileSync(problemsPath, existingProblems.trimEnd() + '\n' + uniqueEntries.join('\n') + '\n');
|
|
@@ -1160,6 +1186,8 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
1160
1186
|
if (etagErrors.length > 0) {
|
|
1161
1187
|
console.log(`\n${etagErrors.length} contact(s) had etag conflicts. Run 'gcards sync' to refresh, then retry push.`);
|
|
1162
1188
|
}
|
|
1189
|
+
|
|
1190
|
+
process.exit(0);
|
|
1163
1191
|
}
|
|
1164
1192
|
|
|
1165
1193
|
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 {
|
|
@@ -925,6 +925,11 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
|
|
|
925
925
|
|
|
926
926
|
// Note: email-in-name is now handled earlier as DELETE
|
|
927
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
|
+
|
|
928
933
|
const parsed = parseFullName(name.givenName);
|
|
929
934
|
|
|
930
935
|
// Company names are fine - skip without adding to problems
|
|
@@ -935,7 +940,8 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
|
|
|
935
940
|
// Check for ambiguous cases
|
|
936
941
|
if (parsed.ambiguous) {
|
|
937
942
|
const displayName = name.displayName || name.givenName || 'Unknown';
|
|
938
|
-
|
|
943
|
+
const email = contact.emailAddresses?.[0]?.value || '';
|
|
944
|
+
problems.push(`${file} ; ${name.givenName} ; ambiguous ; ${email}`);
|
|
939
945
|
console.log(` [SKIP:ambiguous] ${displayName} (${file})`);
|
|
940
946
|
continue;
|
|
941
947
|
}
|
|
@@ -943,7 +949,8 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
|
|
|
943
949
|
// Check if no parseable last name
|
|
944
950
|
if (!parsed.last) {
|
|
945
951
|
const displayName = name.displayName || name.givenName || 'Unknown';
|
|
946
|
-
|
|
952
|
+
const email = contact.emailAddresses?.[0]?.value || '';
|
|
953
|
+
problems.push(`${file} ; ${name.givenName} ; no-last-name ; ${email}`);
|
|
947
954
|
console.log(` [SKIP:no-last-name] ${displayName} (${file})`);
|
|
948
955
|
continue;
|
|
949
956
|
}
|
|
@@ -1093,11 +1100,13 @@ async function runNamesFix(user: string, mode: 'inspect' | 'apply'): Promise<voi
|
|
|
1093
1100
|
if (problems.length > 0) {
|
|
1094
1101
|
const problemsPath = path.join(paths.userDir, 'problems.txt');
|
|
1095
1102
|
const header = `# Edit this file then run: gfix problems -u ${user}
|
|
1096
|
-
# Format: filename
|
|
1103
|
+
# Format: filename ; name ; reason ; email
|
|
1097
1104
|
# To delete: add 'x' at start of line
|
|
1098
1105
|
# To mark as company: add 'c' at start of line (moves name to organization)
|
|
1099
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)
|
|
1100
1108
|
# To skip: leave unchanged
|
|
1109
|
+
# You can manually add entries to this file
|
|
1101
1110
|
#
|
|
1102
1111
|
`;
|
|
1103
1112
|
fs.writeFileSync(problemsPath, header + problems.join('\n'));
|
|
@@ -1171,188 +1180,22 @@ async function runProblems(user: string): Promise<void> {
|
|
|
1171
1180
|
process.exit(1);
|
|
1172
1181
|
}
|
|
1173
1182
|
|
|
1174
|
-
|
|
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
|
-
}
|
|
1183
|
+
console.log(`\nProcessing problems.txt for user: ${user}\n`);
|
|
1214
1184
|
|
|
1215
|
-
|
|
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
|
-
}
|
|
1185
|
+
const result = processProblemsFile(paths);
|
|
1345
1186
|
|
|
1346
1187
|
console.log(`\n${'='.repeat(50)}`);
|
|
1347
1188
|
console.log(`Summary:`);
|
|
1348
|
-
console.log(` Fixed: ${fixed}`);
|
|
1349
|
-
console.log(` Companies: ${companies}`);
|
|
1350
|
-
console.log(` Deleted: ${deleted}`);
|
|
1351
|
-
console.log(` Skipped: ${skipped}`);
|
|
1189
|
+
console.log(` Fixed: ${result.fixed}`);
|
|
1190
|
+
console.log(` Companies: ${result.companies}`);
|
|
1191
|
+
console.log(` Deleted: ${result.deleted}`);
|
|
1192
|
+
console.log(` Skipped: ${result.skipped}`);
|
|
1352
1193
|
|
|
1353
|
-
if (fixed > 0 || deleted > 0 || companies > 0) {
|
|
1194
|
+
if (result.fixed > 0 || result.deleted > 0 || result.companies > 0) {
|
|
1354
1195
|
console.log(`\nRun 'gcards push -u ${user}' to apply changes to Google.`);
|
|
1355
1196
|
}
|
|
1197
|
+
|
|
1198
|
+
process.exit(0);
|
|
1356
1199
|
}
|
|
1357
1200
|
|
|
1358
1201
|
async function runFix(user: string, mode: 'inspect' | 'apply'): Promise<void> {
|
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
|
+
}
|