@bobfrankston/gcards 0.1.36 → 0.1.38
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/.claude/settings.local.json +14 -1
- package/gcards.code-workspace +3 -0
- package/gcards.ts +169 -13
- package/package.json +8 -4
- package/tmpclaude-87f0-cwd +1 -0
- package/.gitattributes +0 -1
- package/.vscode/launch.json +0 -21
- package/.vscode/settings.json +0 -23
- package/.vscode/tasks.json +0 -20
- package/glib/aihelper.ts +0 -215
- package/glib/gctypes.ts +0 -136
- package/glib/gmerge.ts +0 -313
- package/glib/gutils.ts +0 -467
- package/glib/parsecli.ts +0 -134
- package/glib/types.ts +0 -305
- package/tsconfig.json +0 -27
|
@@ -34,7 +34,20 @@
|
|
|
34
34
|
"Bash(npm link:*)",
|
|
35
35
|
"Bash(cmd /c \"dir /AL\")",
|
|
36
36
|
"Bash(node gcards.ts:*)",
|
|
37
|
-
"Bash(npm run release:*)"
|
|
37
|
+
"Bash(npm run release:*)",
|
|
38
|
+
"Bash(npm update:*)",
|
|
39
|
+
"Bash(npm ls:*)",
|
|
40
|
+
"Bash(cmd.exe /c \"cd /d Y:\\\\dev\\\\projects\\\\oauth\\\\oauthsupport && npm run release\")",
|
|
41
|
+
"Bash(powershell.exe -Command \"Push-Location ''Y:\\\\dev\\\\projects\\\\oauth\\\\oauthsupport''; npm run release\")",
|
|
42
|
+
"WebSearch",
|
|
43
|
+
"WebFetch(domain:www.npmjs.com)",
|
|
44
|
+
"WebFetch(domain:github.com)",
|
|
45
|
+
"Bash(npm run check:*)",
|
|
46
|
+
"Bash(powershell.exe -Command \"if \\(Test-Path \"\"$env:APPDATA\\\\gcards\\\\keys.env\"\"\\) { Get-Content \"\"$env:APPDATA\\\\gcards\\\\keys.env\"\" | Select-String ''ANTHROPIC'' | ForEach-Object { $_Line -replace ''=.*'', ''=***'' } } else { Write-Host ''Not found: %APPDATA%\\\\gcards\\\\keys.env'' }\")",
|
|
47
|
+
"Bash(powershell.exe -Command \"if \\(Test-Path \"\"$env:USERPROFILE\\\\.gcards\\\\keys.env\"\"\\) { Write-Host ''Found: ~/.gcards/keys.env''; Get-Content \"\"$env:USERPROFILE\\\\.gcards\\\\keys.env\"\" | Select-String ''ANTHROPIC'' | ForEach-Object { $_Line -replace ''=.*'', ''=***'' } } else { Write-Host ''Not found: ~/.gcards/keys.env'' }\")",
|
|
48
|
+
"Bash(powershell.exe -Command \"if \\($env:ANTHROPIC_API_KEY\\) { Write-Host ''ANTHROPIC_API_KEY is set in environment'' } else { Write-Host ''ANTHROPIC_API_KEY NOT set in environment'' }\")",
|
|
49
|
+
"Bash(powershell.exe -Command \"Set-Clipboard -Value ''John Smith, CEO, Acme Corporation, john.smith@acme.com, +1-555-123-4567''\")",
|
|
50
|
+
"Bash(node:*)"
|
|
38
51
|
],
|
|
39
52
|
"deny": [],
|
|
40
53
|
"ask": []
|
package/gcards.code-workspace
CHANGED
package/gcards.ts
CHANGED
|
@@ -15,7 +15,8 @@ import type { GooglePerson, GoogleConnectionsResponse } from './glib/types.ts';
|
|
|
15
15
|
import { GCARDS_GUID_KEY, extractGuids } from './glib/gctypes.ts';
|
|
16
16
|
import type { ContactIndex, IndexEntry, DeletedEntry, DeleteQueue, DeleteQueueEntry, PushStatus, PendingChange, UserPaths } from './glib/gctypes.ts';
|
|
17
17
|
import { DATA_DIR, CREDENTIALS_FILE, loadConfig, saveConfig, getUserPaths, ensureUserDir, loadIndex, normalizeUser, getAllUsers, resolveUser, FileLogger, processProblemsFile } from './glib/gutils.ts';
|
|
18
|
-
import { explainApiError, isAIAvailable, showAISetupHint } from './glib/aihelper.ts';
|
|
18
|
+
import { explainApiError, isAIAvailable, showAISetupHint, extractContactFromText, extractContactFromImage } from './glib/aihelper.ts';
|
|
19
|
+
import Clipboard from '@crosscopy/clipboard';
|
|
19
20
|
|
|
20
21
|
const PEOPLE_API_BASE = 'https://people.googleapis.com/v1';
|
|
21
22
|
const CONTACTS_SCOPE_READ = 'https://www.googleapis.com/auth/contacts.readonly';
|
|
@@ -42,7 +43,7 @@ let abortController: AbortController | null = null;
|
|
|
42
43
|
function setupEscapeHandler(): void {
|
|
43
44
|
// Create new abort controller for this operation
|
|
44
45
|
abortController = new AbortController();
|
|
45
|
-
|
|
46
|
+
|
|
46
47
|
// Handle Ctrl+C via SIGINT (works without raw mode)
|
|
47
48
|
process.on('SIGINT', () => {
|
|
48
49
|
escapePressed = true;
|
|
@@ -160,7 +161,7 @@ function saveIndex(paths: UserPaths, index: ContactIndex): void {
|
|
|
160
161
|
sortedContacts[key] = value;
|
|
161
162
|
}
|
|
162
163
|
index.contacts = sortedContacts;
|
|
163
|
-
|
|
164
|
+
|
|
164
165
|
// Add helpful comments to the file
|
|
165
166
|
const comment = `// index.json - Active contacts index
|
|
166
167
|
// This file tracks all contacts synced from Google.
|
|
@@ -214,7 +215,7 @@ function saveDeleted(paths: UserPaths, deletedIndex: DeletedIndex): void {
|
|
|
214
215
|
sorted[key] = value;
|
|
215
216
|
}
|
|
216
217
|
deletedIndex.deleted = sorted;
|
|
217
|
-
|
|
218
|
+
|
|
218
219
|
// Add helpful comments
|
|
219
220
|
const comment = `// deleted.json - Tombstone index of deleted contacts
|
|
220
221
|
// This file tracks contacts that have been deleted from Google.
|
|
@@ -315,14 +316,14 @@ function savePhotos(paths: UserPaths, photosIndex: PhotosIndex): void {
|
|
|
315
316
|
<table>
|
|
316
317
|
<tr><th>Photo</th><th>Name</th></tr>
|
|
317
318
|
${entries.map(([resourceName, entry]) => {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
319
|
+
const id = resourceName.replace('people/', '');
|
|
320
|
+
const contactUrl = `https://contacts.google.com/person/${id}`;
|
|
321
|
+
const photoUrl = entry.photos[0]?.url || '';
|
|
322
|
+
return ` <tr>
|
|
322
323
|
<td>${photoUrl ? `<img src="${photoUrl}" alt="${entry.displayName}" loading="lazy">` : '(no photo)'}</td>
|
|
323
324
|
<td><a href="${contactUrl}" target="_blank">${entry.displayName}</a><br><span class="deleted">Deleted: ${entry.deletedAt?.split('T')[0] || 'unknown'}</span></td>
|
|
324
325
|
</tr>`;
|
|
325
|
-
}).join('\n')}
|
|
326
|
+
}).join('\n')}
|
|
326
327
|
</table>
|
|
327
328
|
</body>
|
|
328
329
|
</html>`;
|
|
@@ -342,7 +343,7 @@ function loadDeleteQueue(paths: UserPaths): DeleteQueue {
|
|
|
342
343
|
|
|
343
344
|
function saveDeleteQueue(paths: UserPaths, queue: DeleteQueue): void {
|
|
344
345
|
queue.updatedAt = new Date().toISOString();
|
|
345
|
-
|
|
346
|
+
|
|
346
347
|
// Add helpful comments
|
|
347
348
|
const comment = `// _delete.json - Pending deletion queue
|
|
348
349
|
// This file contains contacts marked for deletion that require review.
|
|
@@ -482,7 +483,7 @@ function saveContact(paths: UserPaths, person: GooglePerson): void {
|
|
|
482
483
|
|
|
483
484
|
const id = person.resourceName.replace('people/', '');
|
|
484
485
|
const filePath = path.join(paths.contactsDir, `${id}.json`);
|
|
485
|
-
|
|
486
|
+
|
|
486
487
|
// Add helpful comment for contact files
|
|
487
488
|
const comment = `// Contact file from Google People API
|
|
488
489
|
// To delete this contact: Add "_delete": "force" field at root level, then run 'gcards push'
|
|
@@ -723,7 +724,7 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
|
|
|
723
724
|
const indexedResourceNames = new Set(
|
|
724
725
|
Object.keys(index.contacts).map(rn => rn.replace('people/', '') + '.json')
|
|
725
726
|
);
|
|
726
|
-
|
|
727
|
+
|
|
727
728
|
for (const file of contactFiles) {
|
|
728
729
|
if (!indexedResourceNames.has(file)) {
|
|
729
730
|
if (!fs.existsSync(paths.orphansDir)) {
|
|
@@ -1048,6 +1049,138 @@ async function createContactOnGoogle(person: GooglePerson, retryCount = 0, token
|
|
|
1048
1049
|
return await response.json();
|
|
1049
1050
|
}
|
|
1050
1051
|
|
|
1052
|
+
/**
|
|
1053
|
+
* Add a contact from text, clipboard, or image file using AI extraction
|
|
1054
|
+
*/
|
|
1055
|
+
async function addContact(user: string, options: { clip: boolean; file: string; text: string; verbose: boolean }): Promise<void> {
|
|
1056
|
+
const paths = getUserPaths(user);
|
|
1057
|
+
ensureUserDir(user);
|
|
1058
|
+
|
|
1059
|
+
// Ensure _add directory exists
|
|
1060
|
+
if (!fs.existsSync(paths.toAddDir)) {
|
|
1061
|
+
fs.mkdirSync(paths.toAddDir, { recursive: true });
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
let extracted;
|
|
1065
|
+
let sourceDesc: string;
|
|
1066
|
+
|
|
1067
|
+
if (options.file) {
|
|
1068
|
+
// Read from image file
|
|
1069
|
+
if (!fs.existsSync(options.file)) {
|
|
1070
|
+
throw new Error(`File not found: ${options.file}`);
|
|
1071
|
+
}
|
|
1072
|
+
console.log(`Reading image from: ${options.file}`);
|
|
1073
|
+
const imageBuffer = fs.readFileSync(options.file);
|
|
1074
|
+
const base64 = imageBuffer.toString('base64');
|
|
1075
|
+
const ext = path.extname(options.file).toLowerCase();
|
|
1076
|
+
const mediaType = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'image/png';
|
|
1077
|
+
|
|
1078
|
+
console.log('Extracting contact info from image...');
|
|
1079
|
+
extracted = await extractContactFromImage(base64, mediaType);
|
|
1080
|
+
sourceDesc = `image:${path.basename(options.file)}`;
|
|
1081
|
+
|
|
1082
|
+
} else if (options.clip) {
|
|
1083
|
+
// Read from clipboard
|
|
1084
|
+
console.log('Reading from clipboard...');
|
|
1085
|
+
|
|
1086
|
+
try {
|
|
1087
|
+
// Try image first
|
|
1088
|
+
let hasImage = false;
|
|
1089
|
+
try {
|
|
1090
|
+
hasImage = await Clipboard.hasImage();
|
|
1091
|
+
} catch {
|
|
1092
|
+
// hasImage check failed, assume text
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
if (hasImage) {
|
|
1096
|
+
console.log('Found image in clipboard');
|
|
1097
|
+
const base64 = await Clipboard.getImageBase64();
|
|
1098
|
+
if (!base64) {
|
|
1099
|
+
throw new Error('Failed to read image from clipboard');
|
|
1100
|
+
}
|
|
1101
|
+
console.log('Extracting contact info from image...');
|
|
1102
|
+
extracted = await extractContactFromImage(base64, 'image/png');
|
|
1103
|
+
sourceDesc = 'clipboard:image';
|
|
1104
|
+
} else {
|
|
1105
|
+
const text = await Clipboard.getText();
|
|
1106
|
+
if (!text || text.trim().length === 0) {
|
|
1107
|
+
throw new Error('Clipboard is empty or contains unsupported content (try copying text or an image)');
|
|
1108
|
+
}
|
|
1109
|
+
console.log(`Found text in clipboard (${text.length} chars)`);
|
|
1110
|
+
if (options.verbose) {
|
|
1111
|
+
console.log(`Text: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`);
|
|
1112
|
+
}
|
|
1113
|
+
console.log('Extracting contact info from text...');
|
|
1114
|
+
extracted = await extractContactFromText(text);
|
|
1115
|
+
sourceDesc = 'clipboard:text';
|
|
1116
|
+
}
|
|
1117
|
+
} catch (clipError: any) {
|
|
1118
|
+
throw new Error(`Clipboard error: ${clipError.message || clipError}. Make sure clipboard contains text or an image.`);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
} else if (options.text) {
|
|
1122
|
+
// Parse from command line text
|
|
1123
|
+
console.log('Extracting contact info from text...');
|
|
1124
|
+
if (options.verbose) {
|
|
1125
|
+
console.log(`Text: ${options.text}`);
|
|
1126
|
+
}
|
|
1127
|
+
extracted = await extractContactFromText(options.text);
|
|
1128
|
+
sourceDesc = 'text';
|
|
1129
|
+
|
|
1130
|
+
} else {
|
|
1131
|
+
throw new Error('No input specified. Use -clip, --file, or provide text. See --help.');
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if (!extracted) {
|
|
1135
|
+
throw new Error('Failed to extract contact information');
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// Build display name for the contact
|
|
1139
|
+
let displayName = 'Unknown';
|
|
1140
|
+
if (extracted.names?.[0]) {
|
|
1141
|
+
const name = extracted.names[0];
|
|
1142
|
+
if (name.givenName || name.familyName) {
|
|
1143
|
+
displayName = [name.givenName, name.familyName].filter(Boolean).join(' ');
|
|
1144
|
+
} else if (name.displayName) {
|
|
1145
|
+
displayName = name.displayName;
|
|
1146
|
+
}
|
|
1147
|
+
} else if (extracted.organizations?.[0]?.name) {
|
|
1148
|
+
displayName = extracted.organizations[0].name;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// Generate a unique filename
|
|
1152
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
|
|
1153
|
+
const safeName = displayName.replace(/[^a-zA-Z0-9]/g, '_').substring(0, 30);
|
|
1154
|
+
const filename = `${timestamp}_${safeName}.json`;
|
|
1155
|
+
const filePath = path.join(paths.toAddDir, filename);
|
|
1156
|
+
|
|
1157
|
+
// Add metadata about source
|
|
1158
|
+
const contactData = {
|
|
1159
|
+
...extracted,
|
|
1160
|
+
userDefined: [
|
|
1161
|
+
{ key: 'addedVia', value: `gcards-add:${sourceDesc}` },
|
|
1162
|
+
{ key: 'addedAt', value: new Date().toISOString() }
|
|
1163
|
+
]
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
// Save to _add folder
|
|
1167
|
+
fs.writeFileSync(filePath, JSON.stringify(contactData, null, 2));
|
|
1168
|
+
|
|
1169
|
+
console.log(`\nExtracted contact: ${displayName}`);
|
|
1170
|
+
if (extracted.emailAddresses?.length) {
|
|
1171
|
+
console.log(` Email: ${extracted.emailAddresses.map(e => e.value).join(', ')}`);
|
|
1172
|
+
}
|
|
1173
|
+
if (extracted.phoneNumbers?.length) {
|
|
1174
|
+
console.log(` Phone: ${extracted.phoneNumbers.map(p => p.value).join(', ')}`);
|
|
1175
|
+
}
|
|
1176
|
+
if (extracted.organizations?.length) {
|
|
1177
|
+
const org = extracted.organizations[0];
|
|
1178
|
+
console.log(` Org: ${[org.title, org.name].filter(Boolean).join(' @ ')}`);
|
|
1179
|
+
}
|
|
1180
|
+
console.log(`\nSaved to: ${filePath}`);
|
|
1181
|
+
console.log(`Run 'gcards push' to create the contact on Google.`);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1051
1184
|
async function pushContacts(user: string, options: { yes: boolean; verbose: boolean; limit: number; since: string }): Promise<void> {
|
|
1052
1185
|
const paths = getUserPaths(user);
|
|
1053
1186
|
ensureUserDir(user);
|
|
@@ -1470,6 +1603,9 @@ async function main(): Promise<void> {
|
|
|
1470
1603
|
case 'push':
|
|
1471
1604
|
await pushContacts(user, { yes: options.yes, verbose: options.verbose, limit: options.limit, since: options.since });
|
|
1472
1605
|
break;
|
|
1606
|
+
case 'add':
|
|
1607
|
+
await addContact(user, { clip: options.clip, file: options.file, text: options.text, verbose: options.verbose });
|
|
1608
|
+
break;
|
|
1473
1609
|
default:
|
|
1474
1610
|
console.error(`Unknown command: ${options.command}`);
|
|
1475
1611
|
showHelp();
|
|
@@ -1492,6 +1628,26 @@ async function main(): Promise<void> {
|
|
|
1492
1628
|
}
|
|
1493
1629
|
}
|
|
1494
1630
|
|
|
1631
|
+
/**
|
|
1632
|
+
* Patch process.exit to throw an exception for easier debugging and testing.
|
|
1633
|
+
* This allows catching accidental exits and inspecting the call stack.
|
|
1634
|
+
*/
|
|
1635
|
+
// const originalExit = process.exit;
|
|
1636
|
+
// process.exit = function patchedExit(code?: number): never {
|
|
1637
|
+
// const err = new Error(`process.exit(${code}) called`);
|
|
1638
|
+
// // Print stack for debugging
|
|
1639
|
+
// console.error(err.stack);
|
|
1640
|
+
// debugger;
|
|
1641
|
+
// // Optionally, throw to allow catching in tests or debugging
|
|
1642
|
+
// throw err;
|
|
1643
|
+
// };
|
|
1644
|
+
|
|
1495
1645
|
if (import.meta.main) {
|
|
1496
|
-
|
|
1646
|
+
try {
|
|
1647
|
+
await main();
|
|
1648
|
+
}
|
|
1649
|
+
catch (e: any) {
|
|
1650
|
+
console.log(`Fatal error: ${e.message}\n${e.stack}`);
|
|
1651
|
+
debugger;
|
|
1652
|
+
}
|
|
1497
1653
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/gcards",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.38",
|
|
4
4
|
"description": "Google Contacts cleanup and management tool",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "gcards.ts",
|
|
@@ -29,12 +29,16 @@
|
|
|
29
29
|
"author": "Bob Frankston",
|
|
30
30
|
"license": "MIT",
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@types/node": "^25.0.
|
|
32
|
+
"@types/node": "^25.0.9",
|
|
33
33
|
"tsx": "^4.21.0"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@bobfrankston/miscassists": "^1.0.
|
|
37
|
-
"@bobfrankston/oauthsupport": "^1.0.
|
|
36
|
+
"@bobfrankston/miscassists": "^1.0.41",
|
|
37
|
+
"@bobfrankston/oauthsupport": "^1.0.3",
|
|
38
|
+
"@crosscopy/clipboard": "^0.2.8",
|
|
38
39
|
"jsonc-parser": "^3.3.1"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
39
43
|
}
|
|
40
44
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/y/dev/utils/cardx/gcards
|
package/.gitattributes
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
* text=auto eol=lf
|
package/.vscode/launch.json
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": "0.2.0",
|
|
3
|
-
"configurations": [
|
|
4
|
-
{
|
|
5
|
-
"type": "node",
|
|
6
|
-
"request": "launch",
|
|
7
|
-
"name": "gcards sync",
|
|
8
|
-
"program": "${workspaceFolder}/gcards.ts",
|
|
9
|
-
"args": ["sync"],
|
|
10
|
-
"skipFiles": ["<node_internals>/**"]
|
|
11
|
-
},
|
|
12
|
-
{
|
|
13
|
-
"type": "node",
|
|
14
|
-
"request": "launch",
|
|
15
|
-
"name": "gcards sync --full",
|
|
16
|
-
"program": "${workspaceFolder}/gcards.ts",
|
|
17
|
-
"args": ["sync", "--full"],
|
|
18
|
-
"skipFiles": ["<node_internals>/**"]
|
|
19
|
-
}
|
|
20
|
-
]
|
|
21
|
-
}
|
package/.vscode/settings.json
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"workbench.colorTheme": "Visual Studio 2019 Dark",
|
|
3
|
-
"workbench.colorCustomizations": {
|
|
4
|
-
"activityBar.activeBackground": "#0ae04a",
|
|
5
|
-
"activityBar.background": "#0ae04a",
|
|
6
|
-
"activityBar.foreground": "#15202b",
|
|
7
|
-
"activityBar.inactiveForeground": "#15202b99",
|
|
8
|
-
"activityBarBadge.background": "#9266f8",
|
|
9
|
-
"activityBarBadge.foreground": "#15202b",
|
|
10
|
-
"commandCenter.border": "#e7e7e799",
|
|
11
|
-
"sash.hoverBorder": "#0ae04a",
|
|
12
|
-
"statusBar.background": "#08af3a",
|
|
13
|
-
"statusBar.foreground": "#e7e7e7",
|
|
14
|
-
"statusBarItem.hoverBackground": "#0ae04a",
|
|
15
|
-
"statusBarItem.remoteBackground": "#08af3a",
|
|
16
|
-
"statusBarItem.remoteForeground": "#e7e7e7",
|
|
17
|
-
"titleBar.activeBackground": "#08af3a",
|
|
18
|
-
"titleBar.activeForeground": "#e7e7e7",
|
|
19
|
-
"titleBar.inactiveBackground": "#08af3a99",
|
|
20
|
-
"titleBar.inactiveForeground": "#e7e7e799"
|
|
21
|
-
},
|
|
22
|
-
"peacock.color": "#08af3a"
|
|
23
|
-
}
|
package/.vscode/tasks.json
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": "2.0.0",
|
|
3
|
-
"tasks": [
|
|
4
|
-
{
|
|
5
|
-
"label": "tsc: check",
|
|
6
|
-
"type": "shell",
|
|
7
|
-
"command": "tsc",
|
|
8
|
-
"args": ["--watch"],
|
|
9
|
-
"runOptions": {
|
|
10
|
-
"runOn": "folderOpen"
|
|
11
|
-
},
|
|
12
|
-
"problemMatcher": "$tsc-watch",
|
|
13
|
-
"isBackground": true,
|
|
14
|
-
"group": {
|
|
15
|
-
"kind": "build",
|
|
16
|
-
"isDefault": true
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
]
|
|
20
|
-
}
|
package/glib/aihelper.ts
DELETED
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AI-powered error explanation helper for Google People API errors
|
|
3
|
-
* Uses Anthropic Claude (primary) or OpenAI (fallback)
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import fs from 'fs';
|
|
7
|
-
import path from 'path';
|
|
8
|
-
|
|
9
|
-
// Load keys from standard locations (AppData for Windows, ~/.config for Unix)
|
|
10
|
-
function loadEnvFile(): void {
|
|
11
|
-
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
12
|
-
const appData = process.env.APPDATA || path.join(home, '.config');
|
|
13
|
-
|
|
14
|
-
const locations = [
|
|
15
|
-
path.join(appData, 'gcards', 'keys.env'), // Standard app config location
|
|
16
|
-
path.join(home, '.gcards', 'keys.env'), // Unix dotfile style
|
|
17
|
-
path.join(process.cwd(), 'keys.env'), // Local override (dev only)
|
|
18
|
-
path.join(process.cwd(), '.env'),
|
|
19
|
-
];
|
|
20
|
-
|
|
21
|
-
for (const loc of locations) {
|
|
22
|
-
if (fs.existsSync(loc)) {
|
|
23
|
-
const content = fs.readFileSync(loc, 'utf-8');
|
|
24
|
-
for (const line of content.split(/\r?\n/)) {
|
|
25
|
-
const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
26
|
-
if (match && !process.env[match[1]]) {
|
|
27
|
-
process.env[match[1]] = match[2].trim().replace(/^["']|["']$/g, '');
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
break;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
loadEnvFile();
|
|
36
|
-
|
|
37
|
-
export interface AIErrorExplanation {
|
|
38
|
-
explanation: string;
|
|
39
|
-
suggestedFix: string;
|
|
40
|
-
correctedJson?: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const SYSTEM_PROMPT = `You are an expert on the Google People API for contacts. When given an API error and the JSON payload that caused it, provide:
|
|
44
|
-
|
|
45
|
-
1. A brief explanation of what went wrong
|
|
46
|
-
2. The specific fix needed
|
|
47
|
-
3. If possible, the corrected JSON snippet
|
|
48
|
-
|
|
49
|
-
Be extremely concise. No preamble. Format:
|
|
50
|
-
PROBLEM: <one line>
|
|
51
|
-
FIX: <one line>
|
|
52
|
-
CORRECTED: <json if applicable, or "N/A">
|
|
53
|
-
|
|
54
|
-
Common issues:
|
|
55
|
-
- nicknames is a top-level field, NOT nested in names[]
|
|
56
|
-
- phoneNumbers[].type values: "mobile", "home", "work", "homeFax", "workFax", "otherFax", "pager", "workMobile", "workPager", "main", "googleVoice", "other"
|
|
57
|
-
- emailAddresses[].type values: "home", "work", "other"
|
|
58
|
-
- addresses[].type values: "home", "work", "other"
|
|
59
|
-
- birthdays[].date needs {year?, month, day} not a string
|
|
60
|
-
- organizations[].type values: "work", "school"
|
|
61
|
-
- Field names are camelCase not snake_case`;
|
|
62
|
-
|
|
63
|
-
async function callAnthropic(errorMsg: string, payload: string): Promise<string | null> {
|
|
64
|
-
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
65
|
-
if (!apiKey) return null;
|
|
66
|
-
|
|
67
|
-
try {
|
|
68
|
-
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
69
|
-
method: 'POST',
|
|
70
|
-
headers: {
|
|
71
|
-
'Content-Type': 'application/json',
|
|
72
|
-
'x-api-key': apiKey,
|
|
73
|
-
'anthropic-version': '2023-06-01'
|
|
74
|
-
},
|
|
75
|
-
body: JSON.stringify({
|
|
76
|
-
model: 'claude-3-haiku-20240307',
|
|
77
|
-
max_tokens: 500,
|
|
78
|
-
system: SYSTEM_PROMPT,
|
|
79
|
-
messages: [{
|
|
80
|
-
role: 'user',
|
|
81
|
-
content: `Error: ${errorMsg}\n\nPayload:\n${payload}`
|
|
82
|
-
}]
|
|
83
|
-
})
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
if (!response.ok) return null;
|
|
87
|
-
|
|
88
|
-
const data = await response.json() as any;
|
|
89
|
-
return data.content?.[0]?.text || null;
|
|
90
|
-
} catch {
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function callOpenAI(errorMsg: string, payload: string): Promise<string | null> {
|
|
96
|
-
const apiKey = process.env.OPENAI_API_KEY;
|
|
97
|
-
if (!apiKey) return null;
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
101
|
-
method: 'POST',
|
|
102
|
-
headers: {
|
|
103
|
-
'Content-Type': 'application/json',
|
|
104
|
-
'Authorization': `Bearer ${apiKey}`
|
|
105
|
-
},
|
|
106
|
-
body: JSON.stringify({
|
|
107
|
-
model: 'gpt-4o-mini',
|
|
108
|
-
max_tokens: 500,
|
|
109
|
-
messages: [
|
|
110
|
-
{ role: 'system', content: SYSTEM_PROMPT },
|
|
111
|
-
{ role: 'user', content: `Error: ${errorMsg}\n\nPayload:\n${payload}` }
|
|
112
|
-
]
|
|
113
|
-
})
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
if (!response.ok) return null;
|
|
117
|
-
|
|
118
|
-
const data = await response.json() as any;
|
|
119
|
-
return data.choices?.[0]?.message?.content || null;
|
|
120
|
-
} catch {
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function parseAIResponse(response: string): AIErrorExplanation {
|
|
126
|
-
const lines = response.split('\n');
|
|
127
|
-
let explanation = '';
|
|
128
|
-
let suggestedFix = '';
|
|
129
|
-
let correctedJson: string | undefined;
|
|
130
|
-
let inCorrected = false;
|
|
131
|
-
const correctedLines: string[] = [];
|
|
132
|
-
|
|
133
|
-
for (const line of lines) {
|
|
134
|
-
if (line.startsWith('PROBLEM:')) {
|
|
135
|
-
explanation = line.substring(8).trim();
|
|
136
|
-
} else if (line.startsWith('FIX:')) {
|
|
137
|
-
suggestedFix = line.substring(4).trim();
|
|
138
|
-
} else if (line.startsWith('CORRECTED:')) {
|
|
139
|
-
const rest = line.substring(10).trim();
|
|
140
|
-
if (rest && rest !== 'N/A') {
|
|
141
|
-
inCorrected = true;
|
|
142
|
-
if (rest !== '{' && rest !== '[') {
|
|
143
|
-
correctedLines.push(rest);
|
|
144
|
-
} else {
|
|
145
|
-
correctedLines.push(rest);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
} else if (inCorrected) {
|
|
149
|
-
correctedLines.push(line);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (correctedLines.length > 0) {
|
|
154
|
-
const joined = correctedLines.join('\n').trim();
|
|
155
|
-
// Try to extract just the JSON part
|
|
156
|
-
const jsonMatch = joined.match(/(\{[\s\S]*\}|\[[\s\S]*\])/);
|
|
157
|
-
if (jsonMatch) {
|
|
158
|
-
correctedJson = jsonMatch[1];
|
|
159
|
-
} else if (joined !== 'N/A') {
|
|
160
|
-
correctedJson = joined;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return { explanation, suggestedFix, correctedJson };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Get AI-powered explanation for a Google People API error
|
|
169
|
-
* @param errorMessage The error message from the API
|
|
170
|
-
* @param payload The JSON payload that caused the error (will be truncated if too long)
|
|
171
|
-
* @returns Explanation and fix suggestion, or null if AI unavailable
|
|
172
|
-
*/
|
|
173
|
-
export async function explainApiError(errorMessage: string, payload: unknown): Promise<AIErrorExplanation | null> {
|
|
174
|
-
// Truncate payload to avoid huge API calls
|
|
175
|
-
let payloadStr = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
|
|
176
|
-
if (payloadStr.length > 2000) {
|
|
177
|
-
payloadStr = payloadStr.substring(0, 2000) + '\n... (truncated)';
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Try Anthropic first, then OpenAI
|
|
181
|
-
let response = await callAnthropic(errorMessage, payloadStr);
|
|
182
|
-
if (!response) {
|
|
183
|
-
response = await callOpenAI(errorMessage, payloadStr);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (!response) return null;
|
|
187
|
-
|
|
188
|
-
return parseAIResponse(response);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Check if AI error explanation is available (any API key configured)
|
|
193
|
-
*/
|
|
194
|
-
export function isAIAvailable(): boolean {
|
|
195
|
-
return !!(process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Get the expected keys.env path for the current platform
|
|
200
|
-
*/
|
|
201
|
-
export function getKeysEnvPath(): string {
|
|
202
|
-
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
203
|
-
const appData = process.env.APPDATA || path.join(home, '.config');
|
|
204
|
-
return path.join(appData, 'gcards', 'keys.env');
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Show instructions for setting up AI keys (call when AI could have helped but wasn't available)
|
|
209
|
-
*/
|
|
210
|
-
export function showAISetupHint(): void {
|
|
211
|
-
const keysPath = getKeysEnvPath();
|
|
212
|
-
console.log(`\n Tip: Enable AI error explanations by creating ${keysPath}`);
|
|
213
|
-
console.log(` Add: ANTHROPIC_API_KEY=sk-ant-... or OPENAI_API_KEY=sk-...`);
|
|
214
|
-
console.log(` See README.md for details.`);
|
|
215
|
-
}
|