@bobfrankston/gcards 0.1.2 → 0.1.5

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,132 @@
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
+ updatedAt: string;
32
+ hasPhoto?: boolean; /** Has non-default photo */
33
+ starred?: boolean; /** In starred/favorites group */
34
+ _delete?: string; /** Deletion reason. *prefix means skipped (e.g., *photo, *starred) */
35
+ guids?: string[]; /** User-defined GUIDs for cross-account tracking */
36
+ }
37
+
38
+ export interface DeletedEntry extends IndexEntry {
39
+ deletedAt: string;
40
+ }
41
+
42
+ // ============================================================
43
+ // Delete Queue (_delete.json)
44
+ // ============================================================
45
+
46
+ export interface DeleteQueueEntry {
47
+ resourceName: string;
48
+ displayName: string;
49
+ addedAt: string;
50
+ _delete: string; /** Reason for deletion (e.g., 'duplicate') */
51
+ }
52
+
53
+ export interface DeleteQueue {
54
+ updatedAt: string;
55
+ entries: DeleteQueueEntry[];
56
+ }
57
+
58
+ // ============================================================
59
+ // Push Status (status.json)
60
+ // ============================================================
61
+
62
+ export interface PushStatus {
63
+ lastPush: string; /** ISO timestamp of last push */
64
+ }
65
+
66
+ // ============================================================
67
+ // Sync Token (sync-token.json)
68
+ // ============================================================
69
+
70
+ export interface SyncTokenData {
71
+ syncToken: string;
72
+ savedAt: string;
73
+ }
74
+
75
+ // ============================================================
76
+ // Pending Changes (for push operations)
77
+ // ============================================================
78
+
79
+ export interface PendingChange {
80
+ type: 'update' | 'delete' | 'add';
81
+ resourceName: string;
82
+ displayName: string;
83
+ filePath: string;
84
+ _delete?: string; /** Deletion reason (for type='delete') */
85
+ }
86
+
87
+ // ============================================================
88
+ // User Paths
89
+ // ============================================================
90
+
91
+ export interface UserPaths {
92
+ userDir: string;
93
+ contactsDir: string; /** Active contacts synced from Google */
94
+ deletedDir: string; /** Backup of deleted contact files */
95
+ toDeleteDir: string; /** User requests to delete (move files here) */
96
+ toAddDir: string; /** User requests to add new contacts */
97
+ fixLogDir: string; /** Logs from gfix operations */
98
+ indexFile: string;
99
+ deletedFile: string; /** Deleted contacts index (deleted.json) */
100
+ deleteQueueFile: string; /** Pending deletions (_delete.json) */
101
+ photosFile: string; /** Photos from deleted contacts (photos.json) */
102
+ statusFile: string;
103
+ syncTokenFile: string;
104
+ tokenFile: string;
105
+ tokenWriteFile: string;
106
+ }
107
+
108
+ // ============================================================
109
+ // Extended Contact (with gcards-specific fields)
110
+ // ============================================================
111
+
112
+ import type { GooglePerson } from './types.ts';
113
+
114
+ /** GooglePerson with optional gcards deletion marker */
115
+ export interface GcardsContact extends GooglePerson {
116
+ _deleted?: boolean; /** Mark for deletion in contact JSON */
117
+ }
118
+
119
+ // ============================================================
120
+ // Helper: GUID in userDefined
121
+ // ============================================================
122
+
123
+ /** Standard key for gcards GUIDs in userDefined */
124
+ export const GCARDS_GUID_KEY = 'gcards_guid';
125
+
126
+ /** Extract gcards GUIDs from a contact's userDefined fields */
127
+ export function extractGuids(contact: GooglePerson): string[] {
128
+ return contact.userDefined
129
+ ?.filter(u => u.key === GCARDS_GUID_KEY)
130
+ .map(u => u.value || '')
131
+ .filter(v => v) || [];
132
+ }
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,143 @@
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
+ deleteQueueFile: path.join(userDir, '_delete.json'),
62
+ photosFile: path.join(userDir, 'photos.json'),
63
+ statusFile: path.join(userDir, 'status.json'),
64
+ syncTokenFile: path.join(userDir, 'sync-token.json'),
65
+ tokenFile: path.join(userDir, 'token.json'),
66
+ tokenWriteFile: path.join(userDir, 'token-write.json')
67
+ };
68
+ }
69
+
70
+ export function ensureUserDir(user: string): void {
71
+ const paths = getUserPaths(user);
72
+ if (!fs.existsSync(paths.userDir)) {
73
+ fs.mkdirSync(paths.userDir, { recursive: true });
74
+ }
75
+ }
76
+
77
+ export function loadIndex(paths: UserPaths): ContactIndex {
78
+ if (fs.existsSync(paths.indexFile)) {
79
+ return JSON.parse(fs.readFileSync(paths.indexFile, 'utf-8'));
80
+ }
81
+ return { contacts: {}, lastSync: '' };
82
+ }
83
+
84
+ export function resolveUser(cliUser: string, setAsDefault = false): string {
85
+ // 1. Use CLI-specified user if provided (and not 'default')
86
+ if (cliUser && cliUser !== 'default') {
87
+ const normalized = normalizeUser(cliUser);
88
+ if (setAsDefault) {
89
+ const config = loadConfig();
90
+ config.lastUser = normalized;
91
+ saveConfig(config);
92
+ }
93
+ return normalized;
94
+ }
95
+
96
+ // 2. Use lastUser from config (already normalized)
97
+ const config = loadConfig();
98
+ if (config.lastUser) {
99
+ return config.lastUser;
100
+ }
101
+
102
+ // 3. No user - require --user on first use
103
+ console.error('No user specified. Use --user <name> on first run (e.g., your Gmail username).');
104
+ console.error('This will be remembered for future runs.');
105
+ process.exit(1);
106
+ }
107
+
108
+ export function normalizeUser(user: string): string {
109
+ return user.toLowerCase().split(/[+@]/)[0].replace(/\./g, '');
110
+ }
111
+
112
+ export function getAllUsers(): string[] {
113
+ if (!fs.existsSync(DATA_DIR)) {
114
+ return [];
115
+ }
116
+ return fs.readdirSync(DATA_DIR)
117
+ .filter(f => {
118
+ const fullPath = path.join(DATA_DIR, f);
119
+ return fs.statSync(fullPath).isDirectory() && !f.startsWith('.');
120
+ });
121
+ }
122
+
123
+ /** Logger that writes to both console and a file */
124
+ export class FileLogger {
125
+ private filePath: string;
126
+
127
+ constructor(filePath: string, clear = true) {
128
+ this.filePath = filePath;
129
+ if (clear && fs.existsSync(filePath)) {
130
+ fs.unlinkSync(filePath);
131
+ }
132
+ }
133
+
134
+ log(message: string): void {
135
+ console.log(message);
136
+ fs.appendFileSync(this.filePath, message + '\n');
137
+ }
138
+
139
+ error(message: string): void {
140
+ console.log(message);
141
+ fs.appendFileSync(this.filePath, message + '\n');
142
+ }
143
+ }
@@ -7,8 +7,11 @@ 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) */
14
+ since: string; /** Override lastSync - check files since this datetime */
12
15
  }
