@bobfrankston/gcards 0.1.11 → 0.1.13
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/README.md +25 -0
- package/gcards.ts +41 -19
- package/gfix.ts +2 -2
- package/glib/aihelper.ts +19 -0
- package/glib/gctypes.ts +1 -0
- package/glib/gutils.ts +17 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -242,11 +242,36 @@ After export, run `gcards push -u <target>` to upload.
|
|
|
242
242
|
|
|
243
243
|
## Setup
|
|
244
244
|
|
|
245
|
+
### Google API Setup
|
|
246
|
+
|
|
245
247
|
1. Create Google Cloud project
|
|
246
248
|
2. Enable People API
|
|
247
249
|
3. Create OAuth2 credentials (Desktop app type)
|
|
248
250
|
4. Download `credentials.json` to the gcards directory
|
|
249
251
|
|
|
252
|
+
### AI Error Explanations (Optional)
|
|
253
|
+
|
|
254
|
+
When API errors occur, gcards can use AI to explain what went wrong and suggest fixes.
|
|
255
|
+
|
|
256
|
+
1. Create `keys.env` in your gcards app directory:
|
|
257
|
+
- **Windows**: `%APPDATA%\gcards\keys.env`
|
|
258
|
+
- **Linux/Mac**: `~/.config/gcards/keys.env`
|
|
259
|
+
|
|
260
|
+
2. Add your API key(s):
|
|
261
|
+
```
|
|
262
|
+
ANTHROPIC_API_KEY=sk-ant-api03-...
|
|
263
|
+
```
|
|
264
|
+
or
|
|
265
|
+
```
|
|
266
|
+
OPENAI_API_KEY=sk-...
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**Get API keys:**
|
|
270
|
+
- Anthropic: https://console.anthropic.com/settings/keys
|
|
271
|
+
- OpenAI: https://platform.openai.com/api-keys
|
|
272
|
+
|
|
273
|
+
The tool tries Anthropic first, then falls back to OpenAI if available.
|
|
274
|
+
|
|
250
275
|
## License
|
|
251
276
|
|
|
252
277
|
MIT
|
package/gcards.ts
CHANGED
|
@@ -14,7 +14,7 @@ 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
16
|
import { DATA_DIR, CREDENTIALS_FILE, loadConfig, saveConfig, getUserPaths, ensureUserDir, loadIndex, normalizeUser, getAllUsers, resolveUser, FileLogger } from './glib/gutils.ts';
|
|
17
|
-
import { explainApiError, isAIAvailable } from './glib/aihelper.ts';
|
|
17
|
+
import { explainApiError, isAIAvailable, showAISetupHint } from './glib/aihelper.ts';
|
|
18
18
|
|
|
19
19
|
const PEOPLE_API_BASE = 'https://people.googleapis.com/v1';
|
|
20
20
|
const CONTACTS_SCOPE_READ = 'https://www.googleapis.com/auth/contacts.readonly';
|
|
@@ -77,6 +77,7 @@ function cleanupEscapeHandler(): void {
|
|
|
77
77
|
if (process.stdin.isTTY) {
|
|
78
78
|
process.stdin.setRawMode(false);
|
|
79
79
|
process.stdin.pause();
|
|
80
|
+
process.stdin.removeAllListeners('data');
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
83
|
|
|
@@ -124,11 +125,24 @@ async function refreshAccessToken(): Promise<string> {
|
|
|
124
125
|
return currentAccessToken;
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
/** Get sort key from contact: fileAs > displayNameLastFirst > displayName */
|
|
129
|
+
function getSortKey(person: GooglePerson): string {
|
|
130
|
+
const fileAs = person.fileAses?.[0]?.value;
|
|
131
|
+
if (fileAs) return fileAs;
|
|
132
|
+
const name = person.names?.[0];
|
|
133
|
+
if (name?.displayNameLastFirst) return name.displayNameLastFirst;
|
|
134
|
+
return name?.displayName || '';
|
|
135
|
+
}
|
|
136
|
+
|
|
127
137
|
function saveIndex(paths: UserPaths, index: ContactIndex): void {
|
|
128
|
-
// Sort contacts by
|
|
138
|
+
// Sort contacts by sortKey (fileAs > displayNameLastFirst > displayName)
|
|
129
139
|
const sortedContacts: Record<string, IndexEntry> = {};
|
|
130
140
|
const entries = Object.entries(index.contacts);
|
|
131
|
-
entries.sort((a, b) =>
|
|
141
|
+
entries.sort((a, b) => {
|
|
142
|
+
const keyA = a[1].sortKey || a[1].displayName || '';
|
|
143
|
+
const keyB = b[1].sortKey || b[1].displayName || '';
|
|
144
|
+
return keyA.localeCompare(keyB);
|
|
145
|
+
});
|
|
132
146
|
for (const [key, value] of entries) {
|
|
133
147
|
sortedContacts[key] = value;
|
|
134
148
|
}
|
|
@@ -452,10 +466,12 @@ async function syncContacts(user: string, options: { full: boolean; verbose: boo
|
|
|
452
466
|
// Preserve _delete flag from existing entry
|
|
453
467
|
const existingDelete = index.contacts[person.resourceName]?._delete;
|
|
454
468
|
|
|
469
|
+
const sortKey = getSortKey(person);
|
|
455
470
|
index.contacts[person.resourceName] = {
|
|
456
471
|
resourceName: person.resourceName,
|
|
457
472
|
displayName,
|
|
458
473
|
updatedAt: new Date().toISOString(),
|
|
474
|
+
sortKey,
|
|
459
475
|
...(hasPhoto && { hasPhoto }),
|
|
460
476
|
...(starred && { starred }),
|
|
461
477
|
...(guids.length > 0 && { guids }),
|
|
@@ -912,10 +928,12 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
912
928
|
const starred = isStarred(created);
|
|
913
929
|
|
|
914
930
|
// Add to index
|
|
931
|
+
const sortKey = getSortKey(created);
|
|
915
932
|
index.contacts[created.resourceName] = {
|
|
916
933
|
resourceName: created.resourceName,
|
|
917
934
|
displayName: change.displayName,
|
|
918
935
|
updatedAt: new Date().toISOString(),
|
|
936
|
+
sortKey,
|
|
919
937
|
...(hasPhoto && { hasPhoto }),
|
|
920
938
|
...(starred && { starred }),
|
|
921
939
|
...(guids.length > 0 && { guids })
|
|
@@ -1036,23 +1054,27 @@ async function pushContacts(user: string, options: { yes: boolean; verbose: bool
|
|
|
1036
1054
|
errorCount++;
|
|
1037
1055
|
|
|
1038
1056
|
// Get AI explanation if available and error is from API
|
|
1039
|
-
if (error instanceof ApiError && error.payload
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1057
|
+
if (error instanceof ApiError && error.payload) {
|
|
1058
|
+
if (isAIAvailable()) {
|
|
1059
|
+
try {
|
|
1060
|
+
const explanation = await explainApiError(error.message, error.payload);
|
|
1061
|
+
if (explanation) {
|
|
1062
|
+
console.log(`\n AI Analysis:`);
|
|
1063
|
+
if (explanation.explanation) {
|
|
1064
|
+
console.log(` Problem: ${explanation.explanation}`);
|
|
1065
|
+
}
|
|
1066
|
+
if (explanation.suggestedFix) {
|
|
1067
|
+
console.log(` Fix: ${explanation.suggestedFix}`);
|
|
1068
|
+
}
|
|
1069
|
+
if (explanation.correctedJson) {
|
|
1070
|
+
console.log(` Corrected JSON:\n${explanation.correctedJson.split('\n').map(l => ' ' + l).join('\n')}`);
|
|
1071
|
+
}
|
|
1072
|
+
problems.push(` AI: ${explanation.explanation || ''} Fix: ${explanation.suggestedFix || ''}`);
|
|
1052
1073
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1074
|
+
} catch { /* ignore AI errors */ }
|
|
1075
|
+
} else {
|
|
1076
|
+
showAISetupHint();
|
|
1077
|
+
}
|
|
1056
1078
|
}
|
|
1057
1079
|
}
|
|
1058
1080
|
}
|
package/gfix.ts
CHANGED
|
@@ -1898,12 +1898,12 @@ async function main(): Promise<void> {
|
|
|
1898
1898
|
console.error('Usage: gfix export -u <source> -to <target> <pattern>');
|
|
1899
1899
|
process.exit(1);
|
|
1900
1900
|
}
|
|
1901
|
-
const resolvedSource = resolveUser(user);
|
|
1901
|
+
const resolvedSource = resolveUser(user, true);
|
|
1902
1902
|
await runExport(resolvedSource, targetUser, positionalArgs[0]);
|
|
1903
1903
|
return;
|
|
1904
1904
|
}
|
|
1905
1905
|
|
|
1906
|
-
const resolvedUser = resolveUser(user);
|
|
1906
|
+
const resolvedUser = resolveUser(user, true);
|
|
1907
1907
|
|
|
1908
1908
|
if (processLimit > 0) {
|
|
1909
1909
|
console.log(`[DEBUG] Processing limited to first ${processLimit} contacts\n`);
|
package/glib/aihelper.ts
CHANGED
|
@@ -194,3 +194,22 @@ export async function explainApiError(errorMessage: string, payload: unknown): P
|
|
|
194
194
|
export function isAIAvailable(): boolean {
|
|
195
195
|
return !!(process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY);
|
|
196
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
|
+
}
|
package/glib/gctypes.ts
CHANGED
|
@@ -29,6 +29,7 @@ export interface IndexEntry {
|
|
|
29
29
|
resourceName: string;
|
|
30
30
|
displayName: string;
|
|
31
31
|
updatedAt: string;
|
|
32
|
+
sortKey?: string; /** Sort key: fileAs, displayNameLastFirst, or displayName */
|
|
32
33
|
hasPhoto?: boolean; /** Has non-default photo */
|
|
33
34
|
starred?: boolean; /** In starred/favorites group */
|
|
34
35
|
_delete?: string; /** Deletion reason. *prefix means skipped (e.g., *photo, *starred) */
|
package/glib/gutils.ts
CHANGED
|
@@ -125,9 +125,23 @@ export function resolveUser(cliUser: string, setAsDefault = false): string {
|
|
|
125
125
|
return config.lastUser;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
// 3.
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
// 3. Auto-detect if only one user exists
|
|
129
|
+
const allUsers = getAllUsers();
|
|
130
|
+
if (allUsers.length === 1) {
|
|
131
|
+
const onlyUser = allUsers[0];
|
|
132
|
+
console.log(`Auto-selected only user: ${onlyUser}`);
|
|
133
|
+
config.lastUser = onlyUser;
|
|
134
|
+
saveConfig(config);
|
|
135
|
+
return onlyUser;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 4. No user - require --user on first use
|
|
139
|
+
if (allUsers.length > 1) {
|
|
140
|
+
console.error(`Multiple users exist: ${allUsers.join(', ')}`);
|
|
141
|
+
console.error('Use -u <name> to select one.');
|
|
142
|
+
} else {
|
|
143
|
+
console.error('No user specified. Use -u <email> on first run (e.g., your Gmail address).');
|
|
144
|
+
}
|
|
131
145
|
process.exit(1);
|
|
132
146
|
}
|
|
133
147
|
|