@bobfrankston/gcards 0.1.0
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 -0
- package/.vscode/launch.json +21 -0
- package/.vscode/settings.json +3 -0
- package/.vscode/tasks.json +20 -0
- package/README.md +61 -0
- package/cli.ts +73 -0
- package/gcards.ts +577 -0
- package/package.json +35 -0
- package/tsconfig.json +27 -0
- package/types.ts +305 -0
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Google Contacts Cleanup Tool (gcards)
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
Intelligent cleanup and merging of Google Contacts using the People API.
|
|
5
|
+
|
|
6
|
+
## Roadmap
|
|
7
|
+
- **Current**: CLI tool
|
|
8
|
+
- **Future**: GUI interface
|
|
9
|
+
- **Eventually**: Own contacts database (vcf as intermediate format)
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
- Fetch contacts via Google People API (preserves `resourceName` for reconciliation)
|
|
13
|
+
- Convert to simplified JSON structure for analysis
|
|
14
|
+
- Identify duplicates: **exact match first** (many blatant dupes), fuzzy matching optional/later
|
|
15
|
+
- Flag test entries (pattern: `m-\d+@bob\.ma` for "Bob Frankston")
|
|
16
|
+
- Generate merge candidates with confidence scores
|
|
17
|
+
- Apply changes back via API using `resourceName`
|
|
18
|
+
- Export to vCard format (`vcf/` directory)
|
|
19
|
+
|
|
20
|
+
## Approach
|
|
21
|
+
1. **Auth**: OAuth2 with Google People API scope
|
|
22
|
+
2. **Fetch**: Full fetch first, save `syncToken` for incremental updates
|
|
23
|
+
3. **Transform**: Convert `GooglePerson[]` → `LocalContact[]` (see `types.ts`)
|
|
24
|
+
4. **Analyze**: Exact duplicate detection first, test entry flagging
|
|
25
|
+
5. **Review**: Output candidates for user review
|
|
26
|
+
6. **Apply**: Update/delete via API using `resourceName`
|
|
27
|
+
7. **Export**: Save to `vcf/` as vCards for local backup/future DB
|
|
28
|
+
8. **Log**: Track deletions in `deletion-log.json` to detect re-additions
|
|
29
|
+
|
|
30
|
+
## Sync Token Usage
|
|
31
|
+
- After full fetch, save `nextSyncToken`
|
|
32
|
+
- Subsequent fetches: pass `syncToken` → get only new/changed/deleted
|
|
33
|
+
- Deleted entries appear with `metadata.deleted: true`
|
|
34
|
+
- Token expires ~30 days → fall back to full fetch (410 GONE error)
|
|
35
|
+
|
|
36
|
+
## Deletion Logging
|
|
37
|
+
Maintain `deletion-log.json` with:
|
|
38
|
+
- `resourceName`, `displayName`, `reason`, `deletedAt`, `originalData`
|
|
39
|
+
- Allows detecting if cleaned-up contact reappears (re-synced, re-added)
|
|
40
|
+
|
|
41
|
+
## Setup
|
|
42
|
+
1. Create Google Cloud project
|
|
43
|
+
2. Enable People API
|
|
44
|
+
3. Create OAuth2 credentials (Desktop app)
|
|
45
|
+
4. Download `credentials.json` to this directory
|
|
46
|
+
|
|
47
|
+
## Directory Structure
|
|
48
|
+
```
|
|
49
|
+
gcards/
|
|
50
|
+
├── README.md
|
|
51
|
+
├── types.ts # TypeScript definitions
|
|
52
|
+
├── credentials.json # OAuth2 credentials (gitignored)
|
|
53
|
+
├── token.json # OAuth tokens (gitignored)
|
|
54
|
+
├── sync-token.json # Sync token for incremental fetch
|
|
55
|
+
├── deletion-log.json # Track deleted contacts
|
|
56
|
+
└── vcf/ # vCard exports for future DB
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Test Entry Pattern
|
|
60
|
+
|
|
61
|
+
Emails matching `m-[2506|2512]+@bob\.ma` with name "Bob Frankston" are test business cards to be flagged/removed.
|
package/cli.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI argument parsing for gcards
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface CliOptions {
|
|
6
|
+
command: string;
|
|
7
|
+
user: string; /** User profile name (default: 'default') */
|
|
8
|
+
full: boolean; /** Force full sync instead of incremental */
|
|
9
|
+
yes: boolean; /** Skip confirmation prompt */
|
|
10
|
+
help: boolean;
|
|
11
|
+
verbose: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseArgs(args: string[]): CliOptions {
|
|
15
|
+
const options: CliOptions = {
|
|
16
|
+
command: '',
|
|
17
|
+
user: 'default',
|
|
18
|
+
full: false,
|
|
19
|
+
yes: false,
|
|
20
|
+
help: false,
|
|
21
|
+
verbose: false
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < args.length; i++) {
|
|
25
|
+
const arg = args[i];
|
|
26
|
+
if (arg === '--full' || arg === '-f') {
|
|
27
|
+
options.full = true;
|
|
28
|
+
} else if (arg === '--yes' || arg === '-y') {
|
|
29
|
+
options.yes = true;
|
|
30
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
31
|
+
options.help = true;
|
|
32
|
+
} else if (arg === '--verbose' || arg === '-v') {
|
|
33
|
+
options.verbose = true;
|
|
34
|
+
} else if ((arg === '--user' || arg === '-user' || arg === '-u') && i + 1 < args.length) {
|
|
35
|
+
options.user = args[++i];
|
|
36
|
+
} else if (!arg.startsWith('-') && !options.command) {
|
|
37
|
+
options.command = arg;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return options;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function showHelp(): void {
|
|
45
|
+
console.log(`
|
|
46
|
+
gcards - Google Contacts management tool
|
|
47
|
+
|
|
48
|
+
Usage: gcards <command> [options]
|
|
49
|
+
|
|
50
|
+
Commands:
|
|
51
|
+
sync Sync contacts from Google (incremental by default)
|
|
52
|
+
push Push local changes to Google (with confirmation)
|
|
53
|
+
|
|
54
|
+
Options:
|
|
55
|
+
--user, -u NAME User profile (default: 'default')
|
|
56
|
+
--full, -f Force full sync (ignore sync token)
|
|
57
|
+
--yes, -y Skip confirmation prompt
|
|
58
|
+
--verbose, -v Verbose output
|
|
59
|
+
--help, -h Show this help
|
|
60
|
+
|
|
61
|
+
Deletion:
|
|
62
|
+
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
|
|
65
|
+
Then run 'gcards push' to apply the deletion
|
|
66
|
+
|
|
67
|
+
Examples:
|
|
68
|
+
gcards sync # Sync default user
|
|
69
|
+
gcards sync -u bob # Sync bob's contacts
|
|
70
|
+
gcards sync --full -u alice # Full sync for alice
|
|
71
|
+
gcards push -u bob # Push bob's changes
|
|
72
|
+
`);
|
|
73
|
+
}
|
package/gcards.ts
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gcards - Google Contacts management tool
|
|
4
|
+
* Downloads and manages Google Contacts with incremental sync support
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { parseArgs, showHelp } from './cli.ts';
|
|
10
|
+
import { authenticateOAuth } from '../../../projects/oauth/oauthsupport/index.ts';
|
|
11
|
+
import type { GooglePerson, GoogleConnectionsResponse } from './types.ts';
|
|
12
|
+
|
|
13
|
+
const APP_DIR = import.meta.dirname;
|
|
14
|
+
const DATA_DIR = path.join(APP_DIR, 'data');
|
|
15
|
+
const CREDENTIALS_FILE = path.join(APP_DIR, 'credentials.json');
|
|
16
|
+
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
|
17
|
+
|
|
18
|
+
const PEOPLE_API_BASE = 'https://people.googleapis.com/v1';
|
|
19
|
+
const CONTACTS_SCOPE_READ = 'https://www.googleapis.com/auth/contacts.readonly';
|
|
20
|
+
const CONTACTS_SCOPE_WRITE = 'https://www.googleapis.com/auth/contacts';
|
|
21
|
+
|
|
22
|
+
interface AppConfig {
|
|
23
|
+
lastUser?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface UserPaths {
|
|
27
|
+
userDir: string;
|
|
28
|
+
contactsDir: string;
|
|
29
|
+
deletedDir: string;
|
|
30
|
+
indexFile: string;
|
|
31
|
+
statusFile: string;
|
|
32
|
+
syncTokenFile: string;
|
|
33
|
+
tokenFile: string;
|
|
34
|
+
tokenWriteFile: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function loadConfig(): AppConfig {
|
|
38
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
39
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
|
40
|
+
}
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function saveConfig(config: AppConfig): void {
|
|
45
|
+
if (!fs.existsSync(DATA_DIR)) {
|
|
46
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Normalize Gmail username: lowercase, remove dots, strip +suffix and @domain */
|
|
52
|
+
function normalizeUser(user: string): string {
|
|
53
|
+
return user.toLowerCase().split(/[+@]/)[0].replace(/\./g, '');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getUserPaths(user: string): UserPaths {
|
|
57
|
+
const userDir = path.join(DATA_DIR, user);
|
|
58
|
+
return {
|
|
59
|
+
userDir,
|
|
60
|
+
contactsDir: path.join(userDir, 'contacts'),
|
|
61
|
+
deletedDir: path.join(userDir, 'deleted'),
|
|
62
|
+
indexFile: path.join(userDir, 'index.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
|
+
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
|
+
interface ContactIndex {
|
|
78
|
+
contacts: Record<string, IndexEntry>; /** resourceName -> entry */
|
|
79
|
+
lastSync: string;
|
|
80
|
+
syncToken?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface IndexEntry {
|
|
84
|
+
resourceName: string;
|
|
85
|
+
displayName: string;
|
|
86
|
+
etag?: string;
|
|
87
|
+
deleted: boolean;
|
|
88
|
+
deletedAt?: string;
|
|
89
|
+
updatedAt: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface PushStatus {
|
|
93
|
+
lastPush: string; /** ISO timestamp of last push */
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface PendingChange {
|
|
97
|
+
type: 'update' | 'delete';
|
|
98
|
+
resourceName: string;
|
|
99
|
+
displayName: string;
|
|
100
|
+
filePath: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function getAccessToken(user: string, writeAccess = false): Promise<string> {
|
|
104
|
+
if (!fs.existsSync(CREDENTIALS_FILE)) {
|
|
105
|
+
throw new Error(`Credentials file not found: ${CREDENTIALS_FILE}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const paths = getUserPaths(user);
|
|
109
|
+
ensureUserDir(user);
|
|
110
|
+
|
|
111
|
+
const scope = writeAccess ? CONTACTS_SCOPE_WRITE : CONTACTS_SCOPE_READ;
|
|
112
|
+
const tokenFileName = writeAccess ? 'token-write.json' : 'token.json';
|
|
113
|
+
|
|
114
|
+
const token = await authenticateOAuth(CREDENTIALS_FILE, {
|
|
115
|
+
scope,
|
|
116
|
+
tokenDirectory: paths.userDir,
|
|
117
|
+
tokenFileName,
|
|
118
|
+
credentialsKey: 'web',
|
|
119
|
+
includeOfflineAccess: true
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (!token) {
|
|
123
|
+
throw new Error('OAuth authentication failed');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return token.access_token;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function loadIndex(paths: UserPaths): ContactIndex {
|
|
130
|
+
if (fs.existsSync(paths.indexFile)) {
|
|
131
|
+
return JSON.parse(fs.readFileSync(paths.indexFile, 'utf-8'));
|
|
132
|
+
}
|
|
133
|
+
return { contacts: {}, lastSync: '' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function saveIndex(paths: UserPaths, index: ContactIndex): void {
|
|
137
|
+
fs.writeFileSync(paths.indexFile, JSON.stringify(index, null, 2));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function loadSyncToken(paths: UserPaths): string | null {
|
|
141
|
+
if (fs.existsSync(paths.syncTokenFile)) {
|
|
142
|
+
const data = JSON.parse(fs.readFileSync(paths.syncTokenFile, 'utf-8'));
|
|
143
|
+
return data.syncToken || null;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function saveSyncToken(paths: UserPaths, token: string): void {
|
|
149
|
+
fs.writeFileSync(paths.syncTokenFile, JSON.stringify({ syncToken: token, savedAt: new Date().toISOString() }, null, 2));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function sleep(ms: number): Promise<void> {
|
|
153
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function fetchContactsWithRetry(
|
|
157
|
+
accessToken: string,
|
|
158
|
+
syncToken?: string,
|
|
159
|
+
pageToken?: string,
|
|
160
|
+
retryCount = 0
|
|
161
|
+
): Promise<GoogleConnectionsResponse> {
|
|
162
|
+
const MAX_RETRIES = 10;
|
|
163
|
+
const BASE_DELAY = 5000; /** 5 seconds base delay */
|
|
164
|
+
|
|
165
|
+
const params = new URLSearchParams({
|
|
166
|
+
personFields: 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays,photos,memberships,metadata',
|
|
167
|
+
pageSize: '100' /** Smaller page size to avoid quota issues */
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (syncToken) {
|
|
171
|
+
params.set('syncToken', syncToken);
|
|
172
|
+
}
|
|
173
|
+
params.set('requestSyncToken', 'true');
|
|
174
|
+
|
|
175
|
+
if (pageToken) {
|
|
176
|
+
params.set('pageToken', pageToken);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const url = `${PEOPLE_API_BASE}/people/me/connections?${params}`;
|
|
180
|
+
const response = await fetch(url, {
|
|
181
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (response.status === 410) {
|
|
185
|
+
console.log('Sync token expired, performing full sync...');
|
|
186
|
+
return fetchContactsWithRetry(accessToken, undefined, pageToken, 0);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (response.status === 429) {
|
|
190
|
+
if (retryCount >= MAX_RETRIES) {
|
|
191
|
+
throw new Error(`Rate limit exceeded after ${MAX_RETRIES} retries`);
|
|
192
|
+
}
|
|
193
|
+
const delay = BASE_DELAY * Math.pow(2, retryCount); /** Exponential backoff */
|
|
194
|
+
console.log(`Rate limited, waiting ${delay / 1000}s before retry ${retryCount + 1}/${MAX_RETRIES}...`);
|
|
195
|
+
await sleep(delay);
|
|
196
|
+
return fetchContactsWithRetry(accessToken, syncToken, pageToken, retryCount + 1);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
throw new Error(`API error: ${response.status} ${await response.text()}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return await response.json() as GoogleConnectionsResponse;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function saveContact(paths: UserPaths, person: GooglePerson): void {
|
|
207
|
+
if (!fs.existsSync(paths.contactsDir)) {
|
|
208
|
+
fs.mkdirSync(paths.contactsDir, { recursive: true });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const id = person.resourceName.replace('people/', '');
|
|
212
|
+
const filePath = path.join(paths.contactsDir, `${id}.json`);
|
|
213
|
+
fs.writeFileSync(filePath, JSON.stringify(person, null, 2));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function deleteContactFile(paths: UserPaths, resourceName: string): void {
|
|
217
|
+
const id = resourceName.replace('people/', '');
|
|
218
|
+
const filePath = path.join(paths.contactsDir, `${id}.json`);
|
|
219
|
+
if (fs.existsSync(filePath)) {
|
|
220
|
+
fs.unlinkSync(filePath);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function syncContacts(user: string, options: { full: boolean; verbose: boolean }): Promise<void> {
|
|
225
|
+
const paths = getUserPaths(user);
|
|
226
|
+
ensureUserDir(user);
|
|
227
|
+
|
|
228
|
+
console.log(`Syncing contacts for user: ${user}`);
|
|
229
|
+
console.log('Getting access token...');
|
|
230
|
+
const accessToken = await getAccessToken(user);
|
|
231
|
+
|
|
232
|
+
const index = loadIndex(paths);
|
|
233
|
+
let syncToken = options.full ? null : loadSyncToken(paths);
|
|
234
|
+
|
|
235
|
+
if (syncToken) {
|
|
236
|
+
console.log('Performing incremental sync...');
|
|
237
|
+
} else {
|
|
238
|
+
console.log('Performing full sync...');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let pageToken: string | undefined;
|
|
242
|
+
let totalProcessed = 0;
|
|
243
|
+
let added = 0;
|
|
244
|
+
let updated = 0;
|
|
245
|
+
let deleted = 0;
|
|
246
|
+
let pageNum = 0;
|
|
247
|
+
|
|
248
|
+
do {
|
|
249
|
+
pageNum++;
|
|
250
|
+
process.stdout.write(`\rFetching page ${pageNum}... (${totalProcessed} contacts so far)`);
|
|
251
|
+
|
|
252
|
+
const response = await fetchContactsWithRetry(accessToken, syncToken || undefined, pageToken);
|
|
253
|
+
|
|
254
|
+
if (response.connections) {
|
|
255
|
+
for (const person of response.connections) {
|
|
256
|
+
const isDeleted = person.metadata?.deleted === true;
|
|
257
|
+
const displayName = person.names?.[0]?.displayName || 'Unknown';
|
|
258
|
+
|
|
259
|
+
if (isDeleted) {
|
|
260
|
+
if (index.contacts[person.resourceName]) {
|
|
261
|
+
index.contacts[person.resourceName].deleted = true;
|
|
262
|
+
index.contacts[person.resourceName].deletedAt = new Date().toISOString();
|
|
263
|
+
deleteContactFile(paths, person.resourceName);
|
|
264
|
+
deleted++;
|
|
265
|
+
if (options.verbose) console.log(`\n Deleted: ${displayName}`);
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
const existed = !!index.contacts[person.resourceName];
|
|
269
|
+
saveContact(paths, person);
|
|
270
|
+
|
|
271
|
+
index.contacts[person.resourceName] = {
|
|
272
|
+
resourceName: person.resourceName,
|
|
273
|
+
displayName,
|
|
274
|
+
etag: person.etag,
|
|
275
|
+
deleted: false,
|
|
276
|
+
updatedAt: new Date().toISOString()
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
if (existed) {
|
|
280
|
+
updated++;
|
|
281
|
+
if (options.verbose) console.log(`\n Updated: ${displayName}`);
|
|
282
|
+
} else {
|
|
283
|
+
added++;
|
|
284
|
+
if (options.verbose) console.log(`\n Added: ${displayName}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
totalProcessed++;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (response.nextSyncToken) {
|
|
292
|
+
saveSyncToken(paths, response.nextSyncToken);
|
|
293
|
+
index.syncToken = response.nextSyncToken;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
pageToken = response.nextPageToken;
|
|
297
|
+
|
|
298
|
+
// Save index after each page in case of interruption
|
|
299
|
+
index.lastSync = new Date().toISOString();
|
|
300
|
+
saveIndex(paths, index);
|
|
301
|
+
|
|
302
|
+
// Small delay between pages to be nice to the API
|
|
303
|
+
if (pageToken) {
|
|
304
|
+
await sleep(500);
|
|
305
|
+
}
|
|
306
|
+
} while (pageToken);
|
|
307
|
+
|
|
308
|
+
const activeContacts = Object.values(index.contacts).filter(c => !c.deleted).length;
|
|
309
|
+
const tombstones = Object.values(index.contacts).filter(c => c.deleted).length;
|
|
310
|
+
|
|
311
|
+
console.log(`\n\nSync complete:`);
|
|
312
|
+
console.log(` Processed: ${totalProcessed}`);
|
|
313
|
+
console.log(` Added: ${added}`);
|
|
314
|
+
console.log(` Updated: ${updated}`);
|
|
315
|
+
console.log(` Deleted: ${deleted}`);
|
|
316
|
+
console.log(` Active contacts: ${activeContacts}`);
|
|
317
|
+
console.log(` Tombstones: ${tombstones}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function loadStatus(paths: UserPaths): PushStatus {
|
|
321
|
+
if (fs.existsSync(paths.statusFile)) {
|
|
322
|
+
return JSON.parse(fs.readFileSync(paths.statusFile, 'utf-8'));
|
|
323
|
+
}
|
|
324
|
+
return { lastPush: '' };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function saveStatus(paths: UserPaths, status: PushStatus): void {
|
|
328
|
+
fs.writeFileSync(paths.statusFile, JSON.stringify(status, null, 2));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function findPendingChanges(paths: UserPaths): PendingChange[] {
|
|
332
|
+
const status = loadStatus(paths);
|
|
333
|
+
const lastPush = status.lastPush ? new Date(status.lastPush).getTime() : 0;
|
|
334
|
+
const changes: PendingChange[] = [];
|
|
335
|
+
|
|
336
|
+
// Check contacts/ for modified files and _deleted markers
|
|
337
|
+
if (fs.existsSync(paths.contactsDir)) {
|
|
338
|
+
const files = fs.readdirSync(paths.contactsDir).filter(f => f.endsWith('.json'));
|
|
339
|
+
for (const file of files) {
|
|
340
|
+
const filePath = path.join(paths.contactsDir, file);
|
|
341
|
+
const stat = fs.statSync(filePath);
|
|
342
|
+
const modTime = stat.mtimeMs;
|
|
343
|
+
|
|
344
|
+
if (modTime > lastPush) {
|
|
345
|
+
const content = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson & { _deleted?: boolean };
|
|
346
|
+
const displayName = content.names?.[0]?.displayName || 'Unknown';
|
|
347
|
+
|
|
348
|
+
if (content._deleted) {
|
|
349
|
+
changes.push({
|
|
350
|
+
type: 'delete',
|
|
351
|
+
resourceName: content.resourceName,
|
|
352
|
+
displayName,
|
|
353
|
+
filePath
|
|
354
|
+
});
|
|
355
|
+
} else {
|
|
356
|
+
changes.push({
|
|
357
|
+
type: 'update',
|
|
358
|
+
resourceName: content.resourceName,
|
|
359
|
+
displayName,
|
|
360
|
+
filePath
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Check deleted/ folder
|
|
368
|
+
if (fs.existsSync(paths.deletedDir)) {
|
|
369
|
+
const files = fs.readdirSync(paths.deletedDir).filter(f => f.endsWith('.json'));
|
|
370
|
+
for (const file of files) {
|
|
371
|
+
const filePath = path.join(paths.deletedDir, file);
|
|
372
|
+
const content = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GooglePerson;
|
|
373
|
+
const displayName = content.names?.[0]?.displayName || 'Unknown';
|
|
374
|
+
|
|
375
|
+
changes.push({
|
|
376
|
+
type: 'delete',
|
|
377
|
+
resourceName: content.resourceName,
|
|
378
|
+
displayName,
|
|
379
|
+
filePath
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return changes;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function confirm(message: string): Promise<boolean> {
|
|
388
|
+
const readline = await import('readline');
|
|
389
|
+
const rl = readline.createInterface({
|
|
390
|
+
input: process.stdin,
|
|
391
|
+
output: process.stdout
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
return new Promise(resolve => {
|
|
395
|
+
rl.question(`${message} (y/N): `, answer => {
|
|
396
|
+
rl.close();
|
|
397
|
+
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function updateContactOnGoogle(accessToken: string, person: GooglePerson): Promise<void> {
|
|
403
|
+
const updateMask = 'names,emailAddresses,phoneNumbers,addresses,organizations,biographies,urls,birthdays';
|
|
404
|
+
|
|
405
|
+
const response = await fetch(`${PEOPLE_API_BASE}/${person.resourceName}:updateContact?updatePersonFields=${updateMask}`, {
|
|
406
|
+
method: 'PATCH',
|
|
407
|
+
headers: {
|
|
408
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
409
|
+
'Content-Type': 'application/json'
|
|
410
|
+
},
|
|
411
|
+
body: JSON.stringify(person)
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
if (!response.ok) {
|
|
415
|
+
throw new Error(`Update failed for ${person.resourceName}: ${response.status} ${await response.text()}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function deleteContactOnGoogle(accessToken: string, resourceName: string): Promise<void> {
|
|
420
|
+
const response = await fetch(`${PEOPLE_API_BASE}/${resourceName}:deleteContact`, {
|
|
421
|
+
method: 'DELETE',
|
|
422
|
+
headers: { 'Authorization': `Bearer ${accessToken}` }
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
if (!response.ok) {
|
|
426
|
+
throw new Error(`Delete failed for ${resourceName}: ${response.status} ${await response.text()}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function pushContacts(user: string, options: { yes: boolean; verbose: boolean }): Promise<void> {
|
|
431
|
+
const paths = getUserPaths(user);
|
|
432
|
+
ensureUserDir(user);
|
|
433
|
+
|
|
434
|
+
console.log(`Pushing contacts for user: ${user}`);
|
|
435
|
+
const changes = findPendingChanges(paths);
|
|
436
|
+
|
|
437
|
+
if (changes.length === 0) {
|
|
438
|
+
console.log('No pending changes to push.');
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const updates = changes.filter(c => c.type === 'update');
|
|
443
|
+
const deletes = changes.filter(c => c.type === 'delete');
|
|
444
|
+
|
|
445
|
+
console.log('\nPending changes:');
|
|
446
|
+
if (updates.length > 0) {
|
|
447
|
+
console.log(`\n Updates (${updates.length}):`);
|
|
448
|
+
for (const c of updates) {
|
|
449
|
+
console.log(` - ${c.displayName} (${c.resourceName})`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (deletes.length > 0) {
|
|
453
|
+
console.log(`\n Deletions (${deletes.length}):`);
|
|
454
|
+
for (const c of deletes) {
|
|
455
|
+
console.log(` - ${c.displayName} (${c.resourceName})`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (!options.yes) {
|
|
460
|
+
const confirmed = await confirm(`\nPush ${changes.length} change(s) to Google?`);
|
|
461
|
+
if (!confirmed) {
|
|
462
|
+
console.log('Aborted.');
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
console.log('\nGetting access token (write access)...');
|
|
468
|
+
const accessToken = await getAccessToken(user, true);
|
|
469
|
+
|
|
470
|
+
const index = loadIndex(paths);
|
|
471
|
+
let successCount = 0;
|
|
472
|
+
let errorCount = 0;
|
|
473
|
+
|
|
474
|
+
for (const change of changes) {
|
|
475
|
+
try {
|
|
476
|
+
if (change.type === 'update') {
|
|
477
|
+
const content = JSON.parse(fs.readFileSync(change.filePath, 'utf-8')) as GooglePerson;
|
|
478
|
+
process.stdout.write(`Updating ${change.displayName}...`);
|
|
479
|
+
await updateContactOnGoogle(accessToken, content);
|
|
480
|
+
console.log(' done');
|
|
481
|
+
successCount++;
|
|
482
|
+
} else {
|
|
483
|
+
process.stdout.write(`Deleting ${change.displayName}...`);
|
|
484
|
+
await deleteContactOnGoogle(accessToken, change.resourceName);
|
|
485
|
+
|
|
486
|
+
// Update index
|
|
487
|
+
if (index.contacts[change.resourceName]) {
|
|
488
|
+
index.contacts[change.resourceName].deleted = true;
|
|
489
|
+
index.contacts[change.resourceName].deletedAt = new Date().toISOString();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Remove from deleted/ folder if it was there
|
|
493
|
+
if (change.filePath.startsWith(paths.deletedDir)) {
|
|
494
|
+
fs.unlinkSync(change.filePath);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
console.log(' done');
|
|
498
|
+
successCount++;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
await sleep(200); // Be nice to the API
|
|
502
|
+
} catch (error: any) {
|
|
503
|
+
console.log(` ERROR: ${error.message}`);
|
|
504
|
+
errorCount++;
|
|
505
|
+
debugger;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Save status with new timestamp
|
|
510
|
+
saveStatus(paths, { lastPush: new Date().toISOString() });
|
|
511
|
+
saveIndex(paths, index);
|
|
512
|
+
|
|
513
|
+
console.log(`\nPush complete: ${successCount} succeeded, ${errorCount} failed`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function resolveUser(cliUser: string): string {
|
|
517
|
+
// 1. Use CLI-specified user if provided (and not 'default')
|
|
518
|
+
if (cliUser && cliUser !== 'default') {
|
|
519
|
+
const normalized = normalizeUser(cliUser);
|
|
520
|
+
const config = loadConfig();
|
|
521
|
+
config.lastUser = normalized;
|
|
522
|
+
saveConfig(config);
|
|
523
|
+
return normalized;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// 2. Use lastUser from config (already normalized)
|
|
527
|
+
const config = loadConfig();
|
|
528
|
+
if (config.lastUser) {
|
|
529
|
+
return config.lastUser;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// 3. No user - require --user on first use
|
|
533
|
+
console.error('No user specified. Use --user <name> on first run (e.g., your Gmail username).');
|
|
534
|
+
console.error('This will be remembered for future runs.');
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function main(): Promise<void> {
|
|
539
|
+
const args = process.argv.slice(2);
|
|
540
|
+
const options = parseArgs(args);
|
|
541
|
+
|
|
542
|
+
if (options.help || !options.command) {
|
|
543
|
+
showHelp();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
try {
|
|
548
|
+
const user = resolveUser(options.user);
|
|
549
|
+
|
|
550
|
+
switch (options.command) {
|
|
551
|
+
case 'sync':
|
|
552
|
+
await syncContacts(user, { full: options.full, verbose: options.verbose });
|
|
553
|
+
break;
|
|
554
|
+
case 'push':
|
|
555
|
+
await pushContacts(user, { yes: options.yes, verbose: options.verbose });
|
|
556
|
+
break;
|
|
557
|
+
default:
|
|
558
|
+
console.error(`Unknown command: ${options.command}`);
|
|
559
|
+
showHelp();
|
|
560
|
+
process.exit(1);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Update lastUser in config
|
|
564
|
+
const config = loadConfig();
|
|
565
|
+
config.lastUser = user;
|
|
566
|
+
saveConfig(config);
|
|
567
|
+
|
|
568
|
+
} catch (error: any) {
|
|
569
|
+
console.error(`Error: ${error.message}`);
|
|
570
|
+
debugger;
|
|
571
|
+
process.exit(1);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (import.meta.main) {
|
|
576
|
+
await main();
|
|
577
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bobfrankston/gcards",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Google Contacts cleanup and management tool",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "gcards.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"gcards": "gcards.ts"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/bobfrankston/gcards.git"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"check": "tsc --noEmit",
|
|
16
|
+
"prerelease:local": "git add -A && (git diff-index --quiet HEAD || git commit -m \"Pre-release commit\")",
|
|
17
|
+
"preversion": "npm run check && git add -A",
|
|
18
|
+
"postversion": "git push && git push --tags",
|
|
19
|
+
"release": "npm run prerelease:local && npm version patch && npm publish --access public"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"google",
|
|
23
|
+
"contacts",
|
|
24
|
+
"cleanup",
|
|
25
|
+
"oauth"
|
|
26
|
+
],
|
|
27
|
+
"author": "Bob Frankston",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.10.1"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@bobfrankston/miscassists": "file:../../../projects/NodeJS/miscassists"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "esnext",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"allowSyntheticDefaultImports": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"allowJs": true,
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"noEmit": true,
|
|
14
|
+
"strictNullChecks": false,
|
|
15
|
+
"noImplicitAny": true,
|
|
16
|
+
"noImplicitReturns": false,
|
|
17
|
+
"noImplicitThis": true,
|
|
18
|
+
"newLine": "lf"
|
|
19
|
+
},
|
|
20
|
+
"exclude": [
|
|
21
|
+
"node_modules",
|
|
22
|
+
"cruft",
|
|
23
|
+
".git",
|
|
24
|
+
"tests",
|
|
25
|
+
"prev"
|
|
26
|
+
]
|
|
27
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google People API Type Definitions
|
|
3
|
+
* Reference: https://developers.google.com/people/api/rest/v1/people
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ============================================================
|
|
7
|
+
// Google People API Response Types (from API)
|
|
8
|
+
// ============================================================
|
|
9
|
+
|
|
10
|
+
export interface GooglePersonMetadata {
|
|
11
|
+
sources?: Array<{
|
|
12
|
+
type: string; // "CONTACT", "PROFILE", "DOMAIN_PROFILE"
|
|
13
|
+
id: string;
|
|
14
|
+
etag?: string;
|
|
15
|
+
updateTime?: string;
|
|
16
|
+
}>;
|
|
17
|
+
previousResourceNames?: string[];
|
|
18
|
+
linkedPeopleResourceNames?: string[];
|
|
19
|
+
deleted?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GoogleName {
|
|
23
|
+
metadata?: FieldMetadata;
|
|
24
|
+
displayName?: string;
|
|
25
|
+
displayNameLastFirst?: string;
|
|
26
|
+
familyName?: string;
|
|
27
|
+
givenName?: string;
|
|
28
|
+
middleName?: string;
|
|
29
|
+
honorificPrefix?: string;
|
|
30
|
+
honorificSuffix?: string;
|
|
31
|
+
phoneticFullName?: string;
|
|
32
|
+
phoneticFamilyName?: string;
|
|
33
|
+
phoneticGivenName?: string;
|
|
34
|
+
phoneticMiddleName?: string;
|
|
35
|
+
phoneticHonorificPrefix?: string;
|
|
36
|
+
phoneticHonorificSuffix?: string;
|
|
37
|
+
unstructuredName?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface GoogleNickname {
|
|
41
|
+
metadata?: FieldMetadata;
|
|
42
|
+
value?: string;
|
|
43
|
+
type?: string; // "DEFAULT", "MAIDEN_NAME", "INITIALS", etc.
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface GoogleEmailAddress {
|
|
47
|
+
metadata?: FieldMetadata;
|
|
48
|
+
value?: string;
|
|
49
|
+
type?: string; // "home", "work", "other"
|
|
50
|
+
formattedType?: string;
|
|
51
|
+
displayName?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface GooglePhoneNumber {
|
|
55
|
+
metadata?: FieldMetadata;
|
|
56
|
+
value?: string;
|
|
57
|
+
canonicalForm?: string; // E.164 format
|
|
58
|
+
type?: string; // "home", "work", "mobile", etc.
|
|
59
|
+
formattedType?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface GoogleAddress {
|
|
63
|
+
metadata?: FieldMetadata;
|
|
64
|
+
formattedValue?: string;
|
|
65
|
+
type?: string;
|
|
66
|
+
formattedType?: string;
|
|
67
|
+
poBox?: string;
|
|
68
|
+
streetAddress?: string;
|
|
69
|
+
extendedAddress?: string;
|
|
70
|
+
city?: string;
|
|
71
|
+
region?: string;
|
|
72
|
+
postalCode?: string;
|
|
73
|
+
country?: string;
|
|
74
|
+
countryCode?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface GoogleOrganization {
|
|
78
|
+
metadata?: FieldMetadata;
|
|
79
|
+
type?: string;
|
|
80
|
+
formattedType?: string;
|
|
81
|
+
name?: string;
|
|
82
|
+
department?: string;
|
|
83
|
+
title?: string;
|
|
84
|
+
jobDescription?: string;
|
|
85
|
+
symbol?: string;
|
|
86
|
+
phoneticName?: string;
|
|
87
|
+
location?: string;
|
|
88
|
+
startDate?: GoogleDate;
|
|
89
|
+
endDate?: GoogleDate;
|
|
90
|
+
current?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface GoogleBiography {
|
|
94
|
+
metadata?: FieldMetadata;
|
|
95
|
+
value?: string;
|
|
96
|
+
contentType?: string; // "TEXT_PLAIN", "TEXT_HTML"
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface GoogleUrl {
|
|
100
|
+
metadata?: FieldMetadata;
|
|
101
|
+
value?: string;
|
|
102
|
+
type?: string;
|
|
103
|
+
formattedType?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface GooglePhoto {
|
|
107
|
+
metadata?: FieldMetadata;
|
|
108
|
+
url?: string;
|
|
109
|
+
default?: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface GoogleBirthday {
|
|
113
|
+
metadata?: FieldMetadata;
|
|
114
|
+
date?: GoogleDate;
|
|
115
|
+
text?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface GoogleDate {
|
|
119
|
+
year?: number;
|
|
120
|
+
month?: number;
|
|
121
|
+
day?: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface GoogleEvent {
|
|
125
|
+
metadata?: FieldMetadata;
|
|
126
|
+
date?: GoogleDate;
|
|
127
|
+
type?: string;
|
|
128
|
+
formattedType?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface GoogleRelation {
|
|
132
|
+
metadata?: FieldMetadata;
|
|
133
|
+
person?: string;
|
|
134
|
+
type?: string;
|
|
135
|
+
formattedType?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface GoogleMembership {
|
|
139
|
+
metadata?: FieldMetadata;
|
|
140
|
+
contactGroupMembership?: {
|
|
141
|
+
contactGroupId?: string;
|
|
142
|
+
contactGroupResourceName?: string;
|
|
143
|
+
};
|
|
144
|
+
domainMembership?: {
|
|
145
|
+
inViewerDomain?: boolean;
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface GoogleUserDefined {
|
|
150
|
+
metadata?: FieldMetadata;
|
|
151
|
+
key?: string;
|
|
152
|
+
value?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface FieldMetadata {
|
|
156
|
+
primary?: boolean;
|
|
157
|
+
verified?: boolean;
|
|
158
|
+
source?: {
|
|
159
|
+
type: string;
|
|
160
|
+
id: string;
|
|
161
|
+
};
|
|
162
|
+
sourcePrimary?: boolean;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Full Google Person resource as returned by People API
|
|
167
|
+
*/
|
|
168
|
+
export interface GooglePerson {
|
|
169
|
+
resourceName: string; // e.g., "people/c1234567890"
|
|
170
|
+
etag?: string;
|
|
171
|
+
metadata?: GooglePersonMetadata;
|
|
172
|
+
names?: GoogleName[];
|
|
173
|
+
nicknames?: GoogleNickname[];
|
|
174
|
+
photos?: GooglePhoto[];
|
|
175
|
+
birthdays?: GoogleBirthday[];
|
|
176
|
+
addresses?: GoogleAddress[];
|
|
177
|
+
emailAddresses?: GoogleEmailAddress[];
|
|
178
|
+
phoneNumbers?: GooglePhoneNumber[];
|
|
179
|
+
organizations?: GoogleOrganization[];
|
|
180
|
+
biographies?: GoogleBiography[];
|
|
181
|
+
urls?: GoogleUrl[];
|
|
182
|
+
events?: GoogleEvent[];
|
|
183
|
+
relations?: GoogleRelation[];
|
|
184
|
+
memberships?: GoogleMembership[];
|
|
185
|
+
userDefined?: GoogleUserDefined[];
|
|
186
|
+
imClients?: Array<{
|
|
187
|
+
metadata?: FieldMetadata;
|
|
188
|
+
username?: string;
|
|
189
|
+
type?: string;
|
|
190
|
+
formattedType?: string;
|
|
191
|
+
protocol?: string;
|
|
192
|
+
formattedProtocol?: string;
|
|
193
|
+
}>;
|
|
194
|
+
sipAddresses?: Array<{
|
|
195
|
+
metadata?: FieldMetadata;
|
|
196
|
+
value?: string;
|
|
197
|
+
type?: string;
|
|
198
|
+
formattedType?: string;
|
|
199
|
+
}>;
|
|
200
|
+
fileAses?: Array<{
|
|
201
|
+
metadata?: FieldMetadata;
|
|
202
|
+
value?: string;
|
|
203
|
+
}>;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface GoogleConnectionsResponse {
|
|
207
|
+
connections?: GooglePerson[];
|
|
208
|
+
nextPageToken?: string;
|
|
209
|
+
nextSyncToken?: string;
|
|
210
|
+
totalPeople?: number;
|
|
211
|
+
totalItems?: number;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ============================================================
|
|
215
|
+
// Local Simplified Format (for analysis/cleanup)
|
|
216
|
+
// ============================================================
|
|
217
|
+
|
|
218
|
+
export interface LocalContact {
|
|
219
|
+
// Identity & reconciliation
|
|
220
|
+
resourceName: string; // From Google - used for updates
|
|
221
|
+
etag?: string; // For conflict detection
|
|
222
|
+
|
|
223
|
+
// Core identity
|
|
224
|
+
displayName: string;
|
|
225
|
+
givenName?: string;
|
|
226
|
+
familyName?: string;
|
|
227
|
+
nickname?: string;
|
|
228
|
+
|
|
229
|
+
// Contact info (flattened, typed arrays)
|
|
230
|
+
emails: Array<{ value: string; type?: string; primary?: boolean }>;
|
|
231
|
+
phones: Array<{ value: string; type?: string; primary?: boolean }>;
|
|
232
|
+
addresses: Array<{
|
|
233
|
+
formatted?: string;
|
|
234
|
+
street?: string;
|
|
235
|
+
city?: string;
|
|
236
|
+
region?: string;
|
|
237
|
+
postalCode?: string;
|
|
238
|
+
country?: string;
|
|
239
|
+
type?: string;
|
|
240
|
+
}>;
|
|
241
|
+
|
|
242
|
+
// Professional
|
|
243
|
+
organizations: Array<{
|
|
244
|
+
name?: string;
|
|
245
|
+
title?: string;
|
|
246
|
+
department?: string;
|
|
247
|
+
}>;
|
|
248
|
+
|
|
249
|
+
// Social/web
|
|
250
|
+
urls: Array<{ value: string; type?: string }>;
|
|
251
|
+
|
|
252
|
+
// Dates
|
|
253
|
+
birthday?: { year?: number; month?: number; day?: number };
|
|
254
|
+
|
|
255
|
+
// Notes
|
|
256
|
+
notes?: string;
|
|
257
|
+
|
|
258
|
+
// Groups
|
|
259
|
+
groups: string[]; // contactGroupResourceNames
|
|
260
|
+
|
|
261
|
+
// Metadata for cleanup
|
|
262
|
+
_meta: {
|
|
263
|
+
sourceType: string; // "CONTACT", "PROFILE", etc.
|
|
264
|
+
updateTime?: string;
|
|
265
|
+
isTestEntry?: boolean; // Flagged as test (m-XXXX@bob.ma pattern)
|
|
266
|
+
duplicateOf?: string[]; // resourceNames of potential duplicates
|
|
267
|
+
mergeConfidence?: number; // 0-1 confidence for merge candidates
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ============================================================
|
|
272
|
+
// Cleanup Analysis Types
|
|
273
|
+
// ============================================================
|
|
274
|
+
|
|
275
|
+
export interface DuplicateGroup {
|
|
276
|
+
primary: string; // resourceName of "best" contact
|
|
277
|
+
duplicates: string[]; // resourceNames to merge into primary
|
|
278
|
+
confidence: number; // 0-1 merge confidence
|
|
279
|
+
matchReasons: string[]; // Why these are considered duplicates
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export interface CleanupReport {
|
|
283
|
+
totalContacts: number;
|
|
284
|
+
testEntries: LocalContact[]; // To be deleted
|
|
285
|
+
duplicateGroups: DuplicateGroup[];
|
|
286
|
+
emptyContacts: LocalContact[]; // No useful info
|
|
287
|
+
suggestions: string[]; // Human-readable recommendations
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ============================================================
|
|
291
|
+
// Deletion Logging (for tracking cleanup history)
|
|
292
|
+
// ============================================================
|
|
293
|
+
|
|
294
|
+
export interface DeletionRecord {
|
|
295
|
+
resourceName: string;
|
|
296
|
+
displayName: string;
|
|
297
|
+
reason: string; // "test_entry", "duplicate_of:people/cXXX", "empty", etc.
|
|
298
|
+
deletedAt: string; // ISO timestamp
|
|
299
|
+
originalData: LocalContact; // Snapshot before deletion
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export interface DeletionLog {
|
|
303
|
+
deletions: DeletionRecord[];
|
|
304
|
+
lastUpdated: string; // ISO timestamp
|
|
305
|
+
}
|