@bobfrankston/gcards 0.1.5 → 0.1.7

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.
@@ -21,7 +21,8 @@
21
21
  "Bash(npm install:*)",
22
22
  "Bash(npx tsx:*)",
23
23
  "WebFetch(domain:lh3.googleusercontent.com)",
24
- "Bash(curl:*)"
24
+ "Bash(curl:*)",
25
+ "Bash(bun build:*)"
25
26
  ],
26
27
  "deny": [],
27
28
  "ask": []
package/README.md CHANGED
@@ -47,6 +47,8 @@ gfix birthday -u user --apply # Extract to CSV and remove
47
47
 
48
48
  gfix undup -u user # Find duplicate contacts -> merger.json
49
49
  gfix merge -u user # Merge duplicates locally (then use gcards push)
50
+
51
+ gfix export -u source -to target "pattern" # Export contacts to another user
50
52
  ```
51
53
 
52
54
  ## Google People API Fields
@@ -217,6 +219,27 @@ gcards sync -a
217
219
  gcards push -a
218
220
  ```
219
221
 
222
+ ## Exporting Contacts Between Users
223
+
224
+ Transfer contacts from one user's account to another:
225
+
226
+ ```bash
227
+ gfix export -u bob -to alice "John*" # Export contacts matching "John*"
228
+ gfix export -u bob -to ali "*@example.com" # Export by email (partial user match)
229
+ gfix export -u bob -to alice c1234567890 # Export specific contact by ID
230
+ ```
231
+
232
+ **Features:**
233
+ - `-u` source user (partial match supported)
234
+ - `-to` target user (partial match - "ali" matches "alice" if unique)
235
+ - Pattern supports `*` (any chars) and `?` (single char) wildcards
236
+ - Matches on: displayName, givenName, familyName, "Last, First", email
237
+ - Searches both `contacts/` and `deleted/` directories
238
+ - Multiple matches prompt for selection: number(s), `*` for all, `q` to quit
239
+ - Creates cleaned file in target's `_add/` as `x<sourceId>.json`
240
+
241
+ After export, run `gcards push -u <target>` to upload.
242
+
220
243
  ## Setup
221
244
 
222
245
  1. Create Google Cloud project
package/gcards.ts CHANGED
@@ -25,12 +25,19 @@ function ts(): string {
25
25
  }
26
26
 
27
27
  let escapePressed = false;
28
+ let abortController: AbortController | null = null;
28
29
 
29
30
  function setupEscapeHandler(): void {
31
+ // Create new abort controller for this operation
32
+ abortController = new AbortController();
33
+
30
34
  // Handle Ctrl+C via SIGINT (works without raw mode)
31
35
  process.on('SIGINT', () => {
32
36
  escapePressed = true;
33
- console.log('\n\nCtrl+C pressed - stopping after current operation...');
37
+ if (abortController) {
38
+ abortController.abort();
39
+ }
40
+ console.log('\n\nCtrl+C pressed - aborting...');
34
41
  });
35
42
 
36
43
  // Handle ESC key via raw mode (optional, for graceful stop)
@@ -40,10 +47,16 @@ function setupEscapeHandler(): void {
40
47
  process.stdin.on('data', (key) => {
41
48
  if (key[0] === 27) { // ESC key
42
49
  escapePressed = true;
43
- console.log('\n\nESC pressed - stopping after current operation...');
50
+ if (abortController) {
51
+ abortController.abort();
52
+ }
53
+ console.log('\n\nESC pressed - aborting...');
44
54
  } else if (key[0] === 3) { // Ctrl+C in raw mode
45
55
  escapePressed = true;
46
- console.log('\n\nCtrl+C pressed - stopping after current operation...');
56
+ if (abortController) {
57
+ abortController.abort();
58
+ }
59
+ console.log('\n\nCtrl+C pressed - aborting...');
47
60
  }
48
61
  });
49
62
  }
@@ -79,7 +92,9 @@ async function getAccessToken(user: string, writeAccess = false, forceRefresh =
79
92
  tokenDirectory: paths.userDir,
80
93
  tokenFileName,
81
94
  credentialsKey: 'web',
82
- includeOfflineAccess: true
95
+ includeOfflineAccess: true,
96
+ prompt: 'select_account', // Always show account picker for new auth
97
+ signal: abortController?.signal // Pass abort signal for immediate cancellation
83
98
  });
