@hasna/contacts 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/LICENSE +180 -0
- package/README.md +93 -0
- package/dashboard/dist/assets/index-B4ndI7Qt.js +49 -0
- package/dashboard/dist/assets/index-C5bn2HWO.css +1 -0
- package/dashboard/dist/index.html +13 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +3992 -0
- package/dist/db/activity.d.ts +15 -0
- package/dist/db/activity.d.ts.map +1 -0
- package/dist/db/companies.d.ts +13 -0
- package/dist/db/companies.d.ts.map +1 -0
- package/dist/db/contacts.d.ts +13 -0
- package/dist/db/contacts.d.ts.map +1 -0
- package/dist/db/database.d.ts +6 -0
- package/dist/db/database.d.ts.map +1 -0
- package/dist/db/relationships.d.ts +11 -0
- package/dist/db/relationships.d.ts.map +1 -0
- package/dist/db/tags.d.ts +14 -0
- package/dist/db/tags.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +999 -0
- package/dist/lib/export.d.ts +8 -0
- package/dist/lib/export.d.ts.map +1 -0
- package/dist/lib/import.d.ts +9 -0
- package/dist/lib/import.d.ts.map +1 -0
- package/dist/mcp/index.d.ts +3 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +1875 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +1476 -0
- package/dist/server/serve.d.ts +2 -0
- package/dist/server/serve.d.ts.map +1 -0
- package/dist/types/index.d.ts +375 -0
- package/dist/types/index.d.ts.map +1 -0
- package/package.json +60 -0
|
@@ -0,0 +1,1875 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// src/mcp/index.ts
|
|
5
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import {
|
|
8
|
+
CallToolRequestSchema,
|
|
9
|
+
ListToolsRequestSchema
|
|
10
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
11
|
+
|
|
12
|
+
// src/db/database.ts
|
|
13
|
+
import { Database } from "bun:sqlite";
|
|
14
|
+
import { existsSync, mkdirSync } from "fs";
|
|
15
|
+
import { dirname, join, resolve } from "path";
|
|
16
|
+
function getDbPath() {
|
|
17
|
+
if (process.env["CONTACTS_DB_PATH"])
|
|
18
|
+
return process.env["CONTACTS_DB_PATH"];
|
|
19
|
+
const home = process.env["HOME"] || "~";
|
|
20
|
+
return join(home, ".contacts", "contacts.db");
|
|
21
|
+
}
|
|
22
|
+
function ensureDir(filePath) {
|
|
23
|
+
if (filePath === ":memory:")
|
|
24
|
+
return;
|
|
25
|
+
const dir = dirname(resolve(filePath));
|
|
26
|
+
if (!existsSync(dir))
|
|
27
|
+
mkdirSync(dir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
var MIGRATIONS = [
|
|
30
|
+
`
|
|
31
|
+
CREATE TABLE IF NOT EXISTS companies (
|
|
32
|
+
id TEXT PRIMARY KEY,
|
|
33
|
+
name TEXT NOT NULL,
|
|
34
|
+
domain TEXT,
|
|
35
|
+
logo_url TEXT,
|
|
36
|
+
description TEXT,
|
|
37
|
+
industry TEXT,
|
|
38
|
+
size TEXT,
|
|
39
|
+
founded_year INTEGER,
|
|
40
|
+
notes TEXT,
|
|
41
|
+
custom_fields TEXT NOT NULL DEFAULT '{}',
|
|
42
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
43
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE TABLE IF NOT EXISTS contacts (
|
|
47
|
+
id TEXT PRIMARY KEY,
|
|
48
|
+
first_name TEXT NOT NULL DEFAULT '',
|
|
49
|
+
last_name TEXT NOT NULL DEFAULT '',
|
|
50
|
+
display_name TEXT NOT NULL,
|
|
51
|
+
nickname TEXT,
|
|
52
|
+
avatar_url TEXT,
|
|
53
|
+
notes TEXT,
|
|
54
|
+
birthday TEXT,
|
|
55
|
+
company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
|
|
56
|
+
job_title TEXT,
|
|
57
|
+
source TEXT NOT NULL DEFAULT 'manual',
|
|
58
|
+
custom_fields TEXT NOT NULL DEFAULT '{}',
|
|
59
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
60
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE TABLE IF NOT EXISTS tags (
|
|
64
|
+
id TEXT PRIMARY KEY,
|
|
65
|
+
name TEXT NOT NULL UNIQUE,
|
|
66
|
+
color TEXT NOT NULL DEFAULT '#6366f1',
|
|
67
|
+
description TEXT,
|
|
68
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
CREATE TABLE IF NOT EXISTS contact_tags (
|
|
72
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
73
|
+
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
74
|
+
PRIMARY KEY (contact_id, tag_id)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
CREATE TABLE IF NOT EXISTS company_tags (
|
|
78
|
+
company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
|
79
|
+
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
80
|
+
PRIMARY KEY (company_id, tag_id)
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
CREATE TABLE IF NOT EXISTS emails (
|
|
84
|
+
id TEXT PRIMARY KEY,
|
|
85
|
+
contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
|
|
86
|
+
company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
|
|
87
|
+
address TEXT NOT NULL,
|
|
88
|
+
type TEXT NOT NULL DEFAULT 'work' CHECK(type IN ('work','personal','other')),
|
|
89
|
+
is_primary INTEGER NOT NULL DEFAULT 0,
|
|
90
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
CREATE TABLE IF NOT EXISTS phones (
|
|
94
|
+
id TEXT PRIMARY KEY,
|
|
95
|
+
contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
|
|
96
|
+
company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
|
|
97
|
+
number TEXT NOT NULL,
|
|
98
|
+
country_code TEXT,
|
|
99
|
+
type TEXT NOT NULL DEFAULT 'mobile' CHECK(type IN ('mobile','work','home','fax','whatsapp','other')),
|
|
100
|
+
is_primary INTEGER NOT NULL DEFAULT 0,
|
|
101
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
CREATE TABLE IF NOT EXISTS addresses (
|
|
105
|
+
id TEXT PRIMARY KEY,
|
|
106
|
+
contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
|
|
107
|
+
company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
|
|
108
|
+
type TEXT NOT NULL DEFAULT 'physical' CHECK(type IN ('physical','mailing','billing','virtual','other')),
|
|
109
|
+
street TEXT,
|
|
110
|
+
city TEXT,
|
|
111
|
+
state TEXT,
|
|
112
|
+
zip TEXT,
|
|
113
|
+
country TEXT,
|
|
114
|
+
is_primary INTEGER NOT NULL DEFAULT 0,
|
|
115
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
CREATE TABLE IF NOT EXISTS social_profiles (
|
|
119
|
+
id TEXT PRIMARY KEY,
|
|
120
|
+
contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
|
|
121
|
+
company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
|
|
122
|
+
platform TEXT NOT NULL CHECK(platform IN ('twitter','linkedin','github','instagram','telegram','discord','youtube','tiktok','bluesky','facebook','whatsapp','snapchat','reddit','other')),
|
|
123
|
+
handle TEXT,
|
|
124
|
+
url TEXT,
|
|
125
|
+
is_primary INTEGER NOT NULL DEFAULT 0,
|
|
126
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
CREATE TABLE IF NOT EXISTS contact_relationships (
|
|
130
|
+
id TEXT PRIMARY KEY,
|
|
131
|
+
contact_a_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
132
|
+
contact_b_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
133
|
+
relationship_type TEXT NOT NULL CHECK(relationship_type IN ('colleague','friend','family','reports_to','mentor','investor','partner','client','vendor','other')),
|
|
134
|
+
notes TEXT,
|
|
135
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
CREATE TABLE IF NOT EXISTS activity_log (
|
|
139
|
+
id TEXT PRIMARY KEY,
|
|
140
|
+
contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
|
|
141
|
+
company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
|
|
142
|
+
action TEXT NOT NULL,
|
|
143
|
+
details TEXT,
|
|
144
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
CREATE TABLE IF NOT EXISTS webhooks (
|
|
148
|
+
id TEXT PRIMARY KEY,
|
|
149
|
+
url TEXT NOT NULL,
|
|
150
|
+
events TEXT NOT NULL DEFAULT '["*"]',
|
|
151
|
+
secret TEXT,
|
|
152
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
153
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS contacts_fts USING fts5(
|
|
157
|
+
id UNINDEXED,
|
|
158
|
+
display_name,
|
|
159
|
+
first_name,
|
|
160
|
+
last_name,
|
|
161
|
+
nickname,
|
|
162
|
+
notes,
|
|
163
|
+
job_title,
|
|
164
|
+
content='contacts',
|
|
165
|
+
content_rowid='rowid'
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
CREATE TRIGGER IF NOT EXISTS contacts_fts_insert AFTER INSERT ON contacts BEGIN
|
|
169
|
+
INSERT INTO contacts_fts(rowid, id, display_name, first_name, last_name, nickname, notes, job_title)
|
|
170
|
+
VALUES (new.rowid, new.id, new.display_name, new.first_name, new.last_name, new.nickname, new.notes, new.job_title);
|
|
171
|
+
END;
|
|
172
|
+
|
|
173
|
+
CREATE TRIGGER IF NOT EXISTS contacts_fts_update AFTER UPDATE ON contacts BEGIN
|
|
174
|
+
DELETE FROM contacts_fts WHERE rowid = old.rowid;
|
|
175
|
+
INSERT INTO contacts_fts(rowid, id, display_name, first_name, last_name, nickname, notes, job_title)
|
|
176
|
+
VALUES (new.rowid, new.id, new.display_name, new.first_name, new.last_name, new.nickname, new.notes, new.job_title);
|
|
177
|
+
END;
|
|
178
|
+
|
|
179
|
+
CREATE TRIGGER IF NOT EXISTS contacts_fts_delete AFTER DELETE ON contacts BEGIN
|
|
180
|
+
DELETE FROM contacts_fts WHERE rowid = old.rowid;
|
|
181
|
+
END;
|
|
182
|
+
|
|
183
|
+
CREATE TABLE IF NOT EXISTS _migrations (version INTEGER PRIMARY KEY);
|
|
184
|
+
`
|
|
185
|
+
];
|
|
186
|
+
var _db = null;
|
|
187
|
+
function getDatabase(path) {
|
|
188
|
+
if (_db)
|
|
189
|
+
return _db;
|
|
190
|
+
const dbPath = path || getDbPath();
|
|
191
|
+
ensureDir(dbPath);
|
|
192
|
+
const db = new Database(dbPath, { create: true });
|
|
193
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
194
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
195
|
+
runMigrations(db);
|
|
196
|
+
_db = db;
|
|
197
|
+
return db;
|
|
198
|
+
}
|
|
199
|
+
function uuid() {
|
|
200
|
+
return crypto.randomUUID();
|
|
201
|
+
}
|
|
202
|
+
function now() {
|
|
203
|
+
return new Date().toISOString();
|
|
204
|
+
}
|
|
205
|
+
function runMigrations(db) {
|
|
206
|
+
try {
|
|
207
|
+
const row = db.query("SELECT MAX(version) as v FROM _migrations").get();
|
|
208
|
+
const current = row?.v ?? -1;
|
|
209
|
+
for (let i = current + 1;i < MIGRATIONS.length; i++) {
|
|
210
|
+
db.exec(MIGRATIONS[i]);
|
|
211
|
+
db.exec(`INSERT OR REPLACE INTO _migrations(version) VALUES(${i})`);
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
for (const m of MIGRATIONS) {
|
|
215
|
+
try {
|
|
216
|
+
db.exec(m);
|
|
217
|
+
} catch {}
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
db.exec(`INSERT OR REPLACE INTO _migrations(version) VALUES(${MIGRATIONS.length - 1})`);
|
|
221
|
+
} catch {}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/types/index.ts
|
|
226
|
+
class ContactNotFoundError extends Error {
|
|
227
|
+
constructor(id) {
|
|
228
|
+
super(`Contact not found: ${id}`);
|
|
229
|
+
this.name = "ContactNotFoundError";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
class CompanyNotFoundError extends Error {
|
|
234
|
+
constructor(id) {
|
|
235
|
+
super(`Company not found: ${id}`);
|
|
236
|
+
this.name = "CompanyNotFoundError";
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
class TagNotFoundError extends Error {
|
|
241
|
+
constructor(id) {
|
|
242
|
+
super(`Tag not found: ${id}`);
|
|
243
|
+
this.name = "TagNotFoundError";
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
class DuplicateTagNameError extends Error {
|
|
248
|
+
constructor(name) {
|
|
249
|
+
super(`Tag with name already exists: ${name}`);
|
|
250
|
+
this.name = "DuplicateTagNameError";
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// src/db/activity.ts
|
|
255
|
+
function logActivity(db, input) {
|
|
256
|
+
const id = uuid();
|
|
257
|
+
db.run(`INSERT INTO activity_log (id, contact_id, company_id, action, details) VALUES (?, ?, ?, ?, ?)`, [id, input.contact_id ?? null, input.company_id ?? null, input.action, input.details ?? null]);
|
|
258
|
+
return db.query(`SELECT * FROM activity_log WHERE id = ?`).get(id);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/db/contacts.ts
|
|
262
|
+
function rowToContact(row) {
|
|
263
|
+
return {
|
|
264
|
+
...row,
|
|
265
|
+
source: row.source,
|
|
266
|
+
custom_fields: JSON.parse(row.custom_fields || "{}")
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function rowToEmail(row) {
|
|
270
|
+
return {
|
|
271
|
+
...row,
|
|
272
|
+
type: row.type,
|
|
273
|
+
is_primary: !!row.is_primary
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function rowToPhone(row) {
|
|
277
|
+
return {
|
|
278
|
+
...row,
|
|
279
|
+
type: row.type,
|
|
280
|
+
is_primary: !!row.is_primary
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function rowToAddress(row) {
|
|
284
|
+
return {
|
|
285
|
+
...row,
|
|
286
|
+
type: row.type,
|
|
287
|
+
is_primary: !!row.is_primary
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function rowToSocialProfile(row) {
|
|
291
|
+
return {
|
|
292
|
+
...row,
|
|
293
|
+
platform: row.platform,
|
|
294
|
+
is_primary: !!row.is_primary
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function rowToTag(row) {
|
|
298
|
+
return { ...row };
|
|
299
|
+
}
|
|
300
|
+
function rowToCompany(row) {
|
|
301
|
+
return {
|
|
302
|
+
...row,
|
|
303
|
+
custom_fields: JSON.parse(row.custom_fields || "{}")
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
function insertEmails(db, contactId, companyId, emails) {
|
|
307
|
+
for (const e of emails) {
|
|
308
|
+
db.run(`INSERT INTO emails (id, contact_id, company_id, address, type, is_primary) VALUES (?, ?, ?, ?, ?, ?)`, [uuid(), contactId, companyId, e.address, e.type ?? "work", e.is_primary ? 1 : 0]);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function insertPhones(db, contactId, companyId, phones) {
|
|
312
|
+
for (const p of phones) {
|
|
313
|
+
db.run(`INSERT INTO phones (id, contact_id, company_id, number, country_code, type, is_primary) VALUES (?, ?, ?, ?, ?, ?, ?)`, [uuid(), contactId, companyId, p.number, p.country_code ?? null, p.type ?? "mobile", p.is_primary ? 1 : 0]);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function insertAddresses(db, contactId, companyId, addresses) {
|
|
317
|
+
for (const a of addresses) {
|
|
318
|
+
db.run(`INSERT INTO addresses (id, contact_id, company_id, type, street, city, state, zip, country, is_primary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [uuid(), contactId, companyId, a.type ?? "physical", a.street ?? null, a.city ?? null, a.state ?? null, a.zip ?? null, a.country ?? null, a.is_primary ? 1 : 0]);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function insertSocialProfiles(db, contactId, companyId, profiles) {
|
|
322
|
+
for (const s of profiles) {
|
|
323
|
+
db.run(`INSERT INTO social_profiles (id, contact_id, company_id, platform, handle, url, is_primary) VALUES (?, ?, ?, ?, ?, ?, ?)`, [uuid(), contactId, companyId, s.platform, s.handle ?? null, s.url ?? null, s.is_primary ? 1 : 0]);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function loadContactDetails(db, contact) {
|
|
327
|
+
const emails = db.query(`SELECT * FROM emails WHERE contact_id = ?`).all(contact.id).map(rowToEmail);
|
|
328
|
+
const phones = db.query(`SELECT * FROM phones WHERE contact_id = ?`).all(contact.id).map(rowToPhone);
|
|
329
|
+
const addresses = db.query(`SELECT * FROM addresses WHERE contact_id = ?`).all(contact.id).map(rowToAddress);
|
|
330
|
+
const social_profiles = db.query(`SELECT * FROM social_profiles WHERE contact_id = ?`).all(contact.id).map(rowToSocialProfile);
|
|
331
|
+
const tags = db.query(`
|
|
332
|
+
SELECT t.* FROM tags t
|
|
333
|
+
JOIN contact_tags ct ON ct.tag_id = t.id
|
|
334
|
+
WHERE ct.contact_id = ?
|
|
335
|
+
`).all(contact.id).map(rowToTag);
|
|
336
|
+
const companyRow = contact.company_id ? db.query(`SELECT * FROM companies WHERE id = ?`).get(contact.company_id) : null;
|
|
337
|
+
const company = companyRow ? rowToCompany(companyRow) : null;
|
|
338
|
+
return { ...contact, emails, phones, addresses, social_profiles, tags, company };
|
|
339
|
+
}
|
|
340
|
+
function createContact(input, db) {
|
|
341
|
+
const d = db || getDatabase();
|
|
342
|
+
const id = uuid();
|
|
343
|
+
const timestamp = now();
|
|
344
|
+
const firstName = input.first_name ?? "";
|
|
345
|
+
const lastName = input.last_name ?? "";
|
|
346
|
+
const displayName = input.display_name ?? (firstName || lastName ? `${firstName} ${lastName}`.trim() : "Unnamed Contact");
|
|
347
|
+
d.run(`INSERT INTO contacts (id, first_name, last_name, display_name, nickname, avatar_url, notes, birthday, company_id, job_title, source, custom_fields, created_at, updated_at)
|
|
348
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
349
|
+
id,
|
|
350
|
+
firstName,
|
|
351
|
+
lastName,
|
|
352
|
+
displayName,
|
|
353
|
+
input.nickname ?? null,
|
|
354
|
+
input.avatar_url ?? null,
|
|
355
|
+
input.notes ?? null,
|
|
356
|
+
input.birthday ?? null,
|
|
357
|
+
input.company_id ?? null,
|
|
358
|
+
input.job_title ?? null,
|
|
359
|
+
input.source ?? "manual",
|
|
360
|
+
JSON.stringify(input.custom_fields ?? {}),
|
|
361
|
+
timestamp,
|
|
362
|
+
timestamp
|
|
363
|
+
]);
|
|
364
|
+
if (input.emails?.length)
|
|
365
|
+
insertEmails(d, id, null, input.emails);
|
|
366
|
+
if (input.phones?.length)
|
|
367
|
+
insertPhones(d, id, null, input.phones);
|
|
368
|
+
if (input.addresses?.length)
|
|
369
|
+
insertAddresses(d, id, null, input.addresses);
|
|
370
|
+
if (input.social_profiles?.length)
|
|
371
|
+
insertSocialProfiles(d, id, null, input.social_profiles);
|
|
372
|
+
if (input.tag_ids?.length) {
|
|
373
|
+
for (const tagId of input.tag_ids) {
|
|
374
|
+
d.run(`INSERT OR IGNORE INTO contact_tags (contact_id, tag_id) VALUES (?, ?)`, [id, tagId]);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
logActivity(d, { contact_id: id, action: "contact.created", details: `Created contact: ${displayName}` });
|
|
378
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
379
|
+
return loadContactDetails(d, rowToContact(row));
|
|
380
|
+
}
|
|
381
|
+
function getContact(id, db) {
|
|
382
|
+
const d = db || getDatabase();
|
|
383
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
384
|
+
if (!row)
|
|
385
|
+
throw new ContactNotFoundError(id);
|
|
386
|
+
return loadContactDetails(d, rowToContact(row));
|
|
387
|
+
}
|
|
388
|
+
function listContacts(opts = {}, db) {
|
|
389
|
+
const d = db || getDatabase();
|
|
390
|
+
const {
|
|
391
|
+
limit = 50,
|
|
392
|
+
offset = 0,
|
|
393
|
+
company_id,
|
|
394
|
+
tag_id,
|
|
395
|
+
source,
|
|
396
|
+
order_by = "display_name",
|
|
397
|
+
order_dir = "asc"
|
|
398
|
+
} = opts;
|
|
399
|
+
const conditions = [];
|
|
400
|
+
const params = [];
|
|
401
|
+
if (company_id) {
|
|
402
|
+
conditions.push("c.company_id = ?");
|
|
403
|
+
params.push(company_id);
|
|
404
|
+
}
|
|
405
|
+
if (source) {
|
|
406
|
+
conditions.push("c.source = ?");
|
|
407
|
+
params.push(source);
|
|
408
|
+
}
|
|
409
|
+
if (tag_id) {
|
|
410
|
+
conditions.push("EXISTS (SELECT 1 FROM contact_tags ct WHERE ct.contact_id = c.id AND ct.tag_id = ?)");
|
|
411
|
+
params.push(tag_id);
|
|
412
|
+
}
|
|
413
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
414
|
+
const validOrderBy = ["display_name", "created_at", "updated_at"].includes(order_by) ? order_by : "display_name";
|
|
415
|
+
const validOrderDir = order_dir === "desc" ? "DESC" : "ASC";
|
|
416
|
+
const totalRow = d.query(`SELECT COUNT(*) as total FROM contacts c ${where}`).get(...params);
|
|
417
|
+
const rows = d.query(`SELECT c.* FROM contacts c ${where} ORDER BY c.${validOrderBy} ${validOrderDir} LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
|
418
|
+
const contacts = rows.map((row) => loadContactDetails(d, rowToContact(row)));
|
|
419
|
+
return { contacts, total: totalRow.total };
|
|
420
|
+
}
|
|
421
|
+
function updateContact(id, input, db) {
|
|
422
|
+
const d = db || getDatabase();
|
|
423
|
+
const existing = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
424
|
+
if (!existing)
|
|
425
|
+
throw new ContactNotFoundError(id);
|
|
426
|
+
const setClauses = ["updated_at = ?"];
|
|
427
|
+
const params = [now()];
|
|
428
|
+
if (input.first_name !== undefined) {
|
|
429
|
+
setClauses.push("first_name = ?");
|
|
430
|
+
params.push(input.first_name);
|
|
431
|
+
}
|
|
432
|
+
if (input.last_name !== undefined) {
|
|
433
|
+
setClauses.push("last_name = ?");
|
|
434
|
+
params.push(input.last_name);
|
|
435
|
+
}
|
|
436
|
+
if (input.display_name !== undefined) {
|
|
437
|
+
setClauses.push("display_name = ?");
|
|
438
|
+
params.push(input.display_name);
|
|
439
|
+
}
|
|
440
|
+
if (input.nickname !== undefined) {
|
|
441
|
+
setClauses.push("nickname = ?");
|
|
442
|
+
params.push(input.nickname);
|
|
443
|
+
}
|
|
444
|
+
if (input.avatar_url !== undefined) {
|
|
445
|
+
setClauses.push("avatar_url = ?");
|
|
446
|
+
params.push(input.avatar_url);
|
|
447
|
+
}
|
|
448
|
+
if (input.notes !== undefined) {
|
|
449
|
+
setClauses.push("notes = ?");
|
|
450
|
+
params.push(input.notes);
|
|
451
|
+
}
|
|
452
|
+
if (input.birthday !== undefined) {
|
|
453
|
+
setClauses.push("birthday = ?");
|
|
454
|
+
params.push(input.birthday);
|
|
455
|
+
}
|
|
456
|
+
if (input.company_id !== undefined) {
|
|
457
|
+
setClauses.push("company_id = ?");
|
|
458
|
+
params.push(input.company_id);
|
|
459
|
+
}
|
|
460
|
+
if (input.job_title !== undefined) {
|
|
461
|
+
setClauses.push("job_title = ?");
|
|
462
|
+
params.push(input.job_title);
|
|
463
|
+
}
|
|
464
|
+
if (input.source !== undefined) {
|
|
465
|
+
setClauses.push("source = ?");
|
|
466
|
+
params.push(input.source);
|
|
467
|
+
}
|
|
468
|
+
if (input.custom_fields !== undefined) {
|
|
469
|
+
setClauses.push("custom_fields = ?");
|
|
470
|
+
params.push(JSON.stringify(input.custom_fields));
|
|
471
|
+
}
|
|
472
|
+
params.push(id);
|
|
473
|
+
d.run(`UPDATE contacts SET ${setClauses.join(", ")} WHERE id = ?`, params);
|
|
474
|
+
logActivity(d, { contact_id: id, action: "contact.updated", details: `Updated contact: ${existing.display_name}` });
|
|
475
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
476
|
+
return loadContactDetails(d, rowToContact(row));
|
|
477
|
+
}
|
|
478
|
+
function deleteContact(id, db) {
|
|
479
|
+
const d = db || getDatabase();
|
|
480
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
481
|
+
if (!row)
|
|
482
|
+
throw new ContactNotFoundError(id);
|
|
483
|
+
logActivity(d, { contact_id: id, action: "contact.deleted", details: `Deleted contact: ${row.display_name}` });
|
|
484
|
+
d.run(`DELETE FROM contacts WHERE id = ?`, [id]);
|
|
485
|
+
}
|
|
486
|
+
function searchContacts(query, db) {
|
|
487
|
+
const d = db || getDatabase();
|
|
488
|
+
const ftsRows = d.query(`
|
|
489
|
+
SELECT c.* FROM contacts c
|
|
490
|
+
JOIN contacts_fts fts ON fts.id = c.id
|
|
491
|
+
WHERE contacts_fts MATCH ?
|
|
492
|
+
ORDER BY rank
|
|
493
|
+
LIMIT 50
|
|
494
|
+
`).all(`"${query.replace(/"/g, '""')}"*`);
|
|
495
|
+
const emailRows = d.query(`
|
|
496
|
+
SELECT DISTINCT c.* FROM contacts c
|
|
497
|
+
JOIN emails e ON e.contact_id = c.id
|
|
498
|
+
WHERE e.address LIKE ?
|
|
499
|
+
LIMIT 20
|
|
500
|
+
`).all(`%${query}%`);
|
|
501
|
+
const phoneRows = d.query(`
|
|
502
|
+
SELECT DISTINCT c.* FROM contacts c
|
|
503
|
+
JOIN phones p ON p.contact_id = c.id
|
|
504
|
+
WHERE p.number LIKE ?
|
|
505
|
+
LIMIT 20
|
|
506
|
+
`).all(`%${query}%`);
|
|
507
|
+
const seen = new Set;
|
|
508
|
+
const allRows = [];
|
|
509
|
+
for (const row of [...ftsRows, ...emailRows, ...phoneRows]) {
|
|
510
|
+
if (!seen.has(row.id)) {
|
|
511
|
+
seen.add(row.id);
|
|
512
|
+
allRows.push(row);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return allRows.map((row) => loadContactDetails(d, rowToContact(row)));
|
|
516
|
+
}
|
|
517
|
+
function mergeContacts(keepId, mergeId, db) {
|
|
518
|
+
const d = db || getDatabase();
|
|
519
|
+
const keepRow = d.query(`SELECT * FROM contacts WHERE id = ?`).get(keepId);
|
|
520
|
+
if (!keepRow)
|
|
521
|
+
throw new ContactNotFoundError(keepId);
|
|
522
|
+
const mergeRow = d.query(`SELECT * FROM contacts WHERE id = ?`).get(mergeId);
|
|
523
|
+
if (!mergeRow)
|
|
524
|
+
throw new ContactNotFoundError(mergeId);
|
|
525
|
+
d.run(`UPDATE emails SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
|
|
526
|
+
d.run(`UPDATE phones SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
|
|
527
|
+
d.run(`UPDATE addresses SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
|
|
528
|
+
d.run(`UPDATE social_profiles SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
|
|
529
|
+
const mergeTags = d.query(`SELECT tag_id FROM contact_tags WHERE contact_id = ?`).all(mergeId);
|
|
530
|
+
for (const { tag_id } of mergeTags) {
|
|
531
|
+
d.run(`INSERT OR IGNORE INTO contact_tags (contact_id, tag_id) VALUES (?, ?)`, [keepId, tag_id]);
|
|
532
|
+
}
|
|
533
|
+
d.run(`UPDATE contact_relationships SET contact_a_id = ? WHERE contact_a_id = ?`, [keepId, mergeId]);
|
|
534
|
+
d.run(`UPDATE contact_relationships SET contact_b_id = ? WHERE contact_b_id = ?`, [keepId, mergeId]);
|
|
535
|
+
d.run(`UPDATE activity_log SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
|
|
536
|
+
d.run(`DELETE FROM contacts WHERE id = ?`, [mergeId]);
|
|
537
|
+
const updates = ["updated_at = ?"];
|
|
538
|
+
const params = [now()];
|
|
539
|
+
if (!keepRow.notes && mergeRow.notes) {
|
|
540
|
+
updates.push("notes = ?");
|
|
541
|
+
params.push(mergeRow.notes);
|
|
542
|
+
}
|
|
543
|
+
if (!keepRow.nickname && mergeRow.nickname) {
|
|
544
|
+
updates.push("nickname = ?");
|
|
545
|
+
params.push(mergeRow.nickname);
|
|
546
|
+
}
|
|
547
|
+
if (!keepRow.avatar_url && mergeRow.avatar_url) {
|
|
548
|
+
updates.push("avatar_url = ?");
|
|
549
|
+
params.push(mergeRow.avatar_url);
|
|
550
|
+
}
|
|
551
|
+
if (!keepRow.birthday && mergeRow.birthday) {
|
|
552
|
+
updates.push("birthday = ?");
|
|
553
|
+
params.push(mergeRow.birthday);
|
|
554
|
+
}
|
|
555
|
+
if (!keepRow.company_id && mergeRow.company_id) {
|
|
556
|
+
updates.push("company_id = ?");
|
|
557
|
+
params.push(mergeRow.company_id);
|
|
558
|
+
}
|
|
559
|
+
if (!keepRow.job_title && mergeRow.job_title) {
|
|
560
|
+
updates.push("job_title = ?");
|
|
561
|
+
params.push(mergeRow.job_title);
|
|
562
|
+
}
|
|
563
|
+
params.push(keepId);
|
|
564
|
+
d.run(`UPDATE contacts SET ${updates.join(", ")} WHERE id = ?`, params);
|
|
565
|
+
logActivity(d, {
|
|
566
|
+
contact_id: keepId,
|
|
567
|
+
action: "contact.merged",
|
|
568
|
+
details: `Merged contact ${mergeRow.display_name} (${mergeId}) into ${keepRow.display_name} (${keepId})`
|
|
569
|
+
});
|
|
570
|
+
const finalRow = d.query(`SELECT * FROM contacts WHERE id = ?`).get(keepId);
|
|
571
|
+
return loadContactDetails(d, rowToContact(finalRow));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// src/db/companies.ts
|
|
575
|
+
function rowToCompany2(row) {
|
|
576
|
+
return {
|
|
577
|
+
...row,
|
|
578
|
+
custom_fields: JSON.parse(row.custom_fields || "{}")
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
function insertEmails2(db, companyId, emails) {
|
|
582
|
+
for (const e of emails) {
|
|
583
|
+
db.run(`INSERT INTO emails (id, contact_id, company_id, address, type, is_primary) VALUES (?, ?, ?, ?, ?, ?)`, [uuid(), null, companyId, e.address, e.type ?? "work", e.is_primary ? 1 : 0]);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
function insertPhones2(db, companyId, phones) {
|
|
587
|
+
for (const p of phones) {
|
|
588
|
+
db.run(`INSERT INTO phones (id, contact_id, company_id, number, country_code, type, is_primary) VALUES (?, ?, ?, ?, ?, ?, ?)`, [uuid(), null, companyId, p.number, p.country_code ?? null, p.type ?? "work", p.is_primary ? 1 : 0]);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
function insertAddresses2(db, companyId, addresses) {
|
|
592
|
+
for (const a of addresses) {
|
|
593
|
+
db.run(`INSERT INTO addresses (id, contact_id, company_id, type, street, city, state, zip, country, is_primary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [uuid(), null, companyId, a.type ?? "physical", a.street ?? null, a.city ?? null, a.state ?? null, a.zip ?? null, a.country ?? null, a.is_primary ? 1 : 0]);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function insertSocialProfiles2(db, companyId, profiles) {
|
|
597
|
+
for (const s of profiles) {
|
|
598
|
+
db.run(`INSERT INTO social_profiles (id, contact_id, company_id, platform, handle, url, is_primary) VALUES (?, ?, ?, ?, ?, ?, ?)`, [uuid(), null, companyId, s.platform, s.handle ?? null, s.url ?? null, s.is_primary ? 1 : 0]);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
function loadCompanyDetails(db, company) {
|
|
602
|
+
const emails = db.query(`SELECT * FROM emails WHERE company_id = ?`).all(company.id).map((row) => ({
|
|
603
|
+
...row,
|
|
604
|
+
type: row.type,
|
|
605
|
+
is_primary: !!row.is_primary
|
|
606
|
+
}));
|
|
607
|
+
const phones = db.query(`SELECT * FROM phones WHERE company_id = ?`).all(company.id).map((row) => ({
|
|
608
|
+
...row,
|
|
609
|
+
type: row.type,
|
|
610
|
+
is_primary: !!row.is_primary
|
|
611
|
+
}));
|
|
612
|
+
const addresses = db.query(`SELECT * FROM addresses WHERE company_id = ?`).all(company.id).map((row) => ({
|
|
613
|
+
...row,
|
|
614
|
+
type: row.type,
|
|
615
|
+
is_primary: !!row.is_primary
|
|
616
|
+
}));
|
|
617
|
+
const social_profiles = db.query(`SELECT * FROM social_profiles WHERE company_id = ?`).all(company.id).map((row) => ({
|
|
618
|
+
...row,
|
|
619
|
+
platform: row.platform,
|
|
620
|
+
is_primary: !!row.is_primary
|
|
621
|
+
}));
|
|
622
|
+
const tags = db.query(`
|
|
623
|
+
SELECT t.* FROM tags t
|
|
624
|
+
JOIN company_tags ct ON ct.tag_id = t.id
|
|
625
|
+
WHERE ct.company_id = ?
|
|
626
|
+
`).all(company.id);
|
|
627
|
+
const empCount = db.query(`SELECT COUNT(*) as count FROM contacts WHERE company_id = ?`).get(company.id);
|
|
628
|
+
return {
|
|
629
|
+
...company,
|
|
630
|
+
emails,
|
|
631
|
+
phones,
|
|
632
|
+
addresses,
|
|
633
|
+
social_profiles,
|
|
634
|
+
tags,
|
|
635
|
+
employee_count: empCount.count
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
function createCompany(input, db) {
|
|
639
|
+
const d = db || getDatabase();
|
|
640
|
+
const id = uuid();
|
|
641
|
+
const timestamp = now();
|
|
642
|
+
d.run(`INSERT INTO companies (id, name, domain, logo_url, description, industry, size, founded_year, notes, custom_fields, created_at, updated_at)
|
|
643
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
644
|
+
id,
|
|
645
|
+
input.name,
|
|
646
|
+
input.domain ?? null,
|
|
647
|
+
input.logo_url ?? null,
|
|
648
|
+
input.description ?? null,
|
|
649
|
+
input.industry ?? null,
|
|
650
|
+
input.size ?? null,
|
|
651
|
+
input.founded_year ?? null,
|
|
652
|
+
input.notes ?? null,
|
|
653
|
+
JSON.stringify(input.custom_fields ?? {}),
|
|
654
|
+
timestamp,
|
|
655
|
+
timestamp
|
|
656
|
+
]);
|
|
657
|
+
if (input.emails?.length)
|
|
658
|
+
insertEmails2(d, id, input.emails);
|
|
659
|
+
if (input.phones?.length)
|
|
660
|
+
insertPhones2(d, id, input.phones);
|
|
661
|
+
if (input.addresses?.length)
|
|
662
|
+
insertAddresses2(d, id, input.addresses);
|
|
663
|
+
if (input.social_profiles?.length)
|
|
664
|
+
insertSocialProfiles2(d, id, input.social_profiles);
|
|
665
|
+
if (input.tag_ids?.length) {
|
|
666
|
+
for (const tagId of input.tag_ids) {
|
|
667
|
+
d.run(`INSERT OR IGNORE INTO company_tags (company_id, tag_id) VALUES (?, ?)`, [id, tagId]);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
logActivity(d, { company_id: id, action: "company.created", details: `Created company: ${input.name}` });
|
|
671
|
+
const row = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
|
|
672
|
+
return loadCompanyDetails(d, rowToCompany2(row));
|
|
673
|
+
}
|
|
674
|
+
function getCompany(id, db) {
|
|
675
|
+
const d = db || getDatabase();
|
|
676
|
+
const row = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
|
|
677
|
+
if (!row)
|
|
678
|
+
throw new CompanyNotFoundError(id);
|
|
679
|
+
return loadCompanyDetails(d, rowToCompany2(row));
|
|
680
|
+
}
|
|
681
|
+
function listCompanies(opts = {}, db) {
|
|
682
|
+
const d = db || getDatabase();
|
|
683
|
+
const {
|
|
684
|
+
limit = 50,
|
|
685
|
+
offset = 0,
|
|
686
|
+
industry,
|
|
687
|
+
tag_id,
|
|
688
|
+
order_by = "name",
|
|
689
|
+
order_dir = "asc"
|
|
690
|
+
} = opts;
|
|
691
|
+
const conditions = [];
|
|
692
|
+
const params = [];
|
|
693
|
+
if (industry) {
|
|
694
|
+
conditions.push("co.industry = ?");
|
|
695
|
+
params.push(industry);
|
|
696
|
+
}
|
|
697
|
+
if (tag_id) {
|
|
698
|
+
conditions.push("EXISTS (SELECT 1 FROM company_tags ct WHERE ct.company_id = co.id AND ct.tag_id = ?)");
|
|
699
|
+
params.push(tag_id);
|
|
700
|
+
}
|
|
701
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
702
|
+
const validOrderBy = ["name", "created_at", "updated_at"].includes(order_by) ? order_by : "name";
|
|
703
|
+
const validOrderDir = order_dir === "desc" ? "DESC" : "ASC";
|
|
704
|
+
const totalRow = d.query(`SELECT COUNT(*) as total FROM companies co ${where}`).get(...params);
|
|
705
|
+
const rows = d.query(`SELECT co.* FROM companies co ${where} ORDER BY co.${validOrderBy} ${validOrderDir} LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
|
706
|
+
const companies = rows.map((row) => loadCompanyDetails(d, rowToCompany2(row)));
|
|
707
|
+
return { companies, total: totalRow.total };
|
|
708
|
+
}
|
|
709
|
+
function updateCompany(id, input, db) {
|
|
710
|
+
const d = db || getDatabase();
|
|
711
|
+
const existing = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
|
|
712
|
+
if (!existing)
|
|
713
|
+
throw new CompanyNotFoundError(id);
|
|
714
|
+
const setClauses = ["updated_at = ?"];
|
|
715
|
+
const params = [now()];
|
|
716
|
+
if (input.name !== undefined) {
|
|
717
|
+
setClauses.push("name = ?");
|
|
718
|
+
params.push(input.name);
|
|
719
|
+
}
|
|
720
|
+
if (input.domain !== undefined) {
|
|
721
|
+
setClauses.push("domain = ?");
|
|
722
|
+
params.push(input.domain);
|
|
723
|
+
}
|
|
724
|
+
if (input.logo_url !== undefined) {
|
|
725
|
+
setClauses.push("logo_url = ?");
|
|
726
|
+
params.push(input.logo_url);
|
|
727
|
+
}
|
|
728
|
+
if (input.description !== undefined) {
|
|
729
|
+
setClauses.push("description = ?");
|
|
730
|
+
params.push(input.description);
|
|
731
|
+
}
|
|
732
|
+
if (input.industry !== undefined) {
|
|
733
|
+
setClauses.push("industry = ?");
|
|
734
|
+
params.push(input.industry);
|
|
735
|
+
}
|
|
736
|
+
if (input.size !== undefined) {
|
|
737
|
+
setClauses.push("size = ?");
|
|
738
|
+
params.push(input.size);
|
|
739
|
+
}
|
|
740
|
+
if (input.founded_year !== undefined) {
|
|
741
|
+
setClauses.push("founded_year = ?");
|
|
742
|
+
params.push(input.founded_year);
|
|
743
|
+
}
|
|
744
|
+
if (input.notes !== undefined) {
|
|
745
|
+
setClauses.push("notes = ?");
|
|
746
|
+
params.push(input.notes);
|
|
747
|
+
}
|
|
748
|
+
if (input.custom_fields !== undefined) {
|
|
749
|
+
setClauses.push("custom_fields = ?");
|
|
750
|
+
params.push(JSON.stringify(input.custom_fields));
|
|
751
|
+
}
|
|
752
|
+
params.push(id);
|
|
753
|
+
d.run(`UPDATE companies SET ${setClauses.join(", ")} WHERE id = ?`, params);
|
|
754
|
+
logActivity(d, { company_id: id, action: "company.updated", details: `Updated company: ${existing.name}` });
|
|
755
|
+
const row = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
|
|
756
|
+
return loadCompanyDetails(d, rowToCompany2(row));
|
|
757
|
+
}
|
|
758
|
+
function deleteCompany(id, db) {
|
|
759
|
+
const d = db || getDatabase();
|
|
760
|
+
const row = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
|
|
761
|
+
if (!row)
|
|
762
|
+
throw new CompanyNotFoundError(id);
|
|
763
|
+
logActivity(d, { company_id: id, action: "company.deleted", details: `Deleted company: ${row.name}` });
|
|
764
|
+
d.run(`DELETE FROM companies WHERE id = ?`, [id]);
|
|
765
|
+
}
|
|
766
|
+
function searchCompanies(query, db) {
|
|
767
|
+
const d = db || getDatabase();
|
|
768
|
+
const rows = d.query(`
|
|
769
|
+
SELECT * FROM companies
|
|
770
|
+
WHERE name LIKE ? OR domain LIKE ? OR description LIKE ? OR industry LIKE ?
|
|
771
|
+
LIMIT 50
|
|
772
|
+
`).all(`%${query}%`, `%${query}%`, `%${query}%`, `%${query}%`);
|
|
773
|
+
return rows.map((row) => loadCompanyDetails(d, rowToCompany2(row)));
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// src/db/tags.ts
|
|
777
|
+
function rowToTag2(row) {
|
|
778
|
+
return { ...row };
|
|
779
|
+
}
|
|
780
|
+
function createTag(input, db) {
|
|
781
|
+
const d = db || getDatabase();
|
|
782
|
+
const existing = d.query(`SELECT id FROM tags WHERE name = ?`).get(input.name);
|
|
783
|
+
if (existing)
|
|
784
|
+
throw new DuplicateTagNameError(input.name);
|
|
785
|
+
const id = uuid();
|
|
786
|
+
d.run(`INSERT INTO tags (id, name, color, description) VALUES (?, ?, ?, ?)`, [id, input.name, input.color ?? "#6366f1", input.description ?? null]);
|
|
787
|
+
return rowToTag2(d.query(`SELECT * FROM tags WHERE id = ?`).get(id));
|
|
788
|
+
}
|
|
789
|
+
function listTags(db) {
|
|
790
|
+
const d = db || getDatabase();
|
|
791
|
+
return d.query(`SELECT * FROM tags ORDER BY name ASC`).all().map(rowToTag2);
|
|
792
|
+
}
|
|
793
|
+
function deleteTag(id, db) {
|
|
794
|
+
const d = db || getDatabase();
|
|
795
|
+
const row = d.query(`SELECT id FROM tags WHERE id = ?`).get(id);
|
|
796
|
+
if (!row)
|
|
797
|
+
throw new TagNotFoundError(id);
|
|
798
|
+
d.run(`DELETE FROM tags WHERE id = ?`, [id]);
|
|
799
|
+
}
|
|
800
|
+
function addTagToContact(contactId, tagId, db) {
|
|
801
|
+
const d = db || getDatabase();
|
|
802
|
+
const contact = d.query(`SELECT id FROM contacts WHERE id = ?`).get(contactId);
|
|
803
|
+
if (!contact)
|
|
804
|
+
throw new ContactNotFoundError(contactId);
|
|
805
|
+
const tag = d.query(`SELECT id FROM tags WHERE id = ?`).get(tagId);
|
|
806
|
+
if (!tag)
|
|
807
|
+
throw new TagNotFoundError(tagId);
|
|
808
|
+
d.run(`INSERT OR IGNORE INTO contact_tags (contact_id, tag_id) VALUES (?, ?)`, [contactId, tagId]);
|
|
809
|
+
}
|
|
810
|
+
function removeTagFromContact(contactId, tagId, db) {
|
|
811
|
+
const d = db || getDatabase();
|
|
812
|
+
d.run(`DELETE FROM contact_tags WHERE contact_id = ? AND tag_id = ?`, [contactId, tagId]);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// src/db/relationships.ts
|
|
816
|
+
function rowToRelationship(row) {
|
|
817
|
+
return {
|
|
818
|
+
...row,
|
|
819
|
+
relationship_type: row.relationship_type
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
function createRelationship(input, db) {
|
|
823
|
+
const d = db || getDatabase();
|
|
824
|
+
const a = d.query(`SELECT id FROM contacts WHERE id = ?`).get(input.contact_a_id);
|
|
825
|
+
if (!a)
|
|
826
|
+
throw new ContactNotFoundError(input.contact_a_id);
|
|
827
|
+
const b = d.query(`SELECT id FROM contacts WHERE id = ?`).get(input.contact_b_id);
|
|
828
|
+
if (!b)
|
|
829
|
+
throw new ContactNotFoundError(input.contact_b_id);
|
|
830
|
+
const id = uuid();
|
|
831
|
+
d.run(`INSERT INTO contact_relationships (id, contact_a_id, contact_b_id, relationship_type, notes) VALUES (?, ?, ?, ?, ?)`, [id, input.contact_a_id, input.contact_b_id, input.relationship_type, input.notes ?? null]);
|
|
832
|
+
return rowToRelationship(d.query(`SELECT * FROM contact_relationships WHERE id = ?`).get(id));
|
|
833
|
+
}
|
|
834
|
+
function listRelationships(opts = {}, db) {
|
|
835
|
+
const d = db || getDatabase();
|
|
836
|
+
const { contact_id, relationship_type } = opts;
|
|
837
|
+
const conditions = [];
|
|
838
|
+
const params = [];
|
|
839
|
+
if (contact_id) {
|
|
840
|
+
conditions.push("(contact_a_id = ? OR contact_b_id = ?)");
|
|
841
|
+
params.push(contact_id, contact_id);
|
|
842
|
+
}
|
|
843
|
+
if (relationship_type) {
|
|
844
|
+
conditions.push("relationship_type = ?");
|
|
845
|
+
params.push(relationship_type);
|
|
846
|
+
}
|
|
847
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
848
|
+
const rows = d.query(`SELECT * FROM contact_relationships ${where} ORDER BY created_at DESC`).all(...params);
|
|
849
|
+
return rows.map(rowToRelationship);
|
|
850
|
+
}
|
|
851
|
+
function deleteRelationship(id, db) {
|
|
852
|
+
const d = db || getDatabase();
|
|
853
|
+
d.run(`DELETE FROM contact_relationships WHERE id = ?`, [id]);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// src/lib/import.ts
|
|
857
|
+
function parseCsv(text) {
|
|
858
|
+
const lines = text.split(/\r?\n/);
|
|
859
|
+
if (lines.length < 2)
|
|
860
|
+
return [];
|
|
861
|
+
const headers = parseCsvLine(lines[0]);
|
|
862
|
+
const rows = [];
|
|
863
|
+
for (let i = 1;i < lines.length; i++) {
|
|
864
|
+
const line = lines[i].trim();
|
|
865
|
+
if (!line)
|
|
866
|
+
continue;
|
|
867
|
+
const values = parseCsvLine(line);
|
|
868
|
+
const row = {};
|
|
869
|
+
headers.forEach((h, idx) => {
|
|
870
|
+
row[h.trim()] = values[idx]?.trim() ?? "";
|
|
871
|
+
});
|
|
872
|
+
rows.push(row);
|
|
873
|
+
}
|
|
874
|
+
return rows;
|
|
875
|
+
}
|
|
876
|
+
function parseCsvLine(line) {
|
|
877
|
+
const fields = [];
|
|
878
|
+
let current = "";
|
|
879
|
+
let inQuotes = false;
|
|
880
|
+
for (let i = 0;i < line.length; i++) {
|
|
881
|
+
const ch = line[i];
|
|
882
|
+
if (ch === '"') {
|
|
883
|
+
if (inQuotes && line[i + 1] === '"') {
|
|
884
|
+
current += '"';
|
|
885
|
+
i++;
|
|
886
|
+
} else {
|
|
887
|
+
inQuotes = !inQuotes;
|
|
888
|
+
}
|
|
889
|
+
} else if (ch === "," && !inQuotes) {
|
|
890
|
+
fields.push(current);
|
|
891
|
+
current = "";
|
|
892
|
+
} else {
|
|
893
|
+
current += ch;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
fields.push(current);
|
|
897
|
+
return fields;
|
|
898
|
+
}
|
|
899
|
+
function csvRowToContact(row) {
|
|
900
|
+
const firstName = row["First Name"] ?? row["first_name"] ?? row["Given Name"] ?? "";
|
|
901
|
+
const lastName = row["Last Name"] ?? row["last_name"] ?? row["Family Name"] ?? "";
|
|
902
|
+
const displayName = row["Name"] ?? row["display_name"] ?? row["Full Name"] ?? [firstName, lastName].filter(Boolean).join(" ") ?? "";
|
|
903
|
+
if (!displayName && !firstName && !lastName)
|
|
904
|
+
return null;
|
|
905
|
+
const contact = {
|
|
906
|
+
display_name: displayName || [firstName, lastName].filter(Boolean).join(" ") || "Unnamed",
|
|
907
|
+
first_name: firstName || undefined,
|
|
908
|
+
last_name: lastName || undefined,
|
|
909
|
+
job_title: row["Job Title"] ?? row["job_title"] ?? row["Title"] ?? undefined,
|
|
910
|
+
notes: row["Notes"] ?? row["notes"] ?? undefined,
|
|
911
|
+
birthday: row["Birthday"] ?? row["birthday"] ?? undefined,
|
|
912
|
+
source: "import"
|
|
913
|
+
};
|
|
914
|
+
const emails = [];
|
|
915
|
+
for (let i = 1;i <= 5; i++) {
|
|
916
|
+
const val = row[`Email ${i} - Value`] ?? row[`Email Address ${i}`] ?? (i === 1 ? row["Email"] ?? row["email"] ?? row["Email Address"] : undefined);
|
|
917
|
+
const rawType = row[`Email ${i} - Type`] ?? (i === 1 ? "work" : "other");
|
|
918
|
+
if (val) {
|
|
919
|
+
const type = rawType?.toLowerCase() === "personal" ? "personal" : rawType?.toLowerCase() === "other" ? "other" : "work";
|
|
920
|
+
emails.push({ address: val, type, is_primary: i === 1 });
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (emails.length)
|
|
924
|
+
contact.emails = emails;
|
|
925
|
+
const phones = [];
|
|
926
|
+
for (let i = 1;i <= 5; i++) {
|
|
927
|
+
const val = row[`Phone ${i} - Value`] ?? row[`Phone ${i}`] ?? (i === 1 ? row["Phone"] ?? row["phone"] ?? row["Mobile"] : undefined);
|
|
928
|
+
const rawType = row[`Phone ${i} - Type`] ?? (i === 1 ? "mobile" : "other");
|
|
929
|
+
if (val) {
|
|
930
|
+
const type = rawType?.toLowerCase().includes("mobile") || rawType?.toLowerCase().includes("cell") ? "mobile" : rawType?.toLowerCase().includes("work") ? "work" : rawType?.toLowerCase().includes("home") ? "home" : rawType?.toLowerCase().includes("fax") ? "fax" : "other";
|
|
931
|
+
phones.push({ number: val, type, is_primary: i === 1 });
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
if (phones.length)
|
|
935
|
+
contact.phones = phones;
|
|
936
|
+
return contact;
|
|
937
|
+
}
|
|
938
|
+
function importFromCsv(data) {
|
|
939
|
+
const rows = parseCsv(data);
|
|
940
|
+
return rows.map(csvRowToContact).filter(Boolean);
|
|
941
|
+
}
|
|
942
|
+
function parseVcf(data) {
|
|
943
|
+
const contacts = [];
|
|
944
|
+
const blocks = data.split(/BEGIN:VCARD/i).filter((b) => b.trim());
|
|
945
|
+
for (const block of blocks) {
|
|
946
|
+
try {
|
|
947
|
+
const contact = parseVcfBlock(`BEGIN:VCARD
|
|
948
|
+
` + block);
|
|
949
|
+
if (contact)
|
|
950
|
+
contacts.push(contact);
|
|
951
|
+
} catch {}
|
|
952
|
+
}
|
|
953
|
+
return contacts;
|
|
954
|
+
}
|
|
955
|
+
function parseVcfBlock(block) {
|
|
956
|
+
const unfolded = block.replace(/\r?\n[ \t]/g, "");
|
|
957
|
+
const lines = unfolded.split(/\r?\n/).filter((l) => l.trim());
|
|
958
|
+
const contact = { source: "import" };
|
|
959
|
+
const emails = [];
|
|
960
|
+
const phones = [];
|
|
961
|
+
const addresses = [];
|
|
962
|
+
const socials = [];
|
|
963
|
+
for (const line of lines) {
|
|
964
|
+
if (/^BEGIN:VCARD$/i.test(line) || /^END:VCARD$/i.test(line) || /^VERSION:/i.test(line))
|
|
965
|
+
continue;
|
|
966
|
+
const colonIdx = line.indexOf(":");
|
|
967
|
+
if (colonIdx === -1)
|
|
968
|
+
continue;
|
|
969
|
+
const propPart = line.slice(0, colonIdx);
|
|
970
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
971
|
+
const semicolonIdx = propPart.indexOf(";");
|
|
972
|
+
const propName = (semicolonIdx === -1 ? propPart : propPart.slice(0, semicolonIdx)).toUpperCase();
|
|
973
|
+
const params = semicolonIdx !== -1 ? propPart.slice(semicolonIdx + 1) : "";
|
|
974
|
+
switch (propName) {
|
|
975
|
+
case "FN":
|
|
976
|
+
contact.display_name = decodeVcfValue(value);
|
|
977
|
+
break;
|
|
978
|
+
case "N": {
|
|
979
|
+
const parts = value.split(";");
|
|
980
|
+
contact.last_name = decodeVcfValue(parts[0] ?? "") || undefined;
|
|
981
|
+
contact.first_name = decodeVcfValue(parts[1] ?? "") || undefined;
|
|
982
|
+
break;
|
|
983
|
+
}
|
|
984
|
+
case "NICKNAME":
|
|
985
|
+
contact.nickname = decodeVcfValue(value) || undefined;
|
|
986
|
+
break;
|
|
987
|
+
case "TITLE":
|
|
988
|
+
contact.job_title = decodeVcfValue(value) || undefined;
|
|
989
|
+
break;
|
|
990
|
+
case "NOTE":
|
|
991
|
+
contact.notes = decodeVcfValue(value) || undefined;
|
|
992
|
+
break;
|
|
993
|
+
case "BDAY":
|
|
994
|
+
contact.birthday = value.replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3");
|
|
995
|
+
break;
|
|
996
|
+
case "EMAIL": {
|
|
997
|
+
const typeMatch = params.match(/TYPE=([^;]+)/i);
|
|
998
|
+
const rawLabel = typeMatch ? typeMatch[1].toLowerCase() : "work";
|
|
999
|
+
const type = rawLabel.includes("personal") ? "personal" : rawLabel.includes("other") ? "other" : "work";
|
|
1000
|
+
const isPrimary = params.includes("PREF") || emails.length === 0;
|
|
1001
|
+
emails.push({ address: decodeVcfValue(value), type, is_primary: isPrimary });
|
|
1002
|
+
break;
|
|
1003
|
+
}
|
|
1004
|
+
case "TEL": {
|
|
1005
|
+
const typeMatch = params.match(/TYPE=([^;]+)/i);
|
|
1006
|
+
const rawLabel = typeMatch ? typeMatch[1].toLowerCase() : "mobile";
|
|
1007
|
+
const type = rawLabel.includes("cell") || rawLabel.includes("mobile") ? "mobile" : rawLabel.includes("work") ? "work" : rawLabel.includes("home") ? "home" : rawLabel.includes("fax") ? "fax" : "other";
|
|
1008
|
+
const isPrimary = params.includes("PREF") || phones.length === 0;
|
|
1009
|
+
phones.push({ number: decodeVcfValue(value), type, is_primary: isPrimary });
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
case "ADR": {
|
|
1013
|
+
const parts = value.split(";");
|
|
1014
|
+
const typeMatch = params.match(/TYPE=([^;]+)/i);
|
|
1015
|
+
const rawLabel = typeMatch ? typeMatch[1].toLowerCase().split(",")[0] : "physical";
|
|
1016
|
+
const type = rawLabel.includes("home") || rawLabel.includes("physical") ? "physical" : rawLabel.includes("mail") ? "mailing" : rawLabel.includes("bill") ? "billing" : "other";
|
|
1017
|
+
addresses.push({
|
|
1018
|
+
type,
|
|
1019
|
+
street: decodeVcfValue(parts[2] ?? "") || undefined,
|
|
1020
|
+
city: decodeVcfValue(parts[3] ?? "") || undefined,
|
|
1021
|
+
state: decodeVcfValue(parts[4] ?? "") || undefined,
|
|
1022
|
+
zip: decodeVcfValue(parts[5] ?? "") || undefined,
|
|
1023
|
+
country: decodeVcfValue(parts[6] ?? "") || undefined,
|
|
1024
|
+
is_primary: addresses.length === 0
|
|
1025
|
+
});
|
|
1026
|
+
break;
|
|
1027
|
+
}
|
|
1028
|
+
case "URL": {
|
|
1029
|
+
const url = decodeVcfValue(value);
|
|
1030
|
+
const platform = detectPlatform(url);
|
|
1031
|
+
socials.push({ platform, url, handle: url });
|
|
1032
|
+
break;
|
|
1033
|
+
}
|
|
1034
|
+
case "X-SOCIALPROFILE": {
|
|
1035
|
+
const typeMatch = params.match(/TYPE=([^;]+)/i);
|
|
1036
|
+
const platform = normalizePlatform(typeMatch?.[1] ?? "other");
|
|
1037
|
+
socials.push({ platform, handle: decodeVcfValue(value), url: value });
|
|
1038
|
+
break;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
if (!contact.display_name) {
|
|
1043
|
+
if (contact.first_name || contact.last_name) {
|
|
1044
|
+
contact.display_name = [contact.first_name, contact.last_name].filter(Boolean).join(" ");
|
|
1045
|
+
} else {
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
if (emails.length)
|
|
1050
|
+
contact.emails = emails;
|
|
1051
|
+
if (phones.length)
|
|
1052
|
+
contact.phones = phones;
|
|
1053
|
+
if (addresses.length)
|
|
1054
|
+
contact.addresses = addresses;
|
|
1055
|
+
if (socials.length)
|
|
1056
|
+
contact.social_profiles = socials;
|
|
1057
|
+
return contact;
|
|
1058
|
+
}
|
|
1059
|
+
function decodeVcfValue(val) {
|
|
1060
|
+
return val.replace(/\\n/g, `
|
|
1061
|
+
`).replace(/\\,/g, ",").replace(/\\;/g, ";").replace(/\\\\/g, "\\");
|
|
1062
|
+
}
|
|
1063
|
+
function detectPlatform(url) {
|
|
1064
|
+
const lower = url.toLowerCase();
|
|
1065
|
+
if (lower.includes("twitter.com") || lower.includes("x.com"))
|
|
1066
|
+
return "twitter";
|
|
1067
|
+
if (lower.includes("linkedin.com"))
|
|
1068
|
+
return "linkedin";
|
|
1069
|
+
if (lower.includes("github.com"))
|
|
1070
|
+
return "github";
|
|
1071
|
+
if (lower.includes("instagram.com"))
|
|
1072
|
+
return "instagram";
|
|
1073
|
+
if (lower.includes("facebook.com"))
|
|
1074
|
+
return "facebook";
|
|
1075
|
+
if (lower.includes("youtube.com"))
|
|
1076
|
+
return "youtube";
|
|
1077
|
+
if (lower.includes("telegram"))
|
|
1078
|
+
return "telegram";
|
|
1079
|
+
if (lower.includes("discord"))
|
|
1080
|
+
return "discord";
|
|
1081
|
+
if (lower.includes("tiktok"))
|
|
1082
|
+
return "tiktok";
|
|
1083
|
+
if (lower.includes("bluesky") || lower.includes("bsky"))
|
|
1084
|
+
return "bluesky";
|
|
1085
|
+
return "other";
|
|
1086
|
+
}
|
|
1087
|
+
function normalizePlatform(raw) {
|
|
1088
|
+
const lower = raw.toLowerCase();
|
|
1089
|
+
const platforms = [
|
|
1090
|
+
"twitter",
|
|
1091
|
+
"linkedin",
|
|
1092
|
+
"github",
|
|
1093
|
+
"instagram",
|
|
1094
|
+
"telegram",
|
|
1095
|
+
"discord",
|
|
1096
|
+
"youtube",
|
|
1097
|
+
"tiktok",
|
|
1098
|
+
"bluesky",
|
|
1099
|
+
"facebook",
|
|
1100
|
+
"whatsapp",
|
|
1101
|
+
"snapchat",
|
|
1102
|
+
"reddit"
|
|
1103
|
+
];
|
|
1104
|
+
for (const p of platforms) {
|
|
1105
|
+
if (lower.includes(p))
|
|
1106
|
+
return p;
|
|
1107
|
+
}
|
|
1108
|
+
return "other";
|
|
1109
|
+
}
|
|
1110
|
+
function importFromJson(data) {
|
|
1111
|
+
let parsed;
|
|
1112
|
+
try {
|
|
1113
|
+
parsed = JSON.parse(data);
|
|
1114
|
+
} catch {
|
|
1115
|
+
throw new Error("Invalid JSON");
|
|
1116
|
+
}
|
|
1117
|
+
if (!Array.isArray(parsed)) {
|
|
1118
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
1119
|
+
parsed = [parsed];
|
|
1120
|
+
} else {
|
|
1121
|
+
throw new Error("JSON must be an array of contacts");
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
return parsed.map((obj) => {
|
|
1125
|
+
const displayName = obj.display_name ?? obj.name ?? [obj.first_name ?? "", obj.last_name ?? ""].filter(Boolean).join(" ") ?? "Unnamed";
|
|
1126
|
+
return {
|
|
1127
|
+
...obj,
|
|
1128
|
+
display_name: displayName,
|
|
1129
|
+
source: "import"
|
|
1130
|
+
};
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
async function importContacts(format, data) {
|
|
1134
|
+
switch (format) {
|
|
1135
|
+
case "csv":
|
|
1136
|
+
return importFromCsv(data);
|
|
1137
|
+
case "vcf":
|
|
1138
|
+
return parseVcf(data);
|
|
1139
|
+
case "json":
|
|
1140
|
+
return importFromJson(data);
|
|
1141
|
+
default:
|
|
1142
|
+
throw new Error(`Unsupported import format: ${format}`);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// src/lib/export.ts
|
|
1147
|
+
function toJson(contacts) {
|
|
1148
|
+
return JSON.stringify(contacts, null, 2);
|
|
1149
|
+
}
|
|
1150
|
+
function escapeCsvField(val) {
|
|
1151
|
+
if (val == null)
|
|
1152
|
+
return "";
|
|
1153
|
+
const str = String(val);
|
|
1154
|
+
if (str.includes(",") || str.includes('"') || str.includes(`
|
|
1155
|
+
`)) {
|
|
1156
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
1157
|
+
}
|
|
1158
|
+
return str;
|
|
1159
|
+
}
|
|
1160
|
+
function toCsv(contacts) {
|
|
1161
|
+
const headers = [
|
|
1162
|
+
"First Name",
|
|
1163
|
+
"Last Name",
|
|
1164
|
+
"Name",
|
|
1165
|
+
"Nickname",
|
|
1166
|
+
"Job Title",
|
|
1167
|
+
"Company",
|
|
1168
|
+
"Email 1 - Value",
|
|
1169
|
+
"Email 1 - Type",
|
|
1170
|
+
"Email 2 - Value",
|
|
1171
|
+
"Email 2 - Type",
|
|
1172
|
+
"Phone 1 - Value",
|
|
1173
|
+
"Phone 1 - Type",
|
|
1174
|
+
"Phone 2 - Value",
|
|
1175
|
+
"Phone 2 - Type",
|
|
1176
|
+
"Address 1 - Street",
|
|
1177
|
+
"Address 1 - City",
|
|
1178
|
+
"Address 1 - State",
|
|
1179
|
+
"Address 1 - Postal Code",
|
|
1180
|
+
"Address 1 - Country",
|
|
1181
|
+
"Address 1 - Type",
|
|
1182
|
+
"Birthday",
|
|
1183
|
+
"Notes",
|
|
1184
|
+
"Tags"
|
|
1185
|
+
];
|
|
1186
|
+
const rows = [headers.map(escapeCsvField).join(",")];
|
|
1187
|
+
for (const c of contacts) {
|
|
1188
|
+
const emails = c.emails ?? [];
|
|
1189
|
+
const phones = c.phones ?? [];
|
|
1190
|
+
const addrs = c.addresses ?? [];
|
|
1191
|
+
const tags = (c.tags ?? []).map((t) => t.name).join(";");
|
|
1192
|
+
const row = [
|
|
1193
|
+
c.first_name,
|
|
1194
|
+
c.last_name,
|
|
1195
|
+
c.display_name,
|
|
1196
|
+
c.nickname,
|
|
1197
|
+
c.job_title,
|
|
1198
|
+
c.company?.name,
|
|
1199
|
+
emails[0]?.address,
|
|
1200
|
+
emails[0]?.type,
|
|
1201
|
+
emails[1]?.address,
|
|
1202
|
+
emails[1]?.type,
|
|
1203
|
+
phones[0]?.number,
|
|
1204
|
+
phones[0]?.type,
|
|
1205
|
+
phones[1]?.number,
|
|
1206
|
+
phones[1]?.type,
|
|
1207
|
+
addrs[0]?.street,
|
|
1208
|
+
addrs[0]?.city,
|
|
1209
|
+
addrs[0]?.state,
|
|
1210
|
+
addrs[0]?.zip,
|
|
1211
|
+
addrs[0]?.country,
|
|
1212
|
+
addrs[0]?.type,
|
|
1213
|
+
c.birthday,
|
|
1214
|
+
c.notes,
|
|
1215
|
+
tags
|
|
1216
|
+
];
|
|
1217
|
+
rows.push(row.map(escapeCsvField).join(","));
|
|
1218
|
+
}
|
|
1219
|
+
return rows.join(`
|
|
1220
|
+
`);
|
|
1221
|
+
}
|
|
1222
|
+
function escapeVcfValue(val) {
|
|
1223
|
+
if (!val)
|
|
1224
|
+
return "";
|
|
1225
|
+
return val.replace(/\\/g, "\\\\").replace(/,/g, "\\,").replace(/;/g, "\\;").replace(/\n/g, "\\n");
|
|
1226
|
+
}
|
|
1227
|
+
function foldVcfLine(line) {
|
|
1228
|
+
if (line.length <= 75)
|
|
1229
|
+
return line;
|
|
1230
|
+
const parts = [line.slice(0, 75)];
|
|
1231
|
+
let i = 75;
|
|
1232
|
+
while (i < line.length) {
|
|
1233
|
+
parts.push(" " + line.slice(i, i + 74));
|
|
1234
|
+
i += 74;
|
|
1235
|
+
}
|
|
1236
|
+
return parts.join(`\r
|
|
1237
|
+
`);
|
|
1238
|
+
}
|
|
1239
|
+
function toVcf(contacts) {
|
|
1240
|
+
const cards = [];
|
|
1241
|
+
for (const c of contacts) {
|
|
1242
|
+
const lines = ["BEGIN:VCARD", "VERSION:3.0"];
|
|
1243
|
+
lines.push(`FN:${escapeVcfValue(c.display_name)}`);
|
|
1244
|
+
lines.push(`N:${escapeVcfValue(c.last_name)};${escapeVcfValue(c.first_name)};;;`);
|
|
1245
|
+
if (c.nickname)
|
|
1246
|
+
lines.push(`NICKNAME:${escapeVcfValue(c.nickname)}`);
|
|
1247
|
+
if (c.job_title)
|
|
1248
|
+
lines.push(`TITLE:${escapeVcfValue(c.job_title)}`);
|
|
1249
|
+
if (c.company?.name)
|
|
1250
|
+
lines.push(`ORG:${escapeVcfValue(c.company.name)}`);
|
|
1251
|
+
if (c.birthday)
|
|
1252
|
+
lines.push(`BDAY:${c.birthday.replace(/-/g, "")}`);
|
|
1253
|
+
for (let i = 0;i < (c.emails ?? []).length; i++) {
|
|
1254
|
+
const e = c.emails[i];
|
|
1255
|
+
const pref = i === 0 || e.is_primary ? ";PREF" : "";
|
|
1256
|
+
lines.push(`EMAIL;TYPE=${e.type.toUpperCase()}${pref}:${escapeVcfValue(e.address)}`);
|
|
1257
|
+
}
|
|
1258
|
+
for (let i = 0;i < (c.phones ?? []).length; i++) {
|
|
1259
|
+
const p = c.phones[i];
|
|
1260
|
+
const pref = i === 0 || p.is_primary ? ";PREF" : "";
|
|
1261
|
+
const vcfType = p.type === "mobile" ? "CELL" : p.type.toUpperCase();
|
|
1262
|
+
lines.push(`TEL;TYPE=${vcfType}${pref}:${escapeVcfValue(p.number)}`);
|
|
1263
|
+
}
|
|
1264
|
+
for (let i = 0;i < (c.addresses ?? []).length; i++) {
|
|
1265
|
+
const a = c.addresses[i];
|
|
1266
|
+
const pref = i === 0 || a.is_primary ? ";PREF" : "";
|
|
1267
|
+
lines.push(`ADR;TYPE=${a.type.toUpperCase()}${pref}:;;${escapeVcfValue(a.street)};${escapeVcfValue(a.city)};${escapeVcfValue(a.state)};${escapeVcfValue(a.zip)};${escapeVcfValue(a.country)}`);
|
|
1268
|
+
}
|
|
1269
|
+
for (const sp of c.social_profiles ?? []) {
|
|
1270
|
+
if (sp.url)
|
|
1271
|
+
lines.push(`URL;TYPE=${sp.platform.toUpperCase()}:${escapeVcfValue(sp.url)}`);
|
|
1272
|
+
if (sp.handle)
|
|
1273
|
+
lines.push(`X-SOCIALPROFILE;TYPE=${sp.platform.toLowerCase()}:${escapeVcfValue(sp.handle)}`);
|
|
1274
|
+
}
|
|
1275
|
+
if (c.notes)
|
|
1276
|
+
lines.push(`NOTE:${escapeVcfValue(c.notes)}`);
|
|
1277
|
+
if (c.tags && c.tags.length > 0) {
|
|
1278
|
+
lines.push(`CATEGORIES:${c.tags.map((t) => escapeVcfValue(t.name)).join(",")}`);
|
|
1279
|
+
}
|
|
1280
|
+
lines.push(`UID:${c.id}`);
|
|
1281
|
+
lines.push("END:VCARD");
|
|
1282
|
+
cards.push(lines.map(foldVcfLine).join(`\r
|
|
1283
|
+
`));
|
|
1284
|
+
}
|
|
1285
|
+
return cards.join(`\r
|
|
1286
|
+
`);
|
|
1287
|
+
}
|
|
1288
|
+
async function exportContacts(format, contacts) {
|
|
1289
|
+
switch (format) {
|
|
1290
|
+
case "json":
|
|
1291
|
+
return toJson(contacts);
|
|
1292
|
+
case "csv":
|
|
1293
|
+
return toCsv(contacts);
|
|
1294
|
+
case "vcf":
|
|
1295
|
+
return toVcf(contacts);
|
|
1296
|
+
default:
|
|
1297
|
+
throw new Error(`Unsupported export format: ${format}`);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// src/mcp/index.ts
|
|
1302
|
+
var server = new Server({ name: "contacts", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
1303
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1304
|
+
tools: [
|
|
1305
|
+
{
|
|
1306
|
+
name: "create_contact",
|
|
1307
|
+
description: "Create a new contact",
|
|
1308
|
+
inputSchema: {
|
|
1309
|
+
type: "object",
|
|
1310
|
+
properties: {
|
|
1311
|
+
first_name: { type: "string" },
|
|
1312
|
+
last_name: { type: "string" },
|
|
1313
|
+
display_name: { type: "string", description: "Display name" },
|
|
1314
|
+
nickname: { type: "string" },
|
|
1315
|
+
job_title: { type: "string" },
|
|
1316
|
+
company_id: { type: "string" },
|
|
1317
|
+
notes: { type: "string" },
|
|
1318
|
+
birthday: { type: "string", description: "YYYY-MM-DD" },
|
|
1319
|
+
emails: {
|
|
1320
|
+
type: "array",
|
|
1321
|
+
items: {
|
|
1322
|
+
type: "object",
|
|
1323
|
+
properties: {
|
|
1324
|
+
address: { type: "string" },
|
|
1325
|
+
type: { type: "string", enum: ["work", "personal", "other"] },
|
|
1326
|
+
is_primary: { type: "boolean" }
|
|
1327
|
+
},
|
|
1328
|
+
required: ["address"]
|
|
1329
|
+
}
|
|
1330
|
+
},
|
|
1331
|
+
phones: {
|
|
1332
|
+
type: "array",
|
|
1333
|
+
items: {
|
|
1334
|
+
type: "object",
|
|
1335
|
+
properties: {
|
|
1336
|
+
number: { type: "string" },
|
|
1337
|
+
type: { type: "string", enum: ["mobile", "work", "home", "fax", "whatsapp", "other"] },
|
|
1338
|
+
is_primary: { type: "boolean" }
|
|
1339
|
+
},
|
|
1340
|
+
required: ["number"]
|
|
1341
|
+
}
|
|
1342
|
+
},
|
|
1343
|
+
addresses: {
|
|
1344
|
+
type: "array",
|
|
1345
|
+
items: {
|
|
1346
|
+
type: "object",
|
|
1347
|
+
properties: {
|
|
1348
|
+
type: { type: "string", enum: ["physical", "mailing", "billing", "virtual", "other"] },
|
|
1349
|
+
street: { type: "string" },
|
|
1350
|
+
city: { type: "string" },
|
|
1351
|
+
state: { type: "string" },
|
|
1352
|
+
zip: { type: "string" },
|
|
1353
|
+
country: { type: "string" },
|
|
1354
|
+
is_primary: { type: "boolean" }
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
},
|
|
1358
|
+
social_profiles: {
|
|
1359
|
+
type: "array",
|
|
1360
|
+
items: {
|
|
1361
|
+
type: "object",
|
|
1362
|
+
properties: {
|
|
1363
|
+
platform: { type: "string", enum: ["twitter", "linkedin", "github", "instagram", "telegram", "discord", "youtube", "tiktok", "bluesky", "facebook", "whatsapp", "snapchat", "reddit", "other"] },
|
|
1364
|
+
handle: { type: "string" },
|
|
1365
|
+
url: { type: "string" },
|
|
1366
|
+
is_primary: { type: "boolean" }
|
|
1367
|
+
},
|
|
1368
|
+
required: ["platform"]
|
|
1369
|
+
}
|
|
1370
|
+
},
|
|
1371
|
+
tag_ids: { type: "array", items: { type: "string" }, description: "Tag IDs to assign" },
|
|
1372
|
+
source: { type: "string" }
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
},
|
|
1376
|
+
{
|
|
1377
|
+
name: "get_contact",
|
|
1378
|
+
description: "Get a contact by ID",
|
|
1379
|
+
inputSchema: {
|
|
1380
|
+
type: "object",
|
|
1381
|
+
properties: { id: { type: "string" } },
|
|
1382
|
+
required: ["id"]
|
|
1383
|
+
}
|
|
1384
|
+
},
|
|
1385
|
+
{
|
|
1386
|
+
name: "update_contact",
|
|
1387
|
+
description: "Update an existing contact",
|
|
1388
|
+
inputSchema: {
|
|
1389
|
+
type: "object",
|
|
1390
|
+
properties: {
|
|
1391
|
+
id: { type: "string" },
|
|
1392
|
+
first_name: { type: "string" },
|
|
1393
|
+
last_name: { type: "string" },
|
|
1394
|
+
display_name: { type: "string" },
|
|
1395
|
+
nickname: { type: "string" },
|
|
1396
|
+
job_title: { type: "string" },
|
|
1397
|
+
company_id: { type: "string" },
|
|
1398
|
+
notes: { type: "string" },
|
|
1399
|
+
birthday: { type: "string" }
|
|
1400
|
+
},
|
|
1401
|
+
required: ["id"]
|
|
1402
|
+
}
|
|
1403
|
+
},
|
|
1404
|
+
{
|
|
1405
|
+
name: "delete_contact",
|
|
1406
|
+
description: "Delete a contact by ID",
|
|
1407
|
+
inputSchema: {
|
|
1408
|
+
type: "object",
|
|
1409
|
+
properties: { id: { type: "string" } },
|
|
1410
|
+
required: ["id"]
|
|
1411
|
+
}
|
|
1412
|
+
},
|
|
1413
|
+
{
|
|
1414
|
+
name: "list_contacts",
|
|
1415
|
+
description: "List contacts with optional filters",
|
|
1416
|
+
inputSchema: {
|
|
1417
|
+
type: "object",
|
|
1418
|
+
properties: {
|
|
1419
|
+
company_id: { type: "string" },
|
|
1420
|
+
tag_id: { type: "string", description: "Filter by tag ID" },
|
|
1421
|
+
limit: { type: "number", description: "Max results (default 50)" },
|
|
1422
|
+
offset: { type: "number" },
|
|
1423
|
+
order_by: { type: "string", enum: ["display_name", "created_at", "updated_at"] },
|
|
1424
|
+
order_dir: { type: "string", enum: ["asc", "desc"] }
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
},
|
|
1428
|
+
{
|
|
1429
|
+
name: "search_contacts",
|
|
1430
|
+
description: "Full-text search across contacts",
|
|
1431
|
+
inputSchema: {
|
|
1432
|
+
type: "object",
|
|
1433
|
+
properties: { query: { type: "string" } },
|
|
1434
|
+
required: ["query"]
|
|
1435
|
+
}
|
|
1436
|
+
},
|
|
1437
|
+
{
|
|
1438
|
+
name: "create_company",
|
|
1439
|
+
description: "Create a new company",
|
|
1440
|
+
inputSchema: {
|
|
1441
|
+
type: "object",
|
|
1442
|
+
properties: {
|
|
1443
|
+
name: { type: "string" },
|
|
1444
|
+
domain: { type: "string" },
|
|
1445
|
+
description: { type: "string" },
|
|
1446
|
+
industry: { type: "string" },
|
|
1447
|
+
size: { type: "string" },
|
|
1448
|
+
founded_year: { type: "number" },
|
|
1449
|
+
notes: { type: "string" },
|
|
1450
|
+
emails: { type: "array", items: { type: "object" } },
|
|
1451
|
+
phones: { type: "array", items: { type: "object" } },
|
|
1452
|
+
addresses: { type: "array", items: { type: "object" } },
|
|
1453
|
+
social_profiles: { type: "array", items: { type: "object" } },
|
|
1454
|
+
tag_ids: { type: "array", items: { type: "string" } }
|
|
1455
|
+
},
|
|
1456
|
+
required: ["name"]
|
|
1457
|
+
}
|
|
1458
|
+
},
|
|
1459
|
+
{
|
|
1460
|
+
name: "get_company",
|
|
1461
|
+
description: "Get a company by ID",
|
|
1462
|
+
inputSchema: {
|
|
1463
|
+
type: "object",
|
|
1464
|
+
properties: { id: { type: "string" } },
|
|
1465
|
+
required: ["id"]
|
|
1466
|
+
}
|
|
1467
|
+
},
|
|
1468
|
+
{
|
|
1469
|
+
name: "update_company",
|
|
1470
|
+
description: "Update an existing company",
|
|
1471
|
+
inputSchema: {
|
|
1472
|
+
type: "object",
|
|
1473
|
+
properties: {
|
|
1474
|
+
id: { type: "string" },
|
|
1475
|
+
name: { type: "string" },
|
|
1476
|
+
domain: { type: "string" },
|
|
1477
|
+
description: { type: "string" },
|
|
1478
|
+
industry: { type: "string" },
|
|
1479
|
+
size: { type: "string" },
|
|
1480
|
+
founded_year: { type: "number" },
|
|
1481
|
+
notes: { type: "string" }
|
|
1482
|
+
},
|
|
1483
|
+
required: ["id"]
|
|
1484
|
+
}
|
|
1485
|
+
},
|
|
1486
|
+
{
|
|
1487
|
+
name: "delete_company",
|
|
1488
|
+
description: "Delete a company by ID",
|
|
1489
|
+
inputSchema: {
|
|
1490
|
+
type: "object",
|
|
1491
|
+
properties: { id: { type: "string" } },
|
|
1492
|
+
required: ["id"]
|
|
1493
|
+
}
|
|
1494
|
+
},
|
|
1495
|
+
{
|
|
1496
|
+
name: "list_companies",
|
|
1497
|
+
description: "List companies with optional filters",
|
|
1498
|
+
inputSchema: {
|
|
1499
|
+
type: "object",
|
|
1500
|
+
properties: {
|
|
1501
|
+
tag_id: { type: "string" },
|
|
1502
|
+
industry: { type: "string" },
|
|
1503
|
+
limit: { type: "number" },
|
|
1504
|
+
offset: { type: "number" }
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
},
|
|
1508
|
+
{
|
|
1509
|
+
name: "search_companies",
|
|
1510
|
+
description: "Search companies by name or domain",
|
|
1511
|
+
inputSchema: {
|
|
1512
|
+
type: "object",
|
|
1513
|
+
properties: { query: { type: "string" } },
|
|
1514
|
+
required: ["query"]
|
|
1515
|
+
}
|
|
1516
|
+
},
|
|
1517
|
+
{
|
|
1518
|
+
name: "create_tag",
|
|
1519
|
+
description: "Create a new tag",
|
|
1520
|
+
inputSchema: {
|
|
1521
|
+
type: "object",
|
|
1522
|
+
properties: {
|
|
1523
|
+
name: { type: "string" },
|
|
1524
|
+
color: { type: "string", description: "Hex color (e.g. #FF5733)" },
|
|
1525
|
+
description: { type: "string" }
|
|
1526
|
+
},
|
|
1527
|
+
required: ["name"]
|
|
1528
|
+
}
|
|
1529
|
+
},
|
|
1530
|
+
{
|
|
1531
|
+
name: "list_tags",
|
|
1532
|
+
description: "List all tags",
|
|
1533
|
+
inputSchema: { type: "object", properties: {} }
|
|
1534
|
+
},
|
|
1535
|
+
{
|
|
1536
|
+
name: "delete_tag",
|
|
1537
|
+
description: "Delete a tag by ID",
|
|
1538
|
+
inputSchema: {
|
|
1539
|
+
type: "object",
|
|
1540
|
+
properties: { id: { type: "string" } },
|
|
1541
|
+
required: ["id"]
|
|
1542
|
+
}
|
|
1543
|
+
},
|
|
1544
|
+
{
|
|
1545
|
+
name: "add_tag_to_contact",
|
|
1546
|
+
description: "Add a tag to a contact",
|
|
1547
|
+
inputSchema: {
|
|
1548
|
+
type: "object",
|
|
1549
|
+
properties: {
|
|
1550
|
+
contact_id: { type: "string" },
|
|
1551
|
+
tag_id: { type: "string" }
|
|
1552
|
+
},
|
|
1553
|
+
required: ["contact_id", "tag_id"]
|
|
1554
|
+
}
|
|
1555
|
+
},
|
|
1556
|
+
{
|
|
1557
|
+
name: "remove_tag_from_contact",
|
|
1558
|
+
description: "Remove a tag from a contact",
|
|
1559
|
+
inputSchema: {
|
|
1560
|
+
type: "object",
|
|
1561
|
+
properties: {
|
|
1562
|
+
contact_id: { type: "string" },
|
|
1563
|
+
tag_id: { type: "string" }
|
|
1564
|
+
},
|
|
1565
|
+
required: ["contact_id", "tag_id"]
|
|
1566
|
+
}
|
|
1567
|
+
},
|
|
1568
|
+
{
|
|
1569
|
+
name: "add_relationship",
|
|
1570
|
+
description: "Add a relationship between two contacts",
|
|
1571
|
+
inputSchema: {
|
|
1572
|
+
type: "object",
|
|
1573
|
+
properties: {
|
|
1574
|
+
contact_a_id: { type: "string" },
|
|
1575
|
+
contact_b_id: { type: "string" },
|
|
1576
|
+
relationship_type: {
|
|
1577
|
+
type: "string",
|
|
1578
|
+
enum: ["colleague", "friend", "family", "reports_to", "mentor", "investor", "partner", "client", "vendor", "other"]
|
|
1579
|
+
},
|
|
1580
|
+
notes: { type: "string" }
|
|
1581
|
+
},
|
|
1582
|
+
required: ["contact_a_id", "contact_b_id", "relationship_type"]
|
|
1583
|
+
}
|
|
1584
|
+
},
|
|
1585
|
+
{
|
|
1586
|
+
name: "list_relationships",
|
|
1587
|
+
description: "List all relationships for a contact",
|
|
1588
|
+
inputSchema: {
|
|
1589
|
+
type: "object",
|
|
1590
|
+
properties: { contact_id: { type: "string" } },
|
|
1591
|
+
required: ["contact_id"]
|
|
1592
|
+
}
|
|
1593
|
+
},
|
|
1594
|
+
{
|
|
1595
|
+
name: "delete_relationship",
|
|
1596
|
+
description: "Delete a relationship by ID",
|
|
1597
|
+
inputSchema: {
|
|
1598
|
+
type: "object",
|
|
1599
|
+
properties: { id: { type: "string" } },
|
|
1600
|
+
required: ["id"]
|
|
1601
|
+
}
|
|
1602
|
+
},
|
|
1603
|
+
{
|
|
1604
|
+
name: "merge_contacts",
|
|
1605
|
+
description: "Merge two contacts \u2014 keeps one, removes the other, merging all data",
|
|
1606
|
+
inputSchema: {
|
|
1607
|
+
type: "object",
|
|
1608
|
+
properties: {
|
|
1609
|
+
keep_id: { type: "string" },
|
|
1610
|
+
merge_id: { type: "string" }
|
|
1611
|
+
},
|
|
1612
|
+
required: ["keep_id", "merge_id"]
|
|
1613
|
+
}
|
|
1614
|
+
},
|
|
1615
|
+
{
|
|
1616
|
+
name: "import_contacts",
|
|
1617
|
+
description: "Import contacts from CSV, vCard, or JSON format",
|
|
1618
|
+
inputSchema: {
|
|
1619
|
+
type: "object",
|
|
1620
|
+
properties: {
|
|
1621
|
+
format: { type: "string", enum: ["json", "csv", "vcf"] },
|
|
1622
|
+
data: { type: "string" }
|
|
1623
|
+
},
|
|
1624
|
+
required: ["format", "data"]
|
|
1625
|
+
}
|
|
1626
|
+
},
|
|
1627
|
+
{
|
|
1628
|
+
name: "export_contacts",
|
|
1629
|
+
description: "Export contacts to CSV, vCard, or JSON format",
|
|
1630
|
+
inputSchema: {
|
|
1631
|
+
type: "object",
|
|
1632
|
+
properties: {
|
|
1633
|
+
format: { type: "string", enum: ["json", "csv", "vcf"] },
|
|
1634
|
+
contact_ids: { type: "array", items: { type: "string" } }
|
|
1635
|
+
},
|
|
1636
|
+
required: ["format"]
|
|
1637
|
+
}
|
|
1638
|
+
},
|
|
1639
|
+
{
|
|
1640
|
+
name: "get_stats",
|
|
1641
|
+
description: "Get database statistics (counts of contacts, companies, tags)",
|
|
1642
|
+
inputSchema: { type: "object", properties: {} }
|
|
1643
|
+
}
|
|
1644
|
+
]
|
|
1645
|
+
}));
|
|
1646
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1647
|
+
const { name, arguments: args } = request.params;
|
|
1648
|
+
const a = args ?? {};
|
|
1649
|
+
try {
|
|
1650
|
+
switch (name) {
|
|
1651
|
+
case "create_contact": {
|
|
1652
|
+
const input = {
|
|
1653
|
+
first_name: a.first_name,
|
|
1654
|
+
last_name: a.last_name,
|
|
1655
|
+
display_name: a.display_name,
|
|
1656
|
+
nickname: a.nickname,
|
|
1657
|
+
job_title: a.job_title,
|
|
1658
|
+
company_id: a.company_id,
|
|
1659
|
+
notes: a.notes,
|
|
1660
|
+
birthday: a.birthday,
|
|
1661
|
+
emails: a.emails,
|
|
1662
|
+
phones: a.phones,
|
|
1663
|
+
addresses: a.addresses,
|
|
1664
|
+
social_profiles: a.social_profiles,
|
|
1665
|
+
tag_ids: a.tag_ids,
|
|
1666
|
+
source: a.source
|
|
1667
|
+
};
|
|
1668
|
+
const contact = createContact(input);
|
|
1669
|
+
return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
|
|
1670
|
+
}
|
|
1671
|
+
case "get_contact": {
|
|
1672
|
+
const contact = getContact(a.id);
|
|
1673
|
+
return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
|
|
1674
|
+
}
|
|
1675
|
+
case "update_contact": {
|
|
1676
|
+
const { id, ...rest } = a;
|
|
1677
|
+
const input = {
|
|
1678
|
+
first_name: rest.first_name,
|
|
1679
|
+
last_name: rest.last_name,
|
|
1680
|
+
display_name: rest.display_name,
|
|
1681
|
+
nickname: rest.nickname,
|
|
1682
|
+
job_title: rest.job_title,
|
|
1683
|
+
company_id: rest.company_id,
|
|
1684
|
+
notes: rest.notes,
|
|
1685
|
+
birthday: rest.birthday
|
|
1686
|
+
};
|
|
1687
|
+
const contact = updateContact(id, input);
|
|
1688
|
+
return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
|
|
1689
|
+
}
|
|
1690
|
+
case "delete_contact": {
|
|
1691
|
+
deleteContact(a.id);
|
|
1692
|
+
return { content: [{ type: "text", text: `Contact ${a.id} deleted successfully` }] };
|
|
1693
|
+
}
|
|
1694
|
+
case "list_contacts": {
|
|
1695
|
+
const result = listContacts({
|
|
1696
|
+
company_id: a.company_id,
|
|
1697
|
+
tag_id: a.tag_id,
|
|
1698
|
+
limit: a.limit,
|
|
1699
|
+
offset: a.offset,
|
|
1700
|
+
order_by: a.order_by,
|
|
1701
|
+
order_dir: a.order_dir
|
|
1702
|
+
});
|
|
1703
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
1704
|
+
}
|
|
1705
|
+
case "search_contacts": {
|
|
1706
|
+
const contacts = searchContacts(a.query);
|
|
1707
|
+
return { content: [{ type: "text", text: JSON.stringify(contacts, null, 2) }] };
|
|
1708
|
+
}
|
|
1709
|
+
case "create_company": {
|
|
1710
|
+
const input = {
|
|
1711
|
+
name: a.name,
|
|
1712
|
+
domain: a.domain,
|
|
1713
|
+
description: a.description,
|
|
1714
|
+
industry: a.industry,
|
|
1715
|
+
size: a.size,
|
|
1716
|
+
founded_year: a.founded_year,
|
|
1717
|
+
notes: a.notes,
|
|
1718
|
+
emails: a.emails,
|
|
1719
|
+
phones: a.phones,
|
|
1720
|
+
addresses: a.addresses,
|
|
1721
|
+
social_profiles: a.social_profiles,
|
|
1722
|
+
tag_ids: a.tag_ids
|
|
1723
|
+
};
|
|
1724
|
+
const company = createCompany(input);
|
|
1725
|
+
return { content: [{ type: "text", text: JSON.stringify(company, null, 2) }] };
|
|
1726
|
+
}
|
|
1727
|
+
case "get_company": {
|
|
1728
|
+
const company = getCompany(a.id);
|
|
1729
|
+
if (!company) {
|
|
1730
|
+
return { content: [{ type: "text", text: `Company not found: ${a.id}` }], isError: true };
|
|
1731
|
+
}
|
|
1732
|
+
return { content: [{ type: "text", text: JSON.stringify(company, null, 2) }] };
|
|
1733
|
+
}
|
|
1734
|
+
case "update_company": {
|
|
1735
|
+
const { id, ...rest } = a;
|
|
1736
|
+
const input = {
|
|
1737
|
+
name: rest.name,
|
|
1738
|
+
domain: rest.domain,
|
|
1739
|
+
description: rest.description,
|
|
1740
|
+
industry: rest.industry,
|
|
1741
|
+
size: rest.size,
|
|
1742
|
+
founded_year: rest.founded_year,
|
|
1743
|
+
notes: rest.notes
|
|
1744
|
+
};
|
|
1745
|
+
const company = updateCompany(id, input);
|
|
1746
|
+
return { content: [{ type: "text", text: JSON.stringify(company, null, 2) }] };
|
|
1747
|
+
}
|
|
1748
|
+
case "delete_company": {
|
|
1749
|
+
deleteCompany(a.id);
|
|
1750
|
+
return { content: [{ type: "text", text: `Company ${a.id} deleted successfully` }] };
|
|
1751
|
+
}
|
|
1752
|
+
case "list_companies": {
|
|
1753
|
+
const result = listCompanies({
|
|
1754
|
+
tag_id: a.tag_id,
|
|
1755
|
+
industry: a.industry,
|
|
1756
|
+
limit: a.limit,
|
|
1757
|
+
offset: a.offset
|
|
1758
|
+
});
|
|
1759
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
1760
|
+
}
|
|
1761
|
+
case "search_companies": {
|
|
1762
|
+
const companies = searchCompanies(a.query);
|
|
1763
|
+
return { content: [{ type: "text", text: JSON.stringify(companies, null, 2) }] };
|
|
1764
|
+
}
|
|
1765
|
+
case "create_tag": {
|
|
1766
|
+
const input = {
|
|
1767
|
+
name: a.name,
|
|
1768
|
+
color: a.color,
|
|
1769
|
+
description: a.description
|
|
1770
|
+
};
|
|
1771
|
+
const tag = createTag(input);
|
|
1772
|
+
return { content: [{ type: "text", text: JSON.stringify(tag, null, 2) }] };
|
|
1773
|
+
}
|
|
1774
|
+
case "list_tags": {
|
|
1775
|
+
const tags = listTags();
|
|
1776
|
+
return { content: [{ type: "text", text: JSON.stringify(tags, null, 2) }] };
|
|
1777
|
+
}
|
|
1778
|
+
case "delete_tag": {
|
|
1779
|
+
deleteTag(a.id);
|
|
1780
|
+
return { content: [{ type: "text", text: `Tag ${a.id} deleted successfully` }] };
|
|
1781
|
+
}
|
|
1782
|
+
case "add_tag_to_contact": {
|
|
1783
|
+
addTagToContact(a.contact_id, a.tag_id);
|
|
1784
|
+
return { content: [{ type: "text", text: `Tag ${a.tag_id} added to contact ${a.contact_id}` }] };
|
|
1785
|
+
}
|
|
1786
|
+
case "remove_tag_from_contact": {
|
|
1787
|
+
removeTagFromContact(a.contact_id, a.tag_id);
|
|
1788
|
+
return { content: [{ type: "text", text: `Tag ${a.tag_id} removed from contact ${a.contact_id}` }] };
|
|
1789
|
+
}
|
|
1790
|
+
case "add_relationship": {
|
|
1791
|
+
const input = {
|
|
1792
|
+
contact_a_id: a.contact_a_id,
|
|
1793
|
+
contact_b_id: a.contact_b_id,
|
|
1794
|
+
relationship_type: a.relationship_type,
|
|
1795
|
+
notes: a.notes
|
|
1796
|
+
};
|
|
1797
|
+
const rel = createRelationship(input);
|
|
1798
|
+
return { content: [{ type: "text", text: JSON.stringify(rel, null, 2) }] };
|
|
1799
|
+
}
|
|
1800
|
+
case "list_relationships": {
|
|
1801
|
+
const rels = listRelationships({ contact_id: a.contact_id });
|
|
1802
|
+
return { content: [{ type: "text", text: JSON.stringify(rels, null, 2) }] };
|
|
1803
|
+
}
|
|
1804
|
+
case "delete_relationship": {
|
|
1805
|
+
deleteRelationship(a.id);
|
|
1806
|
+
return { content: [{ type: "text", text: `Relationship ${a.id} deleted successfully` }] };
|
|
1807
|
+
}
|
|
1808
|
+
case "merge_contacts": {
|
|
1809
|
+
const merged = mergeContacts(a.keep_id, a.merge_id);
|
|
1810
|
+
return { content: [{ type: "text", text: JSON.stringify(merged, null, 2) }] };
|
|
1811
|
+
}
|
|
1812
|
+
case "import_contacts": {
|
|
1813
|
+
const format = a.format;
|
|
1814
|
+
const data = a.data;
|
|
1815
|
+
const inputs = await importContacts(format, data);
|
|
1816
|
+
let importedCount = 0;
|
|
1817
|
+
const errors = [];
|
|
1818
|
+
for (const input of inputs) {
|
|
1819
|
+
try {
|
|
1820
|
+
createContact(input);
|
|
1821
|
+
importedCount++;
|
|
1822
|
+
} catch (err) {
|
|
1823
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
return {
|
|
1827
|
+
content: [{
|
|
1828
|
+
type: "text",
|
|
1829
|
+
text: JSON.stringify({ imported: importedCount, errors: errors.length, error_details: errors }, null, 2)
|
|
1830
|
+
}]
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
case "export_contacts": {
|
|
1834
|
+
const format = a.format;
|
|
1835
|
+
const contactIds = a.contact_ids;
|
|
1836
|
+
let contactList;
|
|
1837
|
+
if (contactIds && contactIds.length > 0) {
|
|
1838
|
+
contactList = contactIds.map((id) => getContact(id));
|
|
1839
|
+
} else {
|
|
1840
|
+
contactList = listContacts({ limit: 1e4 }).contacts;
|
|
1841
|
+
}
|
|
1842
|
+
const output = await exportContacts(format, contactList);
|
|
1843
|
+
return { content: [{ type: "text", text: output }] };
|
|
1844
|
+
}
|
|
1845
|
+
case "get_stats": {
|
|
1846
|
+
const db = getDatabase();
|
|
1847
|
+
const contactCount = db.prepare("SELECT COUNT(*) as count FROM contacts").get().count;
|
|
1848
|
+
const companyCount = db.prepare("SELECT COUNT(*) as count FROM companies").get().count;
|
|
1849
|
+
const tagCount = db.prepare("SELECT COUNT(*) as count FROM tags").get().count;
|
|
1850
|
+
return {
|
|
1851
|
+
content: [{
|
|
1852
|
+
type: "text",
|
|
1853
|
+
text: JSON.stringify({ contacts: contactCount, companies: companyCount, tags: tagCount }, null, 2)
|
|
1854
|
+
}]
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
default:
|
|
1858
|
+
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
1859
|
+
}
|
|
1860
|
+
} catch (err) {
|
|
1861
|
+
return {
|
|
1862
|
+
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
1863
|
+
isError: true
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
});
|
|
1867
|
+
async function main() {
|
|
1868
|
+
const transport = new StdioServerTransport;
|
|
1869
|
+
await server.connect(transport);
|
|
1870
|
+
console.error("Contacts MCP server running on stdio");
|
|
1871
|
+
}
|
|
1872
|
+
main().catch((err) => {
|
|
1873
|
+
console.error("Fatal error:", err);
|
|
1874
|
+
process.exit(1);
|
|
1875
|
+
});
|