@bobfrankston/gcards 0.1.17 → 0.1.18
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 +31 -12
- package/gfix.ts +22 -179
- package/glib/gutils.ts +256 -0
- package/package.json +1 -1
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';
|
|
@@ -563,6 +563,12 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?:
|
|
|
563
563
|
console.log(`Using -since override: ${since}`);
|
|
564
564
|
}
|
|
565
565
|
|
|
566
|
+
// Process problems.txt first (applies deletions, fixes, etc.)
|
|
567
|
+
const problemsResult = processProblemsFile(paths, false);
|
|
568
|
+
if (problemsResult.deleted > 0 || problemsResult.fixed > 0 || problemsResult.companies > 0) {
|
|
569
|
+
console.log(`Processed problems.txt: ${problemsResult.deleted} deleted, ${problemsResult.fixed} fixed, ${problemsResult.companies} companies`);
|
|
570
|
+
}
|
|
571
|
+
|
|
566
572
|
// Check _delete.json for deletion requests
|
|
567
573
|
const deleteQueue = loadDeleteQueue(paths);
|
|
568
574
|
for (const entry of deleteQueue.entries) {
|
|
@@ -629,7 +635,7 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?:
|
|
|
629
635
|
const files = fs.readdirSync(paths.toDeleteDir).filter(f => f.endsWith('.json'));
|
|
630
636
|
for (const file of files) {
|
|
631
637
|
const filePath = path.join(paths.toDeleteDir, file);
|
|
632
|
-
let content: GooglePerson;
|
|
638
|
+
let content: GooglePerson & { _delete?: string | boolean };
|
|
633
639
|
try {
|
|
634
640
|
content = JSON.parse(await fp.readFile(filePath, 'utf-8'));
|
|
635
641
|
} catch (e: any) {
|
|
@@ -644,7 +650,8 @@ async function findPendingChanges(paths: UserPaths, logger: FileLogger, since?:
|
|
|
644
650
|
type: 'delete',
|
|
645
651
|
resourceName: content.resourceName,
|
|
646
652
|
displayName,
|
|
647
|
-
filePath
|
|
653
|
+
filePath,
|
|
654
|
+
_delete: content._delete === 'force' ? 'force' : undefined
|
|
648
655
|
});
|
|
649
656
|
}
|
|
650
657
|
}
|
|
@@ -975,21 +982,21 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
975
982
|
const contactFile = path.join(paths.contactsDir, `${change.resourceName.replace('people/', '')}.json`);
|
|
976
983
|
const fileToRead = fs.existsSync(contactFile) ? contactFile : change.filePath;
|
|
977
984
|
|
|
978
|
-
//
|
|
985
|
+
// Read contact to check photo/starred/force status
|
|
979
986
|
let contactHasPhoto = false;
|
|
980
987
|
let contactIsStarred = false;
|
|
988
|
+
let isForced = change._delete === 'force' || index.contacts[change.resourceName]?._delete === 'force';
|
|
981
989
|
if (fs.existsSync(fileToRead)) {
|
|
982
990
|
try {
|
|
983
991
|
const contact = JSON.parse(fs.readFileSync(fileToRead, 'utf-8')) as GooglePerson;
|
|
984
992
|
contactHasPhoto = hasNonDefaultPhoto(contact);
|
|
985
993
|
contactIsStarred = isStarred(contact);
|
|
994
|
+
if ((contact as any)._delete === 'force') isForced = true;
|
|
986
995
|
} catch { /* ignore read errors */ }
|
|
987
996
|
}
|
|
988
997
|
|
|
989
998
|
// Determine skip reason (photo takes precedence over starred)
|
|
990
|
-
// Use * prefix to indicate "skipped, not deleted"
|
|
991
999
|
// 'force' overrides photo/starred protection
|
|
992
|
-
const isForced = change._delete === 'force' || index.contacts[change.resourceName]?._delete === 'force';
|
|
993
1000
|
const skipReason = isForced ? null : (contactHasPhoto ? '*photo' : (contactIsStarred ? '*starred' : null));
|
|
994
1001
|
|
|
995
1002
|
if (skipReason) {
|
|
@@ -1002,7 +1009,8 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
1002
1009
|
});
|
|
1003
1010
|
if (skipReason === '*photo') skippedPhoto++;
|
|
1004
1011
|
else if (skipReason === '*starred') skippedStarred++;
|
|
1005
|
-
|
|
1012
|
+
const fileId = change.resourceName.replace('people/', '');
|
|
1013
|
+
console.log(`${ts()} [${String(processed).padStart(5)}/${total}] SKIPPED ${change.displayName} (${skipReason}) ${fileId}.json`);
|
|
1006
1014
|
|
|
1007
1015
|
// Update _delete to show skip reason
|
|
1008
1016
|
if (index.contacts[change.resourceName]) {
|
|
@@ -1114,25 +1122,34 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
1114
1122
|
existingProblems = fs.readFileSync(problemsPath, 'utf-8');
|
|
1115
1123
|
} else {
|
|
1116
1124
|
existingProblems = `# Edit this file then run: gfix problems -u ${user}
|
|
1117
|
-
# Format: filename
|
|
1125
|
+
# Format: filename ; name ; reason ; email
|
|
1118
1126
|
# To delete: add 'x' at start of line
|
|
1119
1127
|
# To mark as company: add 'c' at start of line (moves name to organization)
|
|
1120
1128
|
# To fix name: replace the name with "First Last" or "First [Middle] Last"
|
|
1129
|
+
# Fields: nickname: title: company: suffix: (quote values with spaces, "" to remove)
|
|
1121
1130
|
# To skip: leave unchanged
|
|
1131
|
+
# You can manually add entries to this file
|
|
1122
1132
|
#
|
|
1123
1133
|
`;
|
|
1124
1134
|
}
|
|
1125
1135
|
|
|
1126
|
-
// Add photo/starred entries
|
|
1136
|
+
// Add photo/starred entries with email
|
|
1127
1137
|
const newEntries = notDeleted.map(nd => {
|
|
1128
1138
|
const id = nd.resourceName.replace('people/', '');
|
|
1129
1139
|
const reason = nd.reason === '*photo' ? 'has-photo' : 'starred';
|
|
1130
|
-
|
|
1140
|
+
let email = '';
|
|
1141
|
+
if (nd.filePath && fs.existsSync(nd.filePath)) {
|
|
1142
|
+
try {
|
|
1143
|
+
const contact = JSON.parse(fs.readFileSync(nd.filePath, 'utf-8'));
|
|
1144
|
+
email = contact.emailAddresses?.[0]?.value || '';
|
|
1145
|
+
} catch { /* ignore */ }
|
|
1146
|
+
}
|
|
1147
|
+
return `${id}.json ; ${nd.displayName} ; ${reason} ; ${email}`;
|
|
1131
1148
|
});
|
|
1132
1149
|
|
|
1133
1150
|
// 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('
|
|
1151
|
+
const existingLines = new Set(existingProblems.split('\n').map(l => l.split(';')[0].trim()));
|
|
1152
|
+
const uniqueEntries = newEntries.filter(e => !existingLines.has(e.split(';')[0].trim()));
|
|
1136
1153
|
|
|
1137
1154
|
if (uniqueEntries.length > 0) {
|
|
1138
1155
|
fs.writeFileSync(problemsPath, existingProblems.trimEnd() + '\n' + uniqueEntries.join('\n') + '\n');
|
|
@@ -1160,6 +1177,8 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
1160
1177
|
if (etagErrors.length > 0) {
|
|
1161
1178
|
console.log(`\n${etagErrors.length} contact(s) had etag conflicts. Run 'gcards sync' to refresh, then retry push.`);
|
|
1162
1179
|
}
|
|
1180
|
+
|
|
1181
|
+
process.exit(0);
|
|
1163
1182
|
}
|
|
1164
1183
|
|
|
1165
1184
|
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
|
+
}
|