@bobfrankston/gcards 0.1.0 → 0.1.4
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/.gitattributes +1 -0
- package/.vscode/settings.json +22 -2
- package/README.md +215 -61
- package/gcards.ts +498 -185
- package/gfix.ts +1472 -0
- package/glib/gctypes.ts +110 -0
- package/glib/gmerge.ts +290 -0
- package/glib/gutils.ts +141 -0
- package/{cli.ts → glib/parsecli.ts} +41 -5
- package/{types.ts → glib/types.ts} +305 -305
- package/package.json +6 -4
package/glib/gctypes.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gcards Type Definitions
|
|
3
|
+
* Types for gcards index, config, and related structures
|
|
4
|
+
* Other tools can import these to work with gcards data
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Re-export Google types for convenience
|
|
8
|
+
export * from './types.ts';
|
|
9
|
+
|
|
10
|
+
// ============================================================
|
|
11
|
+
// gcards Configuration
|
|
12
|
+
// ============================================================
|
|
13
|
+
|
|
14
|
+
export interface GcardsConfig {
|
|
15
|
+
lastUser?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ============================================================
|
|
19
|
+
// Contact Index (index.json)
|
|
20
|
+
// ============================================================
|
|
21
|
+
|
|
22
|
+
export interface ContactIndex {
|
|
23
|
+
contacts: Record<string, IndexEntry>; /** resourceName -> entry */
|
|
24
|
+
lastSync: string;
|
|
25
|
+
syncToken?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface IndexEntry {
|
|
29
|
+
resourceName: string;
|
|
30
|
+
displayName: string;
|
|
31
|
+
etag?: string;
|
|
32
|
+
deleted: boolean; /** Google deleted this contact */
|
|
33
|
+
deletedAt?: string;
|
|
34
|
+
updatedAt: string;
|
|
35
|
+
_delete?: boolean; /** Request to delete on next push */
|
|
36
|
+
guids?: string[]; /** User-defined GUIDs for cross-account tracking */
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ============================================================
|
|
40
|
+
// Push Status (status.json)
|
|
41
|
+
// ============================================================
|
|
42
|
+
|
|
43
|
+
export interface PushStatus {
|
|
44
|
+
lastPush: string; /** ISO timestamp of last push */
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ============================================================
|
|
48
|
+
// Sync Token (sync-token.json)
|
|
49
|
+
// ============================================================
|
|
50
|
+
|
|
51
|
+
export interface SyncTokenData {
|
|
52
|
+
syncToken: string;
|
|
53
|
+
savedAt: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ============================================================
|
|
57
|
+
// Pending Changes (for push operations)
|
|
58
|
+
// ============================================================
|
|
59
|
+
|
|
60
|
+
export interface PendingChange {
|
|
61
|
+
type: 'update' | 'delete' | 'add';
|
|
62
|
+
resourceName: string;
|
|
63
|
+
displayName: string;
|
|
64
|
+
filePath: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ============================================================
|
|
68
|
+
// User Paths
|
|
69
|
+
// ============================================================
|
|
70
|
+
|
|
71
|
+
export interface UserPaths {
|
|
72
|
+
userDir: string;
|
|
73
|
+
contactsDir: string; /** Active contacts synced from Google */
|
|
74
|
+
deletedDir: string; /** Google-deleted tombstones */
|
|
75
|
+
toDeleteDir: string; /** User requests to delete */
|
|
76
|
+
toAddDir: string; /** User requests to add new contacts */
|
|
77
|
+
fixLogDir: string; /** Logs from gfix operations */
|
|
78
|
+
indexFile: string;
|
|
79
|
+
deletedFile: string; /** Deleted contacts index (deleted.json) */
|
|
80
|
+
statusFile: string;
|
|
81
|
+
syncTokenFile: string;
|
|
82
|
+
tokenFile: string;
|
|
83
|
+
tokenWriteFile: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ============================================================
|
|
87
|
+
// Extended Contact (with gcards-specific fields)
|
|
88
|
+
// ============================================================
|
|
89
|
+
|
|
90
|
+
import type { GooglePerson } from './types.ts';
|
|
91
|
+
|
|
92
|
+
/** GooglePerson with optional gcards deletion marker */
|
|
93
|
+
export interface GcardsContact extends GooglePerson {
|
|
94
|
+
_deleted?: boolean; /** Mark for deletion in contact JSON */
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ============================================================
|
|
98
|
+
// Helper: GUID in userDefined
|
|
99
|
+
// ============================================================
|
|
100
|
+
|
|
101
|
+
/** Standard key for gcards GUIDs in userDefined */
|
|
102
|
+
export const GCARDS_GUID_KEY = 'gcards_guid';
|
|
103
|
+
|
|
104
|
+
/** Extract gcards GUIDs from a contact's userDefined fields */
|
|
105
|
+
export function extractGuids(contact: GooglePerson): string[] {
|
|
106
|
+
return contact.userDefined
|
|
107
|
+
?.filter(u => u.key === GCARDS_GUID_KEY)
|
|
108
|
+
.map(u => u.value || '')
|
|
109
|
+
.filter(v => v) || [];
|
|
110
|
+
}
|
package/glib/gmerge.ts
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gmerge - Contact merge utilities
|
|
3
|
+
* Merges multiple Google contacts into one
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
GooglePerson,
|
|
8
|
+
GoogleEmailAddress,
|
|
9
|
+
GooglePhoneNumber,
|
|
10
|
+
GoogleAddress,
|
|
11
|
+
GoogleOrganization,
|
|
12
|
+
GoogleUrl,
|
|
13
|
+
GoogleMembership,
|
|
14
|
+
GoogleBiography,
|
|
15
|
+
GooglePhoto,
|
|
16
|
+
GoogleUserDefined
|
|
17
|
+
} from './types.ts';
|
|
18
|
+
|
|
19
|
+
export interface MergeEntry {
|
|
20
|
+
name: string;
|
|
21
|
+
emails: string[];
|
|
22
|
+
resourceNames: string[];
|
|
23
|
+
_delete?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Normalize email for deduplication
|
|
28
|
+
*/
|
|
29
|
+
function normalizeEmail(email: string | undefined): string {
|
|
30
|
+
return (email || '').toLowerCase().trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalize phone for deduplication (digits only)
|
|
35
|
+
*/
|
|
36
|
+
function normalizePhone(phone: string | undefined): string {
|
|
37
|
+
return (phone || '').replace(/\D/g, '');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Normalize address for deduplication
|
|
42
|
+
*/
|
|
43
|
+
function normalizeAddress(addr: GoogleAddress): string {
|
|
44
|
+
return (addr.formattedValue || '').toLowerCase().trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Normalize organization for deduplication
|
|
49
|
+
*/
|
|
50
|
+
function normalizeOrg(org: GoogleOrganization): string {
|
|
51
|
+
return `${(org.name || '').toLowerCase()}|${(org.title || '').toLowerCase()}`.trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Normalize URL for deduplication
|
|
56
|
+
*/
|
|
57
|
+
function normalizeUrl(url: string | undefined): string {
|
|
58
|
+
return (url || '').toLowerCase().replace(/\/+$/, '').trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get membership group resource name for deduplication
|
|
63
|
+
*/
|
|
64
|
+
function getMembershipKey(m: GoogleMembership): string {
|
|
65
|
+
return m.contactGroupMembership?.contactGroupResourceName || '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Union arrays with deduplication
|
|
70
|
+
*/
|
|
71
|
+
function unionEmails(target: GoogleEmailAddress[], source: GoogleEmailAddress[]): GoogleEmailAddress[] {
|
|
72
|
+
const seen = new Set<string>();
|
|
73
|
+
const result: GoogleEmailAddress[] = [];
|
|
74
|
+
for (const e of [...target, ...source]) {
|
|
75
|
+
const key = normalizeEmail(e.value);
|
|
76
|
+
if (key && !seen.has(key)) {
|
|
77
|
+
seen.add(key);
|
|
78
|
+
result.push(e);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function unionPhones(target: GooglePhoneNumber[], source: GooglePhoneNumber[]): GooglePhoneNumber[] {
|
|
85
|
+
const seen = new Set<string>();
|
|
86
|
+
const result: GooglePhoneNumber[] = [];
|
|
87
|
+
for (const p of [...target, ...source]) {
|
|
88
|
+
const key = normalizePhone(p.value || p.canonicalForm);
|
|
89
|
+
if (key && !seen.has(key)) {
|
|
90
|
+
seen.add(key);
|
|
91
|
+
result.push(p);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function unionAddresses(target: GoogleAddress[], source: GoogleAddress[]): GoogleAddress[] {
|
|
98
|
+
const seen = new Set<string>();
|
|
99
|
+
const result: GoogleAddress[] = [];
|
|
100
|
+
for (const a of [...target, ...source]) {
|
|
101
|
+
const key = normalizeAddress(a);
|
|
102
|
+
if (key && !seen.has(key)) {
|
|
103
|
+
seen.add(key);
|
|
104
|
+
result.push(a);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function unionOrganizations(target: GoogleOrganization[], source: GoogleOrganization[]): GoogleOrganization[] {
|
|
111
|
+
const seen = new Set<string>();
|
|
112
|
+
const result: GoogleOrganization[] = [];
|
|
113
|
+
for (const o of [...target, ...source]) {
|
|
114
|
+
const key = normalizeOrg(o);
|
|
115
|
+
if (key && key !== '|' && !seen.has(key)) {
|
|
116
|
+
seen.add(key);
|
|
117
|
+
result.push(o);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function unionUrls(target: GoogleUrl[], source: GoogleUrl[]): GoogleUrl[] {
|
|
124
|
+
const seen = new Set<string>();
|
|
125
|
+
const result: GoogleUrl[] = [];
|
|
126
|
+
for (const u of [...target, ...source]) {
|
|
127
|
+
const key = normalizeUrl(u.value);
|
|
128
|
+
if (key && !seen.has(key)) {
|
|
129
|
+
seen.add(key);
|
|
130
|
+
result.push(u);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function unionMemberships(target: GoogleMembership[], source: GoogleMembership[]): GoogleMembership[] {
|
|
137
|
+
const seen = new Set<string>();
|
|
138
|
+
const result: GoogleMembership[] = [];
|
|
139
|
+
for (const m of [...target, ...source]) {
|
|
140
|
+
const key = getMembershipKey(m);
|
|
141
|
+
if (key && !seen.has(key)) {
|
|
142
|
+
seen.add(key);
|
|
143
|
+
result.push(m);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function unionUserDefined(target: GoogleUserDefined[], source: GoogleUserDefined[]): GoogleUserDefined[] {
|
|
150
|
+
const seen = new Set<string>();
|
|
151
|
+
const result: GoogleUserDefined[] = [];
|
|
152
|
+
for (const u of [...target, ...source]) {
|
|
153
|
+
const key = `${u.key}:${u.value}`;
|
|
154
|
+
if (!seen.has(key)) {
|
|
155
|
+
seen.add(key);
|
|
156
|
+
result.push(u);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Concatenate biographies/notes
|
|
164
|
+
*/
|
|
165
|
+
function mergeBiographies(target: GoogleBiography[], source: GoogleBiography[]): GoogleBiography[] {
|
|
166
|
+
const targetText = target.map(b => b.value || '').filter(v => v).join('\n\n');
|
|
167
|
+
const sourceText = source.map(b => b.value || '').filter(v => v).join('\n\n');
|
|
168
|
+
|
|
169
|
+
if (!sourceText) return target;
|
|
170
|
+
if (!targetText) return source;
|
|
171
|
+
|
|
172
|
+
const merged = `${targetText}\n\n[Merged]:\n${sourceText}`;
|
|
173
|
+
return [{ value: merged, contentType: 'TEXT_PLAIN' }];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Merge photos - prefer custom (non-default) over default
|
|
178
|
+
*/
|
|
179
|
+
function mergePhotos(target: GooglePhoto[], source: GooglePhoto[]): GooglePhoto[] {
|
|
180
|
+
const targetHasCustom = target.some(p => p.default === false);
|
|
181
|
+
const sourceHasCustom = source.some(p => p.default === false);
|
|
182
|
+
|
|
183
|
+
// If source has custom and target doesn't, use source
|
|
184
|
+
if (sourceHasCustom && !targetHasCustom) {
|
|
185
|
+
return source.filter(p => p.default === false);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Otherwise keep target
|
|
189
|
+
return target;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Merge multiple contacts into one
|
|
194
|
+
* Returns the merged contact data (without resourceName/etag - caller handles that)
|
|
195
|
+
*/
|
|
196
|
+
export function mergeContacts(target: GooglePerson, sources: GooglePerson[]): Partial<GooglePerson> {
|
|
197
|
+
// Start with target data
|
|
198
|
+
// Note: photos excluded - can't update via updateContact API
|
|
199
|
+
const merged: Partial<GooglePerson> = {
|
|
200
|
+
names: target.names,
|
|
201
|
+
nicknames: target.nicknames,
|
|
202
|
+
birthdays: target.birthdays,
|
|
203
|
+
addresses: target.addresses || [],
|
|
204
|
+
emailAddresses: target.emailAddresses || [],
|
|
205
|
+
phoneNumbers: target.phoneNumbers || [],
|
|
206
|
+
organizations: target.organizations || [],
|
|
207
|
+
biographies: target.biographies || [],
|
|
208
|
+
urls: target.urls || [],
|
|
209
|
+
memberships: target.memberships || [],
|
|
210
|
+
userDefined: target.userDefined || [],
|
|
211
|
+
fileAses: target.fileAses
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Merge each source
|
|
215
|
+
for (const source of sources) {
|
|
216
|
+
// Union arrays
|
|
217
|
+
merged.emailAddresses = unionEmails(merged.emailAddresses || [], source.emailAddresses || []);
|
|
218
|
+
merged.phoneNumbers = unionPhones(merged.phoneNumbers || [], source.phoneNumbers || []);
|
|
219
|
+
merged.addresses = unionAddresses(merged.addresses || [], source.addresses || []);
|
|
220
|
+
merged.organizations = unionOrganizations(merged.organizations || [], source.organizations || []);
|
|
221
|
+
merged.urls = unionUrls(merged.urls || [], source.urls || []);
|
|
222
|
+
merged.memberships = unionMemberships(merged.memberships || [], source.memberships || []);
|
|
223
|
+
merged.userDefined = unionUserDefined(merged.userDefined || [], source.userDefined || []);
|
|
224
|
+
|
|
225
|
+
// Concatenate biographies
|
|
226
|
+
merged.biographies = mergeBiographies(merged.biographies || [], source.biographies || []);
|
|
227
|
+
|
|
228
|
+
// Note: photos skipped - can't update via updateContact API
|
|
229
|
+
|
|
230
|
+
// Use name if target is empty
|
|
231
|
+
if (!merged.names?.length && source.names?.length) {
|
|
232
|
+
merged.names = source.names;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Merge birthdays (keep first non-empty)
|
|
236
|
+
if (!merged.birthdays?.length && source.birthdays?.length) {
|
|
237
|
+
merged.birthdays = source.birthdays;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Merge events, relations (union would be complex, just keep target for now)
|
|
241
|
+
// These are less common fields
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return merged;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Photo entry for photos.json - preserves photos from deleted contacts
|
|
249
|
+
*/
|
|
250
|
+
export interface PhotoEntry {
|
|
251
|
+
contactId: string; // resourceName of surviving contact
|
|
252
|
+
name: string; // display name
|
|
253
|
+
photos: string[]; // URLs from deleted contacts
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Collect photo URLs from source contacts being deleted
|
|
258
|
+
* Returns URLs of non-default (custom) photos only
|
|
259
|
+
*/
|
|
260
|
+
export function collectSourcePhotos(sources: GooglePerson[]): string[] {
|
|
261
|
+
const urls: string[] = [];
|
|
262
|
+
for (const source of sources) {
|
|
263
|
+
for (const photo of source.photos || []) {
|
|
264
|
+
// Only collect custom photos (default: false means custom)
|
|
265
|
+
if (photo.url && photo.default === false) {
|
|
266
|
+
urls.push(photo.url);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return urls;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Fields mask for update - all fields we might have modified
|
|
275
|
+
* Note: photos cannot be updated via updateContact (use separate photo API)
|
|
276
|
+
*/
|
|
277
|
+
export const MERGE_UPDATE_MASK = [
|
|
278
|
+
'names',
|
|
279
|
+
'nicknames',
|
|
280
|
+
'birthdays',
|
|
281
|
+
'addresses',
|
|
282
|
+
'emailAddresses',
|
|
283
|
+
'phoneNumbers',
|
|
284
|
+
'organizations',
|
|
285
|
+
'biographies',
|
|
286
|
+
'urls',
|
|
287
|
+
'memberships',
|
|
288
|
+
'userDefined',
|
|
289
|
+
'fileAses'
|
|
290
|
+
].join(',');
|
package/glib/gutils.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for gcards tools
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import type { GcardsConfig, ContactIndex, UserPaths } from './gctypes.ts';
|
|
9
|
+
|
|
10
|
+
export const APP_DIR = path.dirname(import.meta.dirname); // Parent of glib/
|
|
11
|
+
|
|
12
|
+
/** Get the app directory (%APPDATA%\gcards or ~/.config/gcards) */
|
|
13
|
+
export function getAppDir(): string {
|
|
14
|
+
if (process.platform === 'win32') {
|
|
15
|
+
return path.join(process.env.APPDATA || os.homedir(), 'gcards');
|
|
16
|
+
}
|
|
17
|
+
return path.join(os.homedir(), '.config', 'gcards');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Get the data directory (app directory/data, or local data/ symlink for dev) */
|
|
21
|
+
export function getDataDir(): string {
|
|
22
|
+
// Check for local data/ symlink first (for development)
|
|
23
|
+
// This should point to %APPDATA%\gcards\data (the data directory)
|
|
24
|
+
const localData = path.join(APP_DIR, 'data');
|
|
25
|
+
if (fs.existsSync(localData)) {
|
|
26
|
+
return localData;
|
|
27
|
+
}
|
|
28
|
+
return path.join(getAppDir(), 'data');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const DATA_DIR = getDataDir();
|
|
32
|
+
export const CONFIG_FILE = path.join(getAppDir(), 'config.json');
|
|
33
|
+
export const CREDENTIALS_FILE = path.join(APP_DIR, 'credentials.json');
|
|
34
|
+
|
|
35
|
+
export function loadConfig(): GcardsConfig {
|
|
36
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
37
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
|
38
|
+
}
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function saveConfig(config: GcardsConfig): void {
|
|
43
|
+
const appDir = getAppDir();
|
|
44
|
+
if (!fs.existsSync(appDir)) {
|
|
45
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getUserPaths(user: string): UserPaths {
|
|
51
|
+
const userDir = path.join(DATA_DIR, user);
|
|
52
|
+
return {
|
|
53
|
+
userDir,
|
|
54
|
+
contactsDir: path.join(userDir, 'contacts'),
|
|
55
|
+
deletedDir: path.join(userDir, 'deleted'),
|
|
56
|
+
toDeleteDir: path.join(userDir, '_delete'),
|
|
57
|
+
toAddDir: path.join(userDir, '_add'),
|
|
58
|
+
fixLogDir: path.join(userDir, 'fix-logs'),
|
|
59
|
+
indexFile: path.join(userDir, 'index.json'),
|
|
60
|
+
deletedFile: path.join(userDir, 'deleted.json'),
|
|
61
|
+
statusFile: path.join(userDir, 'status.json'),
|
|
62
|
+
syncTokenFile: path.join(userDir, 'sync-token.json'),
|
|
63
|
+
tokenFile: path.join(userDir, 'token.json'),
|
|
64
|
+
tokenWriteFile: path.join(userDir, 'token-write.json')
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function ensureUserDir(user: string): void {
|
|
69
|
+
const paths = getUserPaths(user);
|
|
70
|
+
if (!fs.existsSync(paths.userDir)) {
|
|
71
|
+
fs.mkdirSync(paths.userDir, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function loadIndex(paths: UserPaths): ContactIndex {
|
|
76
|
+
if (fs.existsSync(paths.indexFile)) {
|
|
77
|
+
return JSON.parse(fs.readFileSync(paths.indexFile, 'utf-8'));
|
|
78
|
+
}
|
|
79
|
+
return { contacts: {}, lastSync: '' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function resolveUser(cliUser: string, setAsDefault = false): string {
|
|
83
|
+
// 1. Use CLI-specified user if provided (and not 'default')
|
|
84
|
+
if (cliUser && cliUser !== 'default') {
|
|
85
|
+
const normalized = normalizeUser(cliUser);
|
|
86
|
+
if (setAsDefault) {
|
|
87
|
+
const config = loadConfig();
|
|
88
|
+
config.lastUser = normalized;
|
|
89
|
+
saveConfig(config);
|
|
90
|
+
}
|
|
91
|
+
return normalized;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 2. Use lastUser from config (already normalized)
|
|
95
|
+
const config = loadConfig();
|
|
96
|
+
if (config.lastUser) {
|
|
97
|
+
return config.lastUser;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 3. No user - require --user on first use
|
|
101
|
+
console.error('No user specified. Use --user <name> on first run (e.g., your Gmail username).');
|
|
102
|
+
console.error('This will be remembered for future runs.');
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function normalizeUser(user: string): string {
|
|
107
|
+
return user.toLowerCase().split(/[+@]/)[0].replace(/\./g, '');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function getAllUsers(): string[] {
|
|
111
|
+
if (!fs.existsSync(DATA_DIR)) {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
return fs.readdirSync(DATA_DIR)
|
|
115
|
+
.filter(f => {
|
|
116
|
+
const fullPath = path.join(DATA_DIR, f);
|
|
117
|
+
return fs.statSync(fullPath).isDirectory() && !f.startsWith('.');
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Logger that writes to both console and a file */
|
|
122
|
+
export class FileLogger {
|
|
123
|
+
private filePath: string;
|
|
124
|
+
|
|
125
|
+
constructor(filePath: string, clear = true) {
|
|
126
|
+
this.filePath = filePath;
|
|
127
|
+
if (clear && fs.existsSync(filePath)) {
|
|
128
|
+
fs.unlinkSync(filePath);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
log(message: string): void {
|
|
133
|
+
console.log(message);
|
|
134
|
+
fs.appendFileSync(this.filePath, message + '\n');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
error(message: string): void {
|
|
138
|
+
console.log(message);
|
|
139
|
+
fs.appendFileSync(this.filePath, message + '\n');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -7,8 +7,10 @@ export interface CliOptions {
|
|
|
7
7
|
user: string; /** User profile name (default: 'default') */
|
|
8
8
|
full: boolean; /** Force full sync instead of incremental */
|
|
9
9
|
yes: boolean; /** Skip confirmation prompt */
|
|
10
|
+
all: boolean; /** Process all users */
|
|
10
11
|
help: boolean;
|
|
11
12
|
verbose: boolean;
|
|
13
|
+
limit: number; /** Process only first n entries (0 = no limit) */
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export function parseArgs(args: string[]): CliOptions {
|
|
@@ -17,8 +19,10 @@ export function parseArgs(args: string[]): CliOptions {
|
|
|
17
19
|
user: 'default',
|
|
18
20
|
full: false,
|
|
19
21
|
yes: false,
|
|
22
|
+
all: false,
|
|
20
23
|
help: false,
|
|
21
|
-
verbose: false
|
|
24
|
+
verbose: false,
|
|
25
|
+
limit: 0
|
|
22
26
|
};
|
|
23
27
|
|
|
24
28
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -31,8 +35,12 @@ export function parseArgs(args: string[]): CliOptions {
|
|
|
31
35
|
options.help = true;
|
|
32
36
|
} else if (arg === '--verbose' || arg === '-v') {
|
|
33
37
|
options.verbose = true;
|
|
38
|
+
} else if (arg === '--all' || arg === '-all' || arg === '-a') {
|
|
39
|
+
options.all = true;
|
|
34
40
|
} else if ((arg === '--user' || arg === '-user' || arg === '-u') && i + 1 < args.length) {
|
|
35
41
|
options.user = args[++i];
|
|
42
|
+
} else if ((arg === '--limit' || arg === '-limit') && i + 1 < args.length) {
|
|
43
|
+
options.limit = parseInt(args[++i], 10) || 0;
|
|
36
44
|
} else if (!arg.startsWith('-') && !options.command) {
|
|
37
45
|
options.command = arg;
|
|
38
46
|
}
|
|
@@ -41,6 +49,19 @@ export function parseArgs(args: string[]): CliOptions {
|
|
|
41
49
|
return options;
|
|
42
50
|
}
|
|
43
51
|
|
|
52
|
+
export function showUsage(): void {
|
|
53
|
+
console.log(`
|
|
54
|
+
gcards - Google Contacts management tool
|
|
55
|
+
|
|
56
|
+
Usage: gcards <command> [options]
|
|
57
|
+
|
|
58
|
+
Commands: sync, push
|
|
59
|
+
Options: -u <user>, -a (all), -f (full), -y (yes), -v (verbose), -limit <n>
|
|
60
|
+
|
|
61
|
+
Use --help or -h for detailed help including deletion instructions.
|
|
62
|
+
`);
|
|
63
|
+
}
|
|
64
|
+
|
|
44
65
|
export function showHelp(): void {
|
|
45
66
|
console.log(`
|
|
46
67
|
gcards - Google Contacts management tool
|
|
@@ -52,22 +73,37 @@ Commands:
|
|
|
52
73
|
push Push local changes to Google (with confirmation)
|
|
53
74
|
|
|
54
75
|
Options:
|
|
55
|
-
--user, -u NAME User profile (
|
|
76
|
+
--user, -u NAME User profile (required on first run)
|
|
77
|
+
--all, -a Process all users
|
|
56
78
|
--full, -f Force full sync (ignore sync token)
|
|
57
79
|
--yes, -y Skip confirmation prompt
|
|
58
80
|
--verbose, -v Verbose output
|
|
81
|
+
--limit, -limit N Process only first N entries (for debugging)
|
|
59
82
|
--help, -h Show this help
|
|
60
83
|
|
|
61
84
|
Deletion:
|
|
62
85
|
To delete a contact from Google, either:
|
|
63
|
-
-
|
|
64
|
-
-
|
|
86
|
+
- In index.json, add "_delete": true to the contact's entry
|
|
87
|
+
- In the contact's JSON, add "_deleted": true
|
|
88
|
+
- Or move the JSON file to the <user>/_delete/ folder
|
|
65
89
|
Then run 'gcards push' to apply the deletion
|
|
66
90
|
|
|
91
|
+
Folders:
|
|
92
|
+
contacts/ Active contacts synced from Google
|
|
93
|
+
deleted/ Tombstones (contacts Google has deleted)
|
|
94
|
+
_delete/ Contacts you want to delete (pending push)
|
|
95
|
+
_add/ New contacts to create (pending push)
|
|
96
|
+
|
|
67
97
|
Examples:
|
|
68
|
-
gcards sync # Sync
|
|
98
|
+
gcards sync # Sync with last used user
|
|
69
99
|
gcards sync -u bob # Sync bob's contacts
|
|
70
100
|
gcards sync --full -u alice # Full sync for alice
|
|
71
101
|
gcards push -u bob # Push bob's changes
|
|
102
|
+
|
|
103
|
+
Workflow for merging duplicates:
|
|
104
|
+
gfix undup -u bob # Detect duplicates -> merger.json
|
|
105
|
+
(edit merger.json to add _delete for unwanted contacts)
|
|
106
|
+
gfix merge -u bob # Apply merge locally
|
|
107
|
+
gcards push -u bob # Push changes to Google
|
|
72
108
|
`);
|
|
73
109
|
}
|