13
16
 
14
17
  export function parseArgs(args: string[]): CliOptions {
@@ -17,8 +20,11 @@ export function parseArgs(args: string[]): CliOptions {
17
20
  user: 'default',
18
21
  full: false,
19
22
  yes: false,
23
+ all: false,
20
24
  help: false,
21
- verbose: false
25
+ verbose: false,
26
+ limit: 0,
27
+ since: ''
22
28
  };
23
29
 
24
30
  for (let i = 0; i < args.length; i++) {
@@ -31,8 +37,14 @@ export function parseArgs(args: string[]): CliOptions {
31
37
  options.help = true;
32
38
  } else if (arg === '--verbose' || arg === '-v') {
33
39
  options.verbose = true;
40
+ } else if (arg === '--all' || arg === '-all' || arg === '-a') {
41
+ options.all = true;
34
42
  } else if ((arg === '--user' || arg === '-user' || arg === '-u') && i + 1 < args.length) {
35
43
  options.user = args[++i];
44
+ } else if ((arg === '--limit' || arg === '-limit') && i + 1 < args.length) {
45
+ options.limit = parseInt(args[++i], 10) || 0;
46
+ } else if ((arg === '--since' || arg === '-since') && i + 1 < args.length) {
47
+ options.since = args[++i];
36
48
  } else if (!arg.startsWith('-') && !options.command) {
37
49
  options.command = arg;
38
50
  }
@@ -41,6 +53,19 @@ export function parseArgs(args: string[]): CliOptions {
41
53
  return options;
42
54
  }
43
55
 
56
+ export function showUsage(): void {
57
+ console.log(`
58
+ gcards - Google Contacts management tool
59
+
60
+ Usage: gcards <command> [options]
61
+
62
+ Commands: sync, push
63
+ Options: -u <user>, -a (all), -f (full), -y (yes), -v (verbose), -limit <n>
64
+
65
+ Use --help or -h for detailed help including deletion instructions.
66
+ `);
67
+ }
68
+
44
69
  export function showHelp(): void {
45
70
  console.log(`
46
71
  gcards - Google Contacts management tool
@@ -52,22 +77,37 @@ Commands:
52
77
  push Push local changes to Google (with confirmation)
53
78
 
54
79
  Options:
55
- --user, -u NAME User profile (default: 'default')
80
+ --user, -u NAME User profile (required on first run)
81
+ --all, -a Process all users
56
82
  --full, -f Force full sync (ignore sync token)
57
83
  --yes, -y Skip confirmation prompt
58
84
  --verbose, -v Verbose output
85
+ --limit, -limit N Process only first N entries (for debugging)
59
86
  --help, -h Show this help
60
87
 
61
88
  Deletion:
62
89
  To delete a contact from Google, either:
63
- - Edit the contact's JSON and add the field: "_deleted": true
64
- - Or move the JSON file to the <user>/deleted/ folder
90
+ - In index.json, add "_delete": true to the contact's entry
91
+ - In the contact's JSON, add "_deleted": true
92
+ - Or move the JSON file to the <user>/_delete/ folder
65
93
  Then run 'gcards push' to apply the deletion
66
94
 
95
+ Folders:
96
+ contacts/ Active contacts synced from Google
97
+ deleted/ Tombstones (contacts Google has deleted)
98
+ _delete/ Contacts you want to delete (pending push)
99
+ _add/ New contacts to create (pending push)
100
+
67
101
  Examples:
68
- gcards sync # Sync default user
102
+ gcards sync # Sync with last used user
69
103
  gcards sync -u bob # Sync bob's contacts
70
104
  gcards sync --full -u alice # Full sync for alice
71
105
  gcards push -u bob # Push bob's changes
106
+
107
+ Workflow for merging duplicates:
108
+ gfix undup -u bob # Detect duplicates -> merger.json
109
+ (edit merger.json to add _delete for unwanted contacts)
110
+ gfix merge -u bob # Apply merge locally
111
+ gcards push -u bob # Push changes to Google
72
112
  `);
73
113
  }