@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.
Files changed (4) hide show
  1. package/gcards.ts +31 -12
  2. package/gfix.ts +22 -179
  3. package/glib/gutils.ts +256 -0
  4. 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
- // Check if contact has a real photo or is starred - skip deletion if so
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
- console.log(`${ts()} [${String(processed).padStart(5)}/${total}] SKIPPED ${change.displayName} (${skipReason})`);
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<TAB>name<TAB>reason
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
- return `${id}.json\t${nd.displayName}\t${reason}`;
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('\t')[0]));
1135
- const uniqueEntries = newEntries.filter(e => !existingLines.has(e.split('\t')[0]));
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
- problems.push(`${file}\t${name.givenName}\tambiguous`);
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
- problems.push(`${file}\t${name.givenName}\tno-last-name`);
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<TAB>name<TAB>reason
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
- const content = fs.readFileSync(problemsPath, 'utf-8');
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
- // Mark with _delete: 'force' to override photo/starred protection
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/gcards",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Google Contacts cleanup and management tool",
5
5
  "type": "module",
6
6
  "main": "gcards.ts",