@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.
@@ -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, pageToken, 0);
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
- // Check if contact has a real photo or is starred - skip deletion if so
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
- console.log(`${ts()} [${String(processed).padStart(5)}/${total}] SKIPPED ${change.displayName} (${skipReason})`);
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<TAB>name<TAB>reason
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
- return `${id}.json\t${nd.displayName}\t${reason}`;
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('\t')[0]));
1135
- const uniqueEntries = newEntries.filter(e => !existingLines.has(e.split('\t')[0]));
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
- 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.19",
4
4
  "description": "Google Contacts cleanup and management tool",
5
5
  "type": "module",
6
6
  "main": "gcards.ts",