84
99
 
85
100
  if (!token) {
package/gfix.ts CHANGED
@@ -10,9 +10,10 @@
10
10
 
11
11
  import fs from 'fs';
12
12
  import path from 'path';
13
+ import readline from 'readline';
13
14
  import type { GooglePerson, GoogleName, GooglePhoneNumber, GoogleEmailAddress, GoogleBirthday } from './glib/types.ts';
14
15
  import type { DeleteQueue, DeleteQueueEntry, UserPaths } from './glib/gctypes.ts';
15
- import { DATA_DIR, resolveUser, getUserPaths, loadIndex } from './glib/gutils.ts';
16
+ import { DATA_DIR, resolveUser, getUserPaths, loadIndex, getAllUsers, normalizeUser } from './glib/gutils.ts';
16
17
  import { mergeContacts as mergeContactData, collectSourcePhotos, type MergeEntry, type PhotoEntry } from './glib/gmerge.ts';
17
18
 
18
19
  function loadDeleteQueue(paths: UserPaths): DeleteQueue {
@@ -1405,6 +1406,321 @@ async function runMerge(user: string, limit: number): Promise<void> {
1405
1406
  console.log(`\nRun 'gcards push -u ${user}' to apply changes to Google.`);
1406
1407
  }
1407
1408
 
1409
+ // ============================================================
1410
+ // Export Feature - Transfer contacts between users
1411
+ // ============================================================
1412
+
1413
+ interface ContactMatch {
1414
+ id: string; /** JSON file id (without .json) */
1415
+ displayName: string;
1416
+ emails: string[];
1417
+ filePath: string;
1418
+ source: 'contacts' | 'deleted';
1419
+ }
1420
+
1421
+ /** Find a contact by ID in contacts or deleted directories */
1422
+ function findContactById(paths: UserPaths, id: string): { contact: GooglePerson; filePath: string; source: 'contacts' | 'deleted' } {
1423
+ const cleanId = id.replace(/\.json$/i, '');
1424
+
1425
+ // Check contacts directory
1426
+ const contactPath = path.join(paths.contactsDir, `${cleanId}.json`);
1427
+ if (fs.existsSync(contactPath)) {
1428
+ return {
1429
+ contact: JSON.parse(fs.readFileSync(contactPath, 'utf-8')),
1430
+ filePath: contactPath,
1431
+ source: 'contacts'
1432
+ };
1433
+ }
1434
+
1435
+ // Check deleted directory
1436
+ const deletedPath = path.join(paths.deletedDir, `${cleanId}.json`);
1437
+ if (fs.existsSync(deletedPath)) {
1438
+ return {
1439
+ contact: JSON.parse(fs.readFileSync(deletedPath, 'utf-8')),
1440
+ filePath: deletedPath,
1441
+ source: 'deleted'
1442
+ };
1443
+ }
1444
+
1445
+ return null;
1446
+ }
1447
+
1448
+ /** Match users by partial name - returns matching user names */
1449
+ function matchUsers(pattern: string): string[] {
1450
+ const allUsers = getAllUsers();
1451
+ const normalizedPattern = pattern.toLowerCase();
1452
+
1453
+ return allUsers.filter(user => user.toLowerCase().includes(normalizedPattern));
1454
+ }
1455
+
1456
+ /** Convert wildcard pattern to regex (supports * and ?) */
1457
+ function wildcardToRegex(pattern: string): RegExp {
1458
+ const escaped = pattern
1459
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars
1460
+ .replace(/\*/g, '.*') // * matches anything
1461
+ .replace(/\?/g, '.'); // ? matches single char
1462
+ return new RegExp(`^${escaped}$`, 'i');
1463
+ }
1464
+
1465
+ /** Search contacts by name or email pattern */
1466
+ function searchContacts(paths: UserPaths, pattern: string): ContactMatch[] {
1467
+ const regex = wildcardToRegex(pattern);
1468
+ const matches: ContactMatch[] = [];
1469
+
1470
+ // Search in contacts directory
1471
+ if (fs.existsSync(paths.contactsDir)) {
1472
+ for (const file of fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'))) {
1473
+ const filePath = path.join(paths.contactsDir, file);
1474
+ const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1475
+ if (matchesContact(contact, regex)) {
1476
+ matches.push({
1477
+ id: file.replace(/\.json$/, ''),
1478
+ displayName: getContactDisplayName(contact),
1479
+ emails: (contact.emailAddresses || []).map(e => e.value).filter(Boolean) as string[],
1480
+ filePath,
1481
+ source: 'contacts'
1482
+ });
1483
+ }
1484
+ }
1485
+ }
1486
+
1487
+ // Search in deleted directory
1488
+ if (fs.existsSync(paths.deletedDir)) {
1489
+ for (const file of fs.readdirSync(paths.deletedDir).filter(f => f.endsWith('.json'))) {
1490
+ const filePath = path.join(paths.deletedDir, file);
1491
+ const contact = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
1492
+ if (matchesContact(contact, regex)) {
1493
+ matches.push({
1494
+ id: file.replace(/\.json$/, ''),
1495
+ displayName: getContactDisplayName(contact),
1496
+ emails: (contact.emailAddresses || []).map(e => e.value).filter(Boolean) as string[],
1497
+ filePath,
1498
+ source: 'deleted'
1499
+ });
1500
+ }
1501
+ }
1502
+ }
1503
+
1504
+ return matches;
1505
+ }
1506
+
1507
+ /** Check if contact matches regex on name or email */
1508
+ function matchesContact(contact: GooglePerson, regex: RegExp): boolean {
1509
+ // Check display name
1510
+ const name = contact.names?.[0];
1511
+ if (name) {
1512
+ if (regex.test(name.displayName || '')) return true;
1513
+ if (regex.test(name.givenName || '')) return true;
1514
+ if (regex.test(name.familyName || '')) return true;
1515
+ // "Last, First" format
1516
+ if (name.givenName && name.familyName) {
1517
+ if (regex.test(`${name.familyName}, ${name.givenName}`)) return true;
1518
+ if (regex.test(`${name.givenName} ${name.familyName}`)) return true;
1519
+ }
1520
+ if (regex.test(name.displayNameLastFirst || '')) return true;
1521
+ }
1522
+
1523
+ // Check emails
1524
+ for (const email of contact.emailAddresses || []) {
1525
+ if (regex.test(email.value || '')) return true;
1526
+ }
1527
+
1528
+ return false;
1529
+ }
1530
+
1531
+ /** Get display name for a contact */
1532
+ function getContactDisplayName(contact: GooglePerson): string {
1533
+ const name = contact.names?.[0];
1534
+ return name?.displayName || name?.givenName || name?.familyName || 'Unknown';
1535
+ }
1536
+
1537
+ /** Clean contact for export - remove Google-specific metadata */
1538
+ function cleanContactForExport(contact: GooglePerson): GooglePerson {
1539
+ const cleaned = { ...contact };
1540
+
1541
+ // Remove resource-specific fields that will be regenerated
1542
+ delete cleaned.resourceName;
1543
+ delete cleaned.etag;
1544
+ delete (cleaned as any)._delete;
1545
+ delete (cleaned as any)._deleted;
1546
+
1547
+ // Remove metadata that links to source account
1548
+ if (cleaned.metadata) {
1549
+ delete cleaned.metadata.sources;
1550
+ delete cleaned.metadata.previousResourceNames;
1551
+ delete cleaned.metadata.linkedPeopleResourceNames;
1552
+ }
1553
+
1554
+ // Clean field metadata (source references)
1555
+ const cleanFieldMetadata = (items: any[]) => {
1556
+ if (!items) return;
1557
+ for (const item of items) {
1558
+ if (item.metadata) {
1559
+ delete item.metadata.source;
1560
+ }
1561
+ }
1562
+ };
1563
+
1564
+ cleanFieldMetadata(cleaned.names);
1565
+ cleanFieldMetadata(cleaned.emailAddresses);
1566
+ cleanFieldMetadata(cleaned.phoneNumbers);
1567
+ cleanFieldMetadata(cleaned.addresses);
1568
+ cleanFieldMetadata(cleaned.organizations);
1569
+ cleanFieldMetadata(cleaned.urls);
1570
+ cleanFieldMetadata(cleaned.biographies);
1571
+ cleanFieldMetadata(cleaned.photos);
1572
+ cleanFieldMetadata(cleaned.birthdays);
1573
+ cleanFieldMetadata(cleaned.relations);
1574
+ cleanFieldMetadata(cleaned.events);
1575
+ cleanFieldMetadata(cleaned.memberships);
1576
+ cleanFieldMetadata(cleaned.userDefined);
1577
+
1578
+ return cleaned;
1579
+ }
1580
+
1581
+ /** Prompt user for selection when multiple matches */
1582
+ async function promptSelection(matches: ContactMatch[]): Promise<string[]> {
1583
+ console.log('\nMultiple contacts found:');
1584
+ for (let i = 0; i < matches.length; i++) {
1585
+ const m = matches[i];
1586
+ const emailStr = m.emails.length > 0 ? ` <${m.emails[0]}>` : '';
1587
+ const srcStr = m.source === 'deleted' ? ' [deleted]' : '';
1588
+ console.log(` ${i + 1}. ${m.displayName}${emailStr}${srcStr} (${m.id})`);
1589
+ }
1590
+ console.log('\nEnter selection: number(s) separated by commas, * for all, or q to quit');
1591
+
1592
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1593
+
1594
+ return new Promise((resolve) => {
1595
+ rl.question('> ', (answer) => {
1596
+ rl.close();
1597
+ const trimmed = answer.trim().toLowerCase();
1598
+
1599
+ if (trimmed === 'q' || trimmed === '') {
1600
+ resolve([]);
1601
+ return;
1602
+ }
1603
+
1604
+ if (trimmed === '*') {
1605
+ resolve(matches.map(m => m.id));
1606
+ return;
1607
+ }
1608
+
1609
+ // Parse comma-separated numbers
1610
+ const selected: string[] = [];
1611
+ for (const part of trimmed.split(',')) {
1612
+ const num = parseInt(part.trim(), 10);
1613
+ if (num >= 1 && num <= matches.length) {
1614
+ selected.push(matches[num - 1].id);
1615
+ }
1616
+ }
1617
+ resolve(selected);
1618
+ });
1619
+ });
1620
+ }
1621
+
1622
+ /** Export contacts from source user to target user */
1623
+ async function runExport(sourceUser: string, targetPattern: string, contactPattern: string): Promise<void> {
1624
+ // Resolve target user by partial match
1625
+ const targetMatches = matchUsers(targetPattern);
1626
+
1627
+ if (targetMatches.length === 0) {
1628
+ console.error(`No users match pattern: ${targetPattern}`);
1629
+ console.error(`Available users: ${getAllUsers().join(', ')}`);
1630
+ process.exit(1);
1631
+ }
1632
+
1633
+ if (targetMatches.length > 1) {
1634
+ console.error(`Multiple users match pattern "${targetPattern}":`);
1635
+ for (const u of targetMatches) {
1636
+ console.error(` - ${u}`);
1637
+ }
1638
+ console.error('Please be more specific.');
1639
+ process.exit(1);
1640
+ }
1641
+
1642
+ const targetUser = targetMatches[0];
1643
+
1644
+ if (normalizeUser(sourceUser) === normalizeUser(targetUser)) {
1645
+ console.error('Source and target users cannot be the same.');
1646
+ process.exit(1);
1647
+ }
1648
+
1649
+ const sourcePaths = getUserPaths(sourceUser);
1650
+ const targetPaths = getUserPaths(targetUser);
1651
+
1652
+ // Ensure target _add directory exists
1653
+ if (!fs.existsSync(targetPaths.toAddDir)) {
1654
+ fs.mkdirSync(targetPaths.toAddDir, { recursive: true });
1655
+ }
1656
+
1657
+ // Find contacts to export
1658
+ let contactIds: string[] = [];
1659
+
1660
+ // Check if contactPattern looks like an ID (alphanumeric, possibly with .json)
1661
+ const cleanPattern = contactPattern.replace(/\.json$/i, '');
1662
+ const found = findContactById(sourcePaths, cleanPattern);
1663
+
1664
+ if (found) {
1665
+ // Direct ID match
1666
+ contactIds = [cleanPattern];
1667
+ } else {
1668
+ // Search by name/email pattern
1669
+ const matches = searchContacts(sourcePaths, contactPattern);
1670
+
1671
+ if (matches.length === 0) {
1672
+ console.error(`No contacts match pattern: ${contactPattern}`);
1673
+ process.exit(1);
1674
+ }
1675
+
1676
+ if (matches.length === 1) {
1677
+ contactIds = [matches[0].id];
1678
+ } else {
1679
+ contactIds = await promptSelection(matches);
1680
+ if (contactIds.length === 0) {
1681
+ console.log('No contacts selected.');
1682
+ return;
1683
+ }
1684
+ }
1685
+ }
1686
+
1687
+ // Export each selected contact
1688
+ let exported = 0;
1689
+ for (const id of contactIds) {
1690
+ const result = findContactById(sourcePaths, id);
1691
+ if (!result) {
1692
+ console.error(`Contact not found: ${id}`);
1693
+ continue;
1694
+ }
1695
+
1696
+ const { contact, source } = result;
1697
+ const displayName = getContactDisplayName(contact);
1698
+
1699
+ // Clean the contact for export
1700
+ const cleaned = cleanContactForExport(contact);
1701
+
1702
+ // Generate unique filename: x<sourceId>.json
1703
+ const exportFileName = `x${id}.json`;
1704
+ const exportPath = path.join(targetPaths.toAddDir, exportFileName);
1705
+
1706
+ // Check for existing file
1707
+ if (fs.existsSync(exportPath)) {
1708
+ console.log(` [SKIP] ${displayName} - already exists: ${exportFileName}`);
1709
+ continue;
1710
+ }
1711
+
1712
+ fs.writeFileSync(exportPath, JSON.stringify(cleaned, null, 2));
1713
+ console.log(` ${displayName} -> ${targetUser}/_add/${exportFileName}${source === 'deleted' ? ' [from deleted]' : ''}`);
1714
+ exported++;
1715
+ }
1716
+
1717
+ console.log(`\n${'='.repeat(50)}`);
1718
+ console.log(`Exported: ${exported} contact(s) to ${targetUser}/_add/`);
1719
+ if (exported > 0) {
1720
+ console.log(`\nTo upload, run: gcards push -u ${targetUser}`);
1721
+ }
1722
+ }
1723
+
1408
1724
  /** Reset ALL delete markers: index.json, contact files, _delete.json, _delete/ folder */
1409
1725
  async function runReset(user: string): Promise<void> {
1410
1726
  const paths = getUserPaths(user);
@@ -1486,9 +1802,11 @@ Commands:
1486
1802
  undup Find duplicate contacts (same name + overlapping email) -> merger.json
1487
1803
  merge Merge duplicates locally (then use gcards push)
1488
1804
  reset Clear all _delete flags from index.json
1805
+ export Transfer contact(s) to another user's _add directory
1489
1806
 
1490
1807
  Options:
1491
- -u, --user <name> User profile to process
1808
+ -u, --user <name> User profile to process (source for export)
1809
+ -to <user> Target user for export (partial match OK)
1492
1810
  --apply For birthday/fileas/names: actually apply changes
1493
1811
  -limit <n> Process only first n contacts (for testing)
1494
1812
 
@@ -1518,6 +1836,16 @@ Duplicate detection and merge:
1518
1836
  - gcards push -u bob # Push changes to Google
1519
1837
  - gcards sync -u bob --full # Resync to get updated contacts
1520
1838
 
1839
+ Export contacts to another user:
1840
+ - gfix export -u bob -to alice "John*" # Export contacts matching "John*" to alice
1841
+ - gfix export -u bob -to ali "*@example.com" # Export by email pattern (ali matches alice)
1842
+ - gfix export -u bob -to alice c1234567890 # Export by contact ID (with or without .json)
1843
+ - Pattern supports * (any chars) and ? (single char) wildcards
1844
+ - Matches on: displayName, givenName, familyName, "Last, First", email
1845
+ - Searches both contacts/ and deleted/ directories
1846
+ - Multiple matches prompt for selection: number(s), * for all, q to quit
1847
+ - Creates cleaned file in target's _add/ directory as x<sourceId>.json
1848
+
1521
1849
  Workflow:
1522
1850
  1. gfix inspect -u bob # Review proposed changes
1523
1851
  2. gfix apply -u bob # Apply changes to local files
@@ -1533,18 +1861,26 @@ async function main(): Promise<void> {
1533
1861
 
1534
1862
  let command = '';
1535
1863
  let user = '';
1864
+ let targetUser = '';
1536
1865
  let applyFlag = false;
1866
+ const positionalArgs: string[] = [];
1537
1867
 
1538
1868
  for (let i = 0; i < args.length; i++) {
1539
1869
  const arg = args[i];
1540
1870
  if ((arg === '-u' || arg === '--user' || arg === '-user') && i + 1 < args.length) {
1541
1871
  user = args[++i];
1872
+ } else if ((arg === '-to' || arg === '--to') && i + 1 < args.length) {
1873
+ targetUser = args[++i];
1542
1874
  } else if (arg === '--apply') {
1543
1875
  applyFlag = true;
1544
1876
  } else if ((arg === '-limit' || arg === '--limit') && i + 1 < args.length) {
1545
1877
  processLimit = parseInt(args[++i], 10) || 0;
1546
- } else if (!arg.startsWith('-') && !command) {
1547
- command = arg;
1878
+ } else if (!arg.startsWith('-')) {
1879
+ if (!command) {
1880
+ command = arg;
1881
+ } else {
1882
+ positionalArgs.push(arg);
1883
+ }
1548
1884
  }
1549
1885
  }
1550
1886
 
@@ -1553,6 +1889,26 @@ async function main(): Promise<void> {
1553
1889
  return;
1554
1890
  }
1555
1891
 
1892
+ // Export command has different argument handling
1893
+ if (command === 'export') {
1894
+ if (!user) {
1895
+ console.error('Export requires -u <sourceUser>');
1896
+ process.exit(1);
1897
+ }
1898
+ if (!targetUser) {
1899
+ console.error('Export requires -to <targetUser>');
1900
+ process.exit(1);
1901
+ }
1902
+ if (positionalArgs.length === 0) {
1903
+ console.error('Export requires a contact pattern or ID');
1904
+ console.error('Usage: gfix export -u <source> -to <target> <pattern>');
1905
+ process.exit(1);
1906
+ }
1907
+ const resolvedSource = resolveUser(user);
1908
+ await runExport(resolvedSource, targetUser, positionalArgs[0]);
1909
+ return;
1910
+ }
1911
+
1556
1912
  const resolvedUser = resolveUser(user);
1557
1913
 
1558
1914
  if (processLimit > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/gcards",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Google Contacts cleanup and management tool",
5
5
  "type": "module",
6
6
  "main": "gcards.ts",