@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,1476 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// src/server/serve.ts
|
|
5
|
+
import { existsSync as existsSync2 } from "fs";
|
|
6
|
+
import { join as join2 } from "path";
|
|
7
|
+
|
|
8
|
+
// src/db/database.ts
|
|
9
|
+
import { Database } from "bun:sqlite";
|
|
10
|
+
import { existsSync, mkdirSync } from "fs";
|
|
11
|
+
import { dirname, join, resolve } from "path";
|
|
12
|
+
function getDbPath() {
|
|
13
|
+
if (process.env["CONTACTS_DB_PATH"])
|
|
14
|
+
return process.env["CONTACTS_DB_PATH"];
|
|
15
|
+
const home = process.env["HOME"] || "~";
|
|
16
|
+
return join(home, ".contacts", "contacts.db");
|
|
17
|
+
}
|
|
18
|
+
function ensureDir(filePath) {
|
|
19
|
+
if (filePath === ":memory:")
|
|
20
|
+
return;
|
|
21
|
+
const dir = dirname(resolve(filePath));
|
|
22
|
+
if (!existsSync(dir))
|
|
23
|
+
mkdirSync(dir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
var MIGRATIONS = [
|
|
26
|
+
`
|
|
27
|
+
CREATE TABLE IF NOT EXISTS companies (
|
|
28
|
+
id TEXT PRIMARY KEY,
|
|
29
|
+
name TEXT NOT NULL,
|
|
30
|
+
domain TEXT,
|
|
31
|
+
logo_url TEXT,
|
|
32
|
+
description TEXT,
|
|
33
|
+
industry TEXT,
|
|
34
|
+
size TEXT,
|
|
35
|
+
founded_year INTEGER,
|
|
36
|
+
notes TEXT,
|
|
37
|
+
custom_fields TEXT NOT NULL DEFAULT '{}',
|
|
38
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
39
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE TABLE IF NOT EXISTS contacts (
|
|
43
|
+
id TEXT PRIMARY KEY,
|
|
44
|
+
first_name TEXT NOT NULL DEFAULT '',
|
|
45
|
+
last_name TEXT NOT NULL DEFAULT '',
|
|
46
|
+
display_name TEXT NOT NULL,
|
|
47
|
+
nickname TEXT,
|
|
48
|
+
avatar_url TEXT,
|
|
49
|
+
notes TEXT,
|
|
50
|
+
birthday TEXT,
|
|
51
|
+
company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
|
|
52
|
+
job_title TEXT,
|
|
53
|
+
source TEXT NOT NULL DEFAULT 'manual',
|
|
54
|
+
custom_fields TEXT NOT NULL DEFAULT '{}',
|
|
55
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
56
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE IF NOT EXISTS tags (
|
|
60
|
+
id TEXT PRIMARY KEY,
|
|
61
|
+
name TEXT NOT NULL UNIQUE,
|
|
62
|
+
color TEXT NOT NULL DEFAULT '#6366f1',
|
|
63
|
+
description TEXT,
|
|
64
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
CREATE TABLE IF NOT EXISTS contact_tags (
|
|
68
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
69
|
+
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
70
|
+
PRIMARY KEY (contact_id, tag_id)
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE TABLE IF NOT EXISTS company_tags (
|
|
74
|
+
company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
|
75
|
+
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
76
|
+
PRIMARY KEY (company_id, tag_id)
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
CREATE TABLE IF NOT EXISTS emails (
|
|
80
|
+
id TEXT PRIMARY KEY,
|
|
81
|
+
contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
|
|
82
|
+
company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
|
|
83
|
+
address TEXT NOT NULL,
|
|
84
|
+
type TEXT NOT NULL DEFAULT 'work' CHECK(type IN ('work','personal','other')),
|
|
85
|
+
is_primary INTEGER NOT NULL DEFAULT 0,
|
|
86
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
CREATE TABLE IF NOT EXISTS phones (
|
|
90
|
+
id TEXT PRIMARY KEY,
|
|
91
|
+
contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
|
|
92
|
+
company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
|
|
93
|
+
number TEXT NOT NULL,
|
|
94
|
+
country_code TEXT,
|
|
95
|
+
type TEXT NOT NULL DEFAULT 'mobile' CHECK(type IN ('mobile','work','home','fax','whatsapp','other')),
|
|
96
|
+
is_primary INTEGER NOT NULL DEFAULT 0,
|
|
97
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
CREATE TABLE IF NOT EXISTS addresses (
|
|
101
|
+
id TEXT PRIMARY KEY,
|
|
102
|
+
contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
|
|
103
|
+
company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
|
|
104
|
+
type TEXT NOT NULL DEFAULT 'physical' CHECK(type IN ('physical','mailing','billing','virtual','other')),
|
|
105
|
+
street TEXT,
|
|
106
|
+
city TEXT,
|
|
107
|
+
state TEXT,
|
|
108
|
+
zip TEXT,
|
|
109
|
+
country TEXT,
|
|
110
|
+
is_primary INTEGER NOT NULL DEFAULT 0,
|
|
111
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
CREATE TABLE IF NOT EXISTS social_profiles (
|
|
115
|
+
id TEXT PRIMARY KEY,
|
|
116
|
+
contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
|
|
117
|
+
company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
|
|
118
|
+
platform TEXT NOT NULL CHECK(platform IN ('twitter','linkedin','github','instagram','telegram','discord','youtube','tiktok','bluesky','facebook','whatsapp','snapchat','reddit','other')),
|
|
119
|
+
handle TEXT,
|
|
120
|
+
url TEXT,
|
|
121
|
+
is_primary INTEGER NOT NULL DEFAULT 0,
|
|
122
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
CREATE TABLE IF NOT EXISTS contact_relationships (
|
|
126
|
+
id TEXT PRIMARY KEY,
|
|
127
|
+
contact_a_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
128
|
+
contact_b_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
129
|
+
relationship_type TEXT NOT NULL CHECK(relationship_type IN ('colleague','friend','family','reports_to','mentor','investor','partner','client','vendor','other')),
|
|
130
|
+
notes TEXT,
|
|
131
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
CREATE TABLE IF NOT EXISTS activity_log (
|
|
135
|
+
id TEXT PRIMARY KEY,
|
|
136
|
+
contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
|
|
137
|
+
company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
|
|
138
|
+
action TEXT NOT NULL,
|
|
139
|
+
details TEXT,
|
|
140
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
CREATE TABLE IF NOT EXISTS webhooks (
|
|
144
|
+
id TEXT PRIMARY KEY,
|
|
145
|
+
url TEXT NOT NULL,
|
|
146
|
+
events TEXT NOT NULL DEFAULT '["*"]',
|
|
147
|
+
secret TEXT,
|
|
148
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
149
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS contacts_fts USING fts5(
|
|
153
|
+
id UNINDEXED,
|
|
154
|
+
display_name,
|
|
155
|
+
first_name,
|
|
156
|
+
last_name,
|
|
157
|
+
nickname,
|
|
158
|
+
notes,
|
|
159
|
+
job_title,
|
|
160
|
+
content='contacts',
|
|
161
|
+
content_rowid='rowid'
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
CREATE TRIGGER IF NOT EXISTS contacts_fts_insert AFTER INSERT ON contacts BEGIN
|
|
165
|
+
INSERT INTO contacts_fts(rowid, id, display_name, first_name, last_name, nickname, notes, job_title)
|
|
166
|
+
VALUES (new.rowid, new.id, new.display_name, new.first_name, new.last_name, new.nickname, new.notes, new.job_title);
|
|
167
|
+
END;
|
|
168
|
+
|
|
169
|
+
CREATE TRIGGER IF NOT EXISTS contacts_fts_update AFTER UPDATE ON contacts BEGIN
|
|
170
|
+
DELETE FROM contacts_fts WHERE rowid = old.rowid;
|
|
171
|
+
INSERT INTO contacts_fts(rowid, id, display_name, first_name, last_name, nickname, notes, job_title)
|
|
172
|
+
VALUES (new.rowid, new.id, new.display_name, new.first_name, new.last_name, new.nickname, new.notes, new.job_title);
|
|
173
|
+
END;
|
|
174
|
+
|
|
175
|
+
CREATE TRIGGER IF NOT EXISTS contacts_fts_delete AFTER DELETE ON contacts BEGIN
|
|
176
|
+
DELETE FROM contacts_fts WHERE rowid = old.rowid;
|
|
177
|
+
END;
|
|
178
|
+
|
|
179
|
+
CREATE TABLE IF NOT EXISTS _migrations (version INTEGER PRIMARY KEY);
|
|
180
|
+
`
|
|
181
|
+
];
|
|
182
|
+
var _db = null;
|
|
183
|
+
function getDatabase(path) {
|
|
184
|
+
if (_db)
|
|
185
|
+
return _db;
|
|
186
|
+
const dbPath = path || getDbPath();
|
|
187
|
+
ensureDir(dbPath);
|
|
188
|
+
const db = new Database(dbPath, { create: true });
|
|
189
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
190
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
191
|
+
runMigrations(db);
|
|
192
|
+
_db = db;
|
|
193
|
+
return db;
|
|
194
|
+
}
|
|
195
|
+
function uuid() {
|
|
196
|
+
return crypto.randomUUID();
|
|
197
|
+
}
|
|
198
|
+
function now() {
|
|
199
|
+
return new Date().toISOString();
|
|
200
|
+
}
|
|
201
|
+
function runMigrations(db) {
|
|
202
|
+
try {
|
|
203
|
+
const row = db.query("SELECT MAX(version) as v FROM _migrations").get();
|
|
204
|
+
const current = row?.v ?? -1;
|
|
205
|
+
for (let i = current + 1;i < MIGRATIONS.length; i++) {
|
|
206
|
+
db.exec(MIGRATIONS[i]);
|
|
207
|
+
db.exec(`INSERT OR REPLACE INTO _migrations(version) VALUES(${i})`);
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
for (const m of MIGRATIONS) {
|
|
211
|
+
try {
|
|
212
|
+
db.exec(m);
|
|
213
|
+
} catch {}
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
db.exec(`INSERT OR REPLACE INTO _migrations(version) VALUES(${MIGRATIONS.length - 1})`);
|
|
217
|
+
} catch {}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// src/types/index.ts
|
|
222
|
+
class ContactNotFoundError extends Error {
|
|
223
|
+
constructor(id) {
|
|
224
|
+
super(`Contact not found: ${id}`);
|
|
225
|
+
this.name = "ContactNotFoundError";
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
class CompanyNotFoundError extends Error {
|
|
230
|
+
constructor(id) {
|
|
231
|
+
super(`Company not found: ${id}`);
|
|
232
|
+
this.name = "CompanyNotFoundError";
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
class TagNotFoundError extends Error {
|
|
237
|
+
constructor(id) {
|
|
238
|
+
super(`Tag not found: ${id}`);
|
|
239
|
+
this.name = "TagNotFoundError";
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
class DuplicateTagNameError extends Error {
|
|
244
|
+
constructor(name) {
|
|
245
|
+
super(`Tag with name already exists: ${name}`);
|
|
246
|
+
this.name = "DuplicateTagNameError";
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// src/db/activity.ts
|
|
251
|
+
function logActivity(db, input) {
|
|
252
|
+
const id = uuid();
|
|
253
|
+
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]);
|
|
254
|
+
return db.query(`SELECT * FROM activity_log WHERE id = ?`).get(id);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// src/db/contacts.ts
|
|
258
|
+
function rowToContact(row) {
|
|
259
|
+
return {
|
|
260
|
+
...row,
|
|
261
|
+
source: row.source,
|
|
262
|
+
custom_fields: JSON.parse(row.custom_fields || "{}")
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
function rowToEmail(row) {
|
|
266
|
+
return {
|
|
267
|
+
...row,
|
|
268
|
+
type: row.type,
|
|
269
|
+
is_primary: !!row.is_primary
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function rowToPhone(row) {
|
|
273
|
+
return {
|
|
274
|
+
...row,
|
|
275
|
+
type: row.type,
|
|
276
|
+
is_primary: !!row.is_primary
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function rowToAddress(row) {
|
|
280
|
+
return {
|
|
281
|
+
...row,
|
|
282
|
+
type: row.type,
|
|
283
|
+
is_primary: !!row.is_primary
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function rowToSocialProfile(row) {
|
|
287
|
+
return {
|
|
288
|
+
...row,
|
|
289
|
+
platform: row.platform,
|
|
290
|
+
is_primary: !!row.is_primary
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
function rowToTag(row) {
|
|
294
|
+
return { ...row };
|
|
295
|
+
}
|
|
296
|
+
function rowToCompany(row) {
|
|
297
|
+
return {
|
|
298
|
+
...row,
|
|
299
|
+
custom_fields: JSON.parse(row.custom_fields || "{}")
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function insertEmails(db, contactId, companyId, emails) {
|
|
303
|
+
for (const e of emails) {
|
|
304
|
+
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]);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function insertPhones(db, contactId, companyId, phones) {
|
|
308
|
+
for (const p of phones) {
|
|
309
|
+
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]);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function insertAddresses(db, contactId, companyId, addresses) {
|
|
313
|
+
for (const a of addresses) {
|
|
314
|
+
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]);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function insertSocialProfiles(db, contactId, companyId, profiles) {
|
|
318
|
+
for (const s of profiles) {
|
|
319
|
+
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]);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function loadContactDetails(db, contact) {
|
|
323
|
+
const emails = db.query(`SELECT * FROM emails WHERE contact_id = ?`).all(contact.id).map(rowToEmail);
|
|
324
|
+
const phones = db.query(`SELECT * FROM phones WHERE contact_id = ?`).all(contact.id).map(rowToPhone);
|
|
325
|
+
const addresses = db.query(`SELECT * FROM addresses WHERE contact_id = ?`).all(contact.id).map(rowToAddress);
|
|
326
|
+
const social_profiles = db.query(`SELECT * FROM social_profiles WHERE contact_id = ?`).all(contact.id).map(rowToSocialProfile);
|
|
327
|
+
const tags = db.query(`
|
|
328
|
+
SELECT t.* FROM tags t
|
|
329
|
+
JOIN contact_tags ct ON ct.tag_id = t.id
|
|
330
|
+
WHERE ct.contact_id = ?
|
|
331
|
+
`).all(contact.id).map(rowToTag);
|
|
332
|
+
const companyRow = contact.company_id ? db.query(`SELECT * FROM companies WHERE id = ?`).get(contact.company_id) : null;
|
|
333
|
+
const company = companyRow ? rowToCompany(companyRow) : null;
|
|
334
|
+
return { ...contact, emails, phones, addresses, social_profiles, tags, company };
|
|
335
|
+
}
|
|
336
|
+
function createContact(input, db) {
|
|
337
|
+
const d = db || getDatabase();
|
|
338
|
+
const id = uuid();
|
|
339
|
+
const timestamp = now();
|
|
340
|
+
const firstName = input.first_name ?? "";
|
|
341
|
+
const lastName = input.last_name ?? "";
|
|
342
|
+
const displayName = input.display_name ?? (firstName || lastName ? `${firstName} ${lastName}`.trim() : "Unnamed Contact");
|
|
343
|
+
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)
|
|
344
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
345
|
+
id,
|
|
346
|
+
firstName,
|
|
347
|
+
lastName,
|
|
348
|
+
displayName,
|
|
349
|
+
input.nickname ?? null,
|
|
350
|
+
input.avatar_url ?? null,
|
|
351
|
+
input.notes ?? null,
|
|
352
|
+
input.birthday ?? null,
|
|
353
|
+
input.company_id ?? null,
|
|
354
|
+
input.job_title ?? null,
|
|
355
|
+
input.source ?? "manual",
|
|
356
|
+
JSON.stringify(input.custom_fields ?? {}),
|
|
357
|
+
timestamp,
|
|
358
|
+
timestamp
|
|
359
|
+
]);
|
|
360
|
+
if (input.emails?.length)
|
|
361
|
+
insertEmails(d, id, null, input.emails);
|
|
362
|
+
if (input.phones?.length)
|
|
363
|
+
insertPhones(d, id, null, input.phones);
|
|
364
|
+
if (input.addresses?.length)
|
|
365
|
+
insertAddresses(d, id, null, input.addresses);
|
|
366
|
+
if (input.social_profiles?.length)
|
|
367
|
+
insertSocialProfiles(d, id, null, input.social_profiles);
|
|
368
|
+
if (input.tag_ids?.length) {
|
|
369
|
+
for (const tagId of input.tag_ids) {
|
|
370
|
+
d.run(`INSERT OR IGNORE INTO contact_tags (contact_id, tag_id) VALUES (?, ?)`, [id, tagId]);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
logActivity(d, { contact_id: id, action: "contact.created", details: `Created contact: ${displayName}` });
|
|
374
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
375
|
+
return loadContactDetails(d, rowToContact(row));
|
|
376
|
+
}
|
|
377
|
+
function getContact(id, db) {
|
|
378
|
+
const d = db || getDatabase();
|
|
379
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
380
|
+
if (!row)
|
|
381
|
+
throw new ContactNotFoundError(id);
|
|
382
|
+
return loadContactDetails(d, rowToContact(row));
|
|
383
|
+
}
|
|
384
|
+
function listContacts(opts = {}, db) {
|
|
385
|
+
const d = db || getDatabase();
|
|
386
|
+
const {
|
|
387
|
+
limit = 50,
|
|
388
|
+
offset = 0,
|
|
389
|
+
company_id,
|
|
390
|
+
tag_id,
|
|
391
|
+
source,
|
|
392
|
+
order_by = "display_name",
|
|
393
|
+
order_dir = "asc"
|
|
394
|
+
} = opts;
|
|
395
|
+
const conditions = [];
|
|
396
|
+
const params = [];
|
|
397
|
+
if (company_id) {
|
|
398
|
+
conditions.push("c.company_id = ?");
|
|
399
|
+
params.push(company_id);
|
|
400
|
+
}
|
|
401
|
+
if (source) {
|
|
402
|
+
conditions.push("c.source = ?");
|
|
403
|
+
params.push(source);
|
|
404
|
+
}
|
|
405
|
+
if (tag_id) {
|
|
406
|
+
conditions.push("EXISTS (SELECT 1 FROM contact_tags ct WHERE ct.contact_id = c.id AND ct.tag_id = ?)");
|
|
407
|
+
params.push(tag_id);
|
|
408
|
+
}
|
|
409
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
410
|
+
const validOrderBy = ["display_name", "created_at", "updated_at"].includes(order_by) ? order_by : "display_name";
|
|
411
|
+
const validOrderDir = order_dir === "desc" ? "DESC" : "ASC";
|
|
412
|
+
const totalRow = d.query(`SELECT COUNT(*) as total FROM contacts c ${where}`).get(...params);
|
|
413
|
+
const rows = d.query(`SELECT c.* FROM contacts c ${where} ORDER BY c.${validOrderBy} ${validOrderDir} LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
|
414
|
+
const contacts = rows.map((row) => loadContactDetails(d, rowToContact(row)));
|
|
415
|
+
return { contacts, total: totalRow.total };
|
|
416
|
+
}
|
|
417
|
+
function updateContact(id, input, db) {
|
|
418
|
+
const d = db || getDatabase();
|
|
419
|
+
const existing = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
420
|
+
if (!existing)
|
|
421
|
+
throw new ContactNotFoundError(id);
|
|
422
|
+
const setClauses = ["updated_at = ?"];
|
|
423
|
+
const params = [now()];
|
|
424
|
+
if (input.first_name !== undefined) {
|
|
425
|
+
setClauses.push("first_name = ?");
|
|
426
|
+
params.push(input.first_name);
|
|
427
|
+
}
|
|
428
|
+
if (input.last_name !== undefined) {
|
|
429
|
+
setClauses.push("last_name = ?");
|
|
430
|
+
params.push(input.last_name);
|
|
431
|
+
}
|
|
432
|
+
if (input.display_name !== undefined) {
|
|
433
|
+
setClauses.push("display_name = ?");
|
|
434
|
+
params.push(input.display_name);
|
|
435
|
+
}
|
|
436
|
+
if (input.nickname !== undefined) {
|
|
437
|
+
setClauses.push("nickname = ?");
|
|
438
|
+
params.push(input.nickname);
|
|
439
|
+
}
|
|
440
|
+
if (input.avatar_url !== undefined) {
|
|
441
|
+
setClauses.push("avatar_url = ?");
|
|
442
|
+
params.push(input.avatar_url);
|
|
443
|
+
}
|
|
444
|
+
if (input.notes !== undefined) {
|
|
445
|
+
setClauses.push("notes = ?");
|
|
446
|
+
params.push(input.notes);
|
|
447
|
+
}
|
|
448
|
+
if (input.birthday !== undefined) {
|
|
449
|
+
setClauses.push("birthday = ?");
|
|
450
|
+
params.push(input.birthday);
|
|
451
|
+
}
|
|
452
|
+
if (input.company_id !== undefined) {
|
|
453
|
+
setClauses.push("company_id = ?");
|
|
454
|
+
params.push(input.company_id);
|
|
455
|
+
}
|
|
456
|
+
if (input.job_title !== undefined) {
|
|
457
|
+
setClauses.push("job_title = ?");
|
|
458
|
+
params.push(input.job_title);
|
|
459
|
+
}
|
|
460
|
+
if (input.source !== undefined) {
|
|
461
|
+
setClauses.push("source = ?");
|
|
462
|
+
params.push(input.source);
|
|
463
|
+
}
|
|
464
|
+
if (input.custom_fields !== undefined) {
|
|
465
|
+
setClauses.push("custom_fields = ?");
|
|
466
|
+
params.push(JSON.stringify(input.custom_fields));
|
|
467
|
+
}
|
|
468
|
+
params.push(id);
|
|
469
|
+
d.run(`UPDATE contacts SET ${setClauses.join(", ")} WHERE id = ?`, params);
|
|
470
|
+
logActivity(d, { contact_id: id, action: "contact.updated", details: `Updated contact: ${existing.display_name}` });
|
|
471
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
472
|
+
return loadContactDetails(d, rowToContact(row));
|
|
473
|
+
}
|
|
474
|
+
function deleteContact(id, db) {
|
|
475
|
+
const d = db || getDatabase();
|
|
476
|
+
const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
|
|
477
|
+
if (!row)
|
|
478
|
+
throw new ContactNotFoundError(id);
|
|
479
|
+
logActivity(d, { contact_id: id, action: "contact.deleted", details: `Deleted contact: ${row.display_name}` });
|
|
480
|
+
d.run(`DELETE FROM contacts WHERE id = ?`, [id]);
|
|
481
|
+
}
|
|
482
|
+
function searchContacts(query, db) {
|
|
483
|
+
const d = db || getDatabase();
|
|
484
|
+
const ftsRows = d.query(`
|
|
485
|
+
SELECT c.* FROM contacts c
|
|
486
|
+
JOIN contacts_fts fts ON fts.id = c.id
|
|
487
|
+
WHERE contacts_fts MATCH ?
|
|
488
|
+
ORDER BY rank
|
|
489
|
+
LIMIT 50
|
|
490
|
+
`).all(`"${query.replace(/"/g, '""')}"*`);
|
|
491
|
+
const emailRows = d.query(`
|
|
492
|
+
SELECT DISTINCT c.* FROM contacts c
|
|
493
|
+
JOIN emails e ON e.contact_id = c.id
|
|
494
|
+
WHERE e.address LIKE ?
|
|
495
|
+
LIMIT 20
|
|
496
|
+
`).all(`%${query}%`);
|
|
497
|
+
const phoneRows = d.query(`
|
|
498
|
+
SELECT DISTINCT c.* FROM contacts c
|
|
499
|
+
JOIN phones p ON p.contact_id = c.id
|
|
500
|
+
WHERE p.number LIKE ?
|
|
501
|
+
LIMIT 20
|
|
502
|
+
`).all(`%${query}%`);
|
|
503
|
+
const seen = new Set;
|
|
504
|
+
const allRows = [];
|
|
505
|
+
for (const row of [...ftsRows, ...emailRows, ...phoneRows]) {
|
|
506
|
+
if (!seen.has(row.id)) {
|
|
507
|
+
seen.add(row.id);
|
|
508
|
+
allRows.push(row);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return allRows.map((row) => loadContactDetails(d, rowToContact(row)));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// src/db/companies.ts
|
|
515
|
+
function rowToCompany2(row) {
|
|
516
|
+
return {
|
|
517
|
+
...row,
|
|
518
|
+
custom_fields: JSON.parse(row.custom_fields || "{}")
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
function insertEmails2(db, companyId, emails) {
|
|
522
|
+
for (const e of emails) {
|
|
523
|
+
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]);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function insertPhones2(db, companyId, phones) {
|
|
527
|
+
for (const p of phones) {
|
|
528
|
+
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]);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
function insertAddresses2(db, companyId, addresses) {
|
|
532
|
+
for (const a of addresses) {
|
|
533
|
+
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]);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
function insertSocialProfiles2(db, companyId, profiles) {
|
|
537
|
+
for (const s of profiles) {
|
|
538
|
+
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]);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function loadCompanyDetails(db, company) {
|
|
542
|
+
const emails = db.query(`SELECT * FROM emails WHERE company_id = ?`).all(company.id).map((row) => ({
|
|
543
|
+
...row,
|
|
544
|
+
type: row.type,
|
|
545
|
+
is_primary: !!row.is_primary
|
|
546
|
+
}));
|
|
547
|
+
const phones = db.query(`SELECT * FROM phones WHERE company_id = ?`).all(company.id).map((row) => ({
|
|
548
|
+
...row,
|
|
549
|
+
type: row.type,
|
|
550
|
+
is_primary: !!row.is_primary
|
|
551
|
+
}));
|
|
552
|
+
const addresses = db.query(`SELECT * FROM addresses WHERE company_id = ?`).all(company.id).map((row) => ({
|
|
553
|
+
...row,
|
|
554
|
+
type: row.type,
|
|
555
|
+
is_primary: !!row.is_primary
|
|
556
|
+
}));
|
|
557
|
+
const social_profiles = db.query(`SELECT * FROM social_profiles WHERE company_id = ?`).all(company.id).map((row) => ({
|
|
558
|
+
...row,
|
|
559
|
+
platform: row.platform,
|
|
560
|
+
is_primary: !!row.is_primary
|
|
561
|
+
}));
|
|
562
|
+
const tags = db.query(`
|
|
563
|
+
SELECT t.* FROM tags t
|
|
564
|
+
JOIN company_tags ct ON ct.tag_id = t.id
|
|
565
|
+
WHERE ct.company_id = ?
|
|
566
|
+
`).all(company.id);
|
|
567
|
+
const empCount = db.query(`SELECT COUNT(*) as count FROM contacts WHERE company_id = ?`).get(company.id);
|
|
568
|
+
return {
|
|
569
|
+
...company,
|
|
570
|
+
emails,
|
|
571
|
+
phones,
|
|
572
|
+
addresses,
|
|
573
|
+
social_profiles,
|
|
574
|
+
tags,
|
|
575
|
+
employee_count: empCount.count
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
function createCompany(input, db) {
|
|
579
|
+
const d = db || getDatabase();
|
|
580
|
+
const id = uuid();
|
|
581
|
+
const timestamp = now();
|
|
582
|
+
d.run(`INSERT INTO companies (id, name, domain, logo_url, description, industry, size, founded_year, notes, custom_fields, created_at, updated_at)
|
|
583
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
584
|
+
id,
|
|
585
|
+
input.name,
|
|
586
|
+
input.domain ?? null,
|
|
587
|
+
input.logo_url ?? null,
|
|
588
|
+
input.description ?? null,
|
|
589
|
+
input.industry ?? null,
|
|
590
|
+
input.size ?? null,
|
|
591
|
+
input.founded_year ?? null,
|
|
592
|
+
input.notes ?? null,
|
|
593
|
+
JSON.stringify(input.custom_fields ?? {}),
|
|
594
|
+
timestamp,
|
|
595
|
+
timestamp
|
|
596
|
+
]);
|
|
597
|
+
if (input.emails?.length)
|
|
598
|
+
insertEmails2(d, id, input.emails);
|
|
599
|
+
if (input.phones?.length)
|
|
600
|
+
insertPhones2(d, id, input.phones);
|
|
601
|
+
if (input.addresses?.length)
|
|
602
|
+
insertAddresses2(d, id, input.addresses);
|
|
603
|
+
if (input.social_profiles?.length)
|
|
604
|
+
insertSocialProfiles2(d, id, input.social_profiles);
|
|
605
|
+
if (input.tag_ids?.length) {
|
|
606
|
+
for (const tagId of input.tag_ids) {
|
|
607
|
+
d.run(`INSERT OR IGNORE INTO company_tags (company_id, tag_id) VALUES (?, ?)`, [id, tagId]);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
logActivity(d, { company_id: id, action: "company.created", details: `Created company: ${input.name}` });
|
|
611
|
+
const row = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
|
|
612
|
+
return loadCompanyDetails(d, rowToCompany2(row));
|
|
613
|
+
}
|
|
614
|
+
function getCompany(id, db) {
|
|
615
|
+
const d = db || getDatabase();
|
|
616
|
+
const row = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
|
|
617
|
+
if (!row)
|
|
618
|
+
throw new CompanyNotFoundError(id);
|
|
619
|
+
return loadCompanyDetails(d, rowToCompany2(row));
|
|
620
|
+
}
|
|
621
|
+
function listCompanies(opts = {}, db) {
|
|
622
|
+
const d = db || getDatabase();
|
|
623
|
+
const {
|
|
624
|
+
limit = 50,
|
|
625
|
+
offset = 0,
|
|
626
|
+
industry,
|
|
627
|
+
tag_id,
|
|
628
|
+
order_by = "name",
|
|
629
|
+
order_dir = "asc"
|
|
630
|
+
} = opts;
|
|
631
|
+
const conditions = [];
|
|
632
|
+
const params = [];
|
|
633
|
+
if (industry) {
|
|
634
|
+
conditions.push("co.industry = ?");
|
|
635
|
+
params.push(industry);
|
|
636
|
+
}
|
|
637
|
+
if (tag_id) {
|
|
638
|
+
conditions.push("EXISTS (SELECT 1 FROM company_tags ct WHERE ct.company_id = co.id AND ct.tag_id = ?)");
|
|
639
|
+
params.push(tag_id);
|
|
640
|
+
}
|
|
641
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
642
|
+
const validOrderBy = ["name", "created_at", "updated_at"].includes(order_by) ? order_by : "name";
|
|
643
|
+
const validOrderDir = order_dir === "desc" ? "DESC" : "ASC";
|
|
644
|
+
const totalRow = d.query(`SELECT COUNT(*) as total FROM companies co ${where}`).get(...params);
|
|
645
|
+
const rows = d.query(`SELECT co.* FROM companies co ${where} ORDER BY co.${validOrderBy} ${validOrderDir} LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
|
646
|
+
const companies = rows.map((row) => loadCompanyDetails(d, rowToCompany2(row)));
|
|
647
|
+
return { companies, total: totalRow.total };
|
|
648
|
+
}
|
|
649
|
+
function updateCompany(id, input, db) {
|
|
650
|
+
const d = db || getDatabase();
|
|
651
|
+
const existing = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
|
|
652
|
+
if (!existing)
|
|
653
|
+
throw new CompanyNotFoundError(id);
|
|
654
|
+
const setClauses = ["updated_at = ?"];
|
|
655
|
+
const params = [now()];
|
|
656
|
+
if (input.name !== undefined) {
|
|
657
|
+
setClauses.push("name = ?");
|
|
658
|
+
params.push(input.name);
|
|
659
|
+
}
|
|
660
|
+
if (input.domain !== undefined) {
|
|
661
|
+
setClauses.push("domain = ?");
|
|
662
|
+
params.push(input.domain);
|
|
663
|
+
}
|
|
664
|
+
if (input.logo_url !== undefined) {
|
|
665
|
+
setClauses.push("logo_url = ?");
|
|
666
|
+
params.push(input.logo_url);
|
|
667
|
+
}
|
|
668
|
+
if (input.description !== undefined) {
|
|
669
|
+
setClauses.push("description = ?");
|
|
670
|
+
params.push(input.description);
|
|
671
|
+
}
|
|
672
|
+
if (input.industry !== undefined) {
|
|
673
|
+
setClauses.push("industry = ?");
|
|
674
|
+
params.push(input.industry);
|
|
675
|
+
}
|
|
676
|
+
if (input.size !== undefined) {
|
|
677
|
+
setClauses.push("size = ?");
|
|
678
|
+
params.push(input.size);
|
|
679
|
+
}
|
|
680
|
+
if (input.founded_year !== undefined) {
|
|
681
|
+
setClauses.push("founded_year = ?");
|
|
682
|
+
params.push(input.founded_year);
|
|
683
|
+
}
|
|
684
|
+
if (input.notes !== undefined) {
|
|
685
|
+
setClauses.push("notes = ?");
|
|
686
|
+
params.push(input.notes);
|
|
687
|
+
}
|
|
688
|
+
if (input.custom_fields !== undefined) {
|
|
689
|
+
setClauses.push("custom_fields = ?");
|
|
690
|
+
params.push(JSON.stringify(input.custom_fields));
|
|
691
|
+
}
|
|
692
|
+
params.push(id);
|
|
693
|
+
d.run(`UPDATE companies SET ${setClauses.join(", ")} WHERE id = ?`, params);
|
|
694
|
+
logActivity(d, { company_id: id, action: "company.updated", details: `Updated company: ${existing.name}` });
|
|
695
|
+
const row = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
|
|
696
|
+
return loadCompanyDetails(d, rowToCompany2(row));
|
|
697
|
+
}
|
|
698
|
+
function deleteCompany(id, db) {
|
|
699
|
+
const d = db || getDatabase();
|
|
700
|
+
const row = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
|
|
701
|
+
if (!row)
|
|
702
|
+
throw new CompanyNotFoundError(id);
|
|
703
|
+
logActivity(d, { company_id: id, action: "company.deleted", details: `Deleted company: ${row.name}` });
|
|
704
|
+
d.run(`DELETE FROM companies WHERE id = ?`, [id]);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// src/db/tags.ts
|
|
708
|
+
function rowToTag2(row) {
|
|
709
|
+
return { ...row };
|
|
710
|
+
}
|
|
711
|
+
function createTag(input, db) {
|
|
712
|
+
const d = db || getDatabase();
|
|
713
|
+
const existing = d.query(`SELECT id FROM tags WHERE name = ?`).get(input.name);
|
|
714
|
+
if (existing)
|
|
715
|
+
throw new DuplicateTagNameError(input.name);
|
|
716
|
+
const id = uuid();
|
|
717
|
+
d.run(`INSERT INTO tags (id, name, color, description) VALUES (?, ?, ?, ?)`, [id, input.name, input.color ?? "#6366f1", input.description ?? null]);
|
|
718
|
+
return rowToTag2(d.query(`SELECT * FROM tags WHERE id = ?`).get(id));
|
|
719
|
+
}
|
|
720
|
+
function listTags(db) {
|
|
721
|
+
const d = db || getDatabase();
|
|
722
|
+
return d.query(`SELECT * FROM tags ORDER BY name ASC`).all().map(rowToTag2);
|
|
723
|
+
}
|
|
724
|
+
function deleteTag(id, db) {
|
|
725
|
+
const d = db || getDatabase();
|
|
726
|
+
const row = d.query(`SELECT id FROM tags WHERE id = ?`).get(id);
|
|
727
|
+
if (!row)
|
|
728
|
+
throw new TagNotFoundError(id);
|
|
729
|
+
d.run(`DELETE FROM tags WHERE id = ?`, [id]);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// src/lib/import.ts
|
|
733
|
+
function parseCsv(text) {
|
|
734
|
+
const lines = text.split(/\r?\n/);
|
|
735
|
+
if (lines.length < 2)
|
|
736
|
+
return [];
|
|
737
|
+
const headers = parseCsvLine(lines[0]);
|
|
738
|
+
const rows = [];
|
|
739
|
+
for (let i = 1;i < lines.length; i++) {
|
|
740
|
+
const line = lines[i].trim();
|
|
741
|
+
if (!line)
|
|
742
|
+
continue;
|
|
743
|
+
const values = parseCsvLine(line);
|
|
744
|
+
const row = {};
|
|
745
|
+
headers.forEach((h, idx) => {
|
|
746
|
+
row[h.trim()] = values[idx]?.trim() ?? "";
|
|
747
|
+
});
|
|
748
|
+
rows.push(row);
|
|
749
|
+
}
|
|
750
|
+
return rows;
|
|
751
|
+
}
|
|
752
|
+
function parseCsvLine(line) {
|
|
753
|
+
const fields = [];
|
|
754
|
+
let current = "";
|
|
755
|
+
let inQuotes = false;
|
|
756
|
+
for (let i = 0;i < line.length; i++) {
|
|
757
|
+
const ch = line[i];
|
|
758
|
+
if (ch === '"') {
|
|
759
|
+
if (inQuotes && line[i + 1] === '"') {
|
|
760
|
+
current += '"';
|
|
761
|
+
i++;
|
|
762
|
+
} else {
|
|
763
|
+
inQuotes = !inQuotes;
|
|
764
|
+
}
|
|
765
|
+
} else if (ch === "," && !inQuotes) {
|
|
766
|
+
fields.push(current);
|
|
767
|
+
current = "";
|
|
768
|
+
} else {
|
|
769
|
+
current += ch;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
fields.push(current);
|
|
773
|
+
return fields;
|
|
774
|
+
}
|
|
775
|
+
function csvRowToContact(row) {
|
|
776
|
+
const firstName = row["First Name"] ?? row["first_name"] ?? row["Given Name"] ?? "";
|
|
777
|
+
const lastName = row["Last Name"] ?? row["last_name"] ?? row["Family Name"] ?? "";
|
|
778
|
+
const displayName = row["Name"] ?? row["display_name"] ?? row["Full Name"] ?? [firstName, lastName].filter(Boolean).join(" ") ?? "";
|
|
779
|
+
if (!displayName && !firstName && !lastName)
|
|
780
|
+
return null;
|
|
781
|
+
const contact = {
|
|
782
|
+
display_name: displayName || [firstName, lastName].filter(Boolean).join(" ") || "Unnamed",
|
|
783
|
+
first_name: firstName || undefined,
|
|
784
|
+
last_name: lastName || undefined,
|
|
785
|
+
job_title: row["Job Title"] ?? row["job_title"] ?? row["Title"] ?? undefined,
|
|
786
|
+
notes: row["Notes"] ?? row["notes"] ?? undefined,
|
|
787
|
+
birthday: row["Birthday"] ?? row["birthday"] ?? undefined,
|
|
788
|
+
source: "import"
|
|
789
|
+
};
|
|
790
|
+
const emails = [];
|
|
791
|
+
for (let i = 1;i <= 5; i++) {
|
|
792
|
+
const val = row[`Email ${i} - Value`] ?? row[`Email Address ${i}`] ?? (i === 1 ? row["Email"] ?? row["email"] ?? row["Email Address"] : undefined);
|
|
793
|
+
const rawType = row[`Email ${i} - Type`] ?? (i === 1 ? "work" : "other");
|
|
794
|
+
if (val) {
|
|
795
|
+
const type = rawType?.toLowerCase() === "personal" ? "personal" : rawType?.toLowerCase() === "other" ? "other" : "work";
|
|
796
|
+
emails.push({ address: val, type, is_primary: i === 1 });
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (emails.length)
|
|
800
|
+
contact.emails = emails;
|
|
801
|
+
const phones = [];
|
|
802
|
+
for (let i = 1;i <= 5; i++) {
|
|
803
|
+
const val = row[`Phone ${i} - Value`] ?? row[`Phone ${i}`] ?? (i === 1 ? row["Phone"] ?? row["phone"] ?? row["Mobile"] : undefined);
|
|
804
|
+
const rawType = row[`Phone ${i} - Type`] ?? (i === 1 ? "mobile" : "other");
|
|
805
|
+
if (val) {
|
|
806
|
+
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";
|
|
807
|
+
phones.push({ number: val, type, is_primary: i === 1 });
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
if (phones.length)
|
|
811
|
+
contact.phones = phones;
|
|
812
|
+
return contact;
|
|
813
|
+
}
|
|
814
|
+
function importFromCsv(data) {
|
|
815
|
+
const rows = parseCsv(data);
|
|
816
|
+
return rows.map(csvRowToContact).filter(Boolean);
|
|
817
|
+
}
|
|
818
|
+
function parseVcf(data) {
|
|
819
|
+
const contacts = [];
|
|
820
|
+
const blocks = data.split(/BEGIN:VCARD/i).filter((b) => b.trim());
|
|
821
|
+
for (const block of blocks) {
|
|
822
|
+
try {
|
|
823
|
+
const contact = parseVcfBlock(`BEGIN:VCARD
|
|
824
|
+
` + block);
|
|
825
|
+
if (contact)
|
|
826
|
+
contacts.push(contact);
|
|
827
|
+
} catch {}
|
|
828
|
+
}
|
|
829
|
+
return contacts;
|
|
830
|
+
}
|
|
831
|
+
function parseVcfBlock(block) {
|
|
832
|
+
const unfolded = block.replace(/\r?\n[ \t]/g, "");
|
|
833
|
+
const lines = unfolded.split(/\r?\n/).filter((l) => l.trim());
|
|
834
|
+
const contact = { source: "import" };
|
|
835
|
+
const emails = [];
|
|
836
|
+
const phones = [];
|
|
837
|
+
const addresses = [];
|
|
838
|
+
const socials = [];
|
|
839
|
+
for (const line of lines) {
|
|
840
|
+
if (/^BEGIN:VCARD$/i.test(line) || /^END:VCARD$/i.test(line) || /^VERSION:/i.test(line))
|
|
841
|
+
continue;
|
|
842
|
+
const colonIdx = line.indexOf(":");
|
|
843
|
+
if (colonIdx === -1)
|
|
844
|
+
continue;
|
|
845
|
+
const propPart = line.slice(0, colonIdx);
|
|
846
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
847
|
+
const semicolonIdx = propPart.indexOf(";");
|
|
848
|
+
const propName = (semicolonIdx === -1 ? propPart : propPart.slice(0, semicolonIdx)).toUpperCase();
|
|
849
|
+
const params = semicolonIdx !== -1 ? propPart.slice(semicolonIdx + 1) : "";
|
|
850
|
+
switch (propName) {
|
|
851
|
+
case "FN":
|
|
852
|
+
contact.display_name = decodeVcfValue(value);
|
|
853
|
+
break;
|
|
854
|
+
case "N": {
|
|
855
|
+
const parts = value.split(";");
|
|
856
|
+
contact.last_name = decodeVcfValue(parts[0] ?? "") || undefined;
|
|
857
|
+
contact.first_name = decodeVcfValue(parts[1] ?? "") || undefined;
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
case "NICKNAME":
|
|
861
|
+
contact.nickname = decodeVcfValue(value) || undefined;
|
|
862
|
+
break;
|
|
863
|
+
case "TITLE":
|
|
864
|
+
contact.job_title = decodeVcfValue(value) || undefined;
|
|
865
|
+
break;
|
|
866
|
+
case "NOTE":
|
|
867
|
+
contact.notes = decodeVcfValue(value) || undefined;
|
|
868
|
+
break;
|
|
869
|
+
case "BDAY":
|
|
870
|
+
contact.birthday = value.replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3");
|
|
871
|
+
break;
|
|
872
|
+
case "EMAIL": {
|
|
873
|
+
const typeMatch = params.match(/TYPE=([^;]+)/i);
|
|
874
|
+
const rawLabel = typeMatch ? typeMatch[1].toLowerCase() : "work";
|
|
875
|
+
const type = rawLabel.includes("personal") ? "personal" : rawLabel.includes("other") ? "other" : "work";
|
|
876
|
+
const isPrimary = params.includes("PREF") || emails.length === 0;
|
|
877
|
+
emails.push({ address: decodeVcfValue(value), type, is_primary: isPrimary });
|
|
878
|
+
break;
|
|
879
|
+
}
|
|
880
|
+
case "TEL": {
|
|
881
|
+
const typeMatch = params.match(/TYPE=([^;]+)/i);
|
|
882
|
+
const rawLabel = typeMatch ? typeMatch[1].toLowerCase() : "mobile";
|
|
883
|
+
const type = rawLabel.includes("cell") || rawLabel.includes("mobile") ? "mobile" : rawLabel.includes("work") ? "work" : rawLabel.includes("home") ? "home" : rawLabel.includes("fax") ? "fax" : "other";
|
|
884
|
+
const isPrimary = params.includes("PREF") || phones.length === 0;
|
|
885
|
+
phones.push({ number: decodeVcfValue(value), type, is_primary: isPrimary });
|
|
886
|
+
break;
|
|
887
|
+
}
|
|
888
|
+
case "ADR": {
|
|
889
|
+
const parts = value.split(";");
|
|
890
|
+
const typeMatch = params.match(/TYPE=([^;]+)/i);
|
|
891
|
+
const rawLabel = typeMatch ? typeMatch[1].toLowerCase().split(",")[0] : "physical";
|
|
892
|
+
const type = rawLabel.includes("home") || rawLabel.includes("physical") ? "physical" : rawLabel.includes("mail") ? "mailing" : rawLabel.includes("bill") ? "billing" : "other";
|
|
893
|
+
addresses.push({
|
|
894
|
+
type,
|
|
895
|
+
street: decodeVcfValue(parts[2] ?? "") || undefined,
|
|
896
|
+
city: decodeVcfValue(parts[3] ?? "") || undefined,
|
|
897
|
+
state: decodeVcfValue(parts[4] ?? "") || undefined,
|
|
898
|
+
zip: decodeVcfValue(parts[5] ?? "") || undefined,
|
|
899
|
+
country: decodeVcfValue(parts[6] ?? "") || undefined,
|
|
900
|
+
is_primary: addresses.length === 0
|
|
901
|
+
});
|
|
902
|
+
break;
|
|
903
|
+
}
|
|
904
|
+
case "URL": {
|
|
905
|
+
const url = decodeVcfValue(value);
|
|
906
|
+
const platform = detectPlatform(url);
|
|
907
|
+
socials.push({ platform, url, handle: url });
|
|
908
|
+
break;
|
|
909
|
+
}
|
|
910
|
+
case "X-SOCIALPROFILE": {
|
|
911
|
+
const typeMatch = params.match(/TYPE=([^;]+)/i);
|
|
912
|
+
const platform = normalizePlatform(typeMatch?.[1] ?? "other");
|
|
913
|
+
socials.push({ platform, handle: decodeVcfValue(value), url: value });
|
|
914
|
+
break;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
if (!contact.display_name) {
|
|
919
|
+
if (contact.first_name || contact.last_name) {
|
|
920
|
+
contact.display_name = [contact.first_name, contact.last_name].filter(Boolean).join(" ");
|
|
921
|
+
} else {
|
|
922
|
+
return null;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
if (emails.length)
|
|
926
|
+
contact.emails = emails;
|
|
927
|
+
if (phones.length)
|
|
928
|
+
contact.phones = phones;
|
|
929
|
+
if (addresses.length)
|
|
930
|
+
contact.addresses = addresses;
|
|
931
|
+
if (socials.length)
|
|
932
|
+
contact.social_profiles = socials;
|
|
933
|
+
return contact;
|
|
934
|
+
}
|
|
935
|
+
function decodeVcfValue(val) {
|
|
936
|
+
return val.replace(/\\n/g, `
|
|
937
|
+
`).replace(/\\,/g, ",").replace(/\\;/g, ";").replace(/\\\\/g, "\\");
|
|
938
|
+
}
|
|
939
|
+
function detectPlatform(url) {
|
|
940
|
+
const lower = url.toLowerCase();
|
|
941
|
+
if (lower.includes("twitter.com") || lower.includes("x.com"))
|
|
942
|
+
return "twitter";
|
|
943
|
+
if (lower.includes("linkedin.com"))
|
|
944
|
+
return "linkedin";
|
|
945
|
+
if (lower.includes("github.com"))
|
|
946
|
+
return "github";
|
|
947
|
+
if (lower.includes("instagram.com"))
|
|
948
|
+
return "instagram";
|
|
949
|
+
if (lower.includes("facebook.com"))
|
|
950
|
+
return "facebook";
|
|
951
|
+
if (lower.includes("youtube.com"))
|
|
952
|
+
return "youtube";
|
|
953
|
+
if (lower.includes("telegram"))
|
|
954
|
+
return "telegram";
|
|
955
|
+
if (lower.includes("discord"))
|
|
956
|
+
return "discord";
|
|
957
|
+
if (lower.includes("tiktok"))
|
|
958
|
+
return "tiktok";
|
|
959
|
+
if (lower.includes("bluesky") || lower.includes("bsky"))
|
|
960
|
+
return "bluesky";
|
|
961
|
+
return "other";
|
|
962
|
+
}
|
|
963
|
+
function normalizePlatform(raw) {
|
|
964
|
+
const lower = raw.toLowerCase();
|
|
965
|
+
const platforms = [
|
|
966
|
+
"twitter",
|
|
967
|
+
"linkedin",
|
|
968
|
+
"github",
|
|
969
|
+
"instagram",
|
|
970
|
+
"telegram",
|
|
971
|
+
"discord",
|
|
972
|
+
"youtube",
|
|
973
|
+
"tiktok",
|
|
974
|
+
"bluesky",
|
|
975
|
+
"facebook",
|
|
976
|
+
"whatsapp",
|
|
977
|
+
"snapchat",
|
|
978
|
+
"reddit"
|
|
979
|
+
];
|
|
980
|
+
for (const p of platforms) {
|
|
981
|
+
if (lower.includes(p))
|
|
982
|
+
return p;
|
|
983
|
+
}
|
|
984
|
+
return "other";
|
|
985
|
+
}
|
|
986
|
+
function importFromJson(data) {
|
|
987
|
+
let parsed;
|
|
988
|
+
try {
|
|
989
|
+
parsed = JSON.parse(data);
|
|
990
|
+
} catch {
|
|
991
|
+
throw new Error("Invalid JSON");
|
|
992
|
+
}
|
|
993
|
+
if (!Array.isArray(parsed)) {
|
|
994
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
995
|
+
parsed = [parsed];
|
|
996
|
+
} else {
|
|
997
|
+
throw new Error("JSON must be an array of contacts");
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return parsed.map((obj) => {
|
|
1001
|
+
const displayName = obj.display_name ?? obj.name ?? [obj.first_name ?? "", obj.last_name ?? ""].filter(Boolean).join(" ") ?? "Unnamed";
|
|
1002
|
+
return {
|
|
1003
|
+
...obj,
|
|
1004
|
+
display_name: displayName,
|
|
1005
|
+
source: "import"
|
|
1006
|
+
};
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
async function importContacts(format, data) {
|
|
1010
|
+
switch (format) {
|
|
1011
|
+
case "csv":
|
|
1012
|
+
return importFromCsv(data);
|
|
1013
|
+
case "vcf":
|
|
1014
|
+
return parseVcf(data);
|
|
1015
|
+
case "json":
|
|
1016
|
+
return importFromJson(data);
|
|
1017
|
+
default:
|
|
1018
|
+
throw new Error(`Unsupported import format: ${format}`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// src/lib/export.ts
|
|
1023
|
+
function toJson(contacts) {
|
|
1024
|
+
return JSON.stringify(contacts, null, 2);
|
|
1025
|
+
}
|
|
1026
|
+
function escapeCsvField(val) {
|
|
1027
|
+
if (val == null)
|
|
1028
|
+
return "";
|
|
1029
|
+
const str = String(val);
|
|
1030
|
+
if (str.includes(",") || str.includes('"') || str.includes(`
|
|
1031
|
+
`)) {
|
|
1032
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
1033
|
+
}
|
|
1034
|
+
return str;
|
|
1035
|
+
}
|
|
1036
|
+
function toCsv(contacts) {
|
|
1037
|
+
const headers = [
|
|
1038
|
+
"First Name",
|
|
1039
|
+
"Last Name",
|
|
1040
|
+
"Name",
|
|
1041
|
+
"Nickname",
|
|
1042
|
+
"Job Title",
|
|
1043
|
+
"Company",
|
|
1044
|
+
"Email 1 - Value",
|
|
1045
|
+
"Email 1 - Type",
|
|
1046
|
+
"Email 2 - Value",
|
|
1047
|
+
"Email 2 - Type",
|
|
1048
|
+
"Phone 1 - Value",
|
|
1049
|
+
"Phone 1 - Type",
|
|
1050
|
+
"Phone 2 - Value",
|
|
1051
|
+
"Phone 2 - Type",
|
|
1052
|
+
"Address 1 - Street",
|
|
1053
|
+
"Address 1 - City",
|
|
1054
|
+
"Address 1 - State",
|
|
1055
|
+
"Address 1 - Postal Code",
|
|
1056
|
+
"Address 1 - Country",
|
|
1057
|
+
"Address 1 - Type",
|
|
1058
|
+
"Birthday",
|
|
1059
|
+
"Notes",
|
|
1060
|
+
"Tags"
|
|
1061
|
+
];
|
|
1062
|
+
const rows = [headers.map(escapeCsvField).join(",")];
|
|
1063
|
+
for (const c of contacts) {
|
|
1064
|
+
const emails = c.emails ?? [];
|
|
1065
|
+
const phones = c.phones ?? [];
|
|
1066
|
+
const addrs = c.addresses ?? [];
|
|
1067
|
+
const tags = (c.tags ?? []).map((t) => t.name).join(";");
|
|
1068
|
+
const row = [
|
|
1069
|
+
c.first_name,
|
|
1070
|
+
c.last_name,
|
|
1071
|
+
c.display_name,
|
|
1072
|
+
c.nickname,
|
|
1073
|
+
c.job_title,
|
|
1074
|
+
c.company?.name,
|
|
1075
|
+
emails[0]?.address,
|
|
1076
|
+
emails[0]?.type,
|
|
1077
|
+
emails[1]?.address,
|
|
1078
|
+
emails[1]?.type,
|
|
1079
|
+
phones[0]?.number,
|
|
1080
|
+
phones[0]?.type,
|
|
1081
|
+
phones[1]?.number,
|
|
1082
|
+
phones[1]?.type,
|
|
1083
|
+
addrs[0]?.street,
|
|
1084
|
+
addrs[0]?.city,
|
|
1085
|
+
addrs[0]?.state,
|
|
1086
|
+
addrs[0]?.zip,
|
|
1087
|
+
addrs[0]?.country,
|
|
1088
|
+
addrs[0]?.type,
|
|
1089
|
+
c.birthday,
|
|
1090
|
+
c.notes,
|
|
1091
|
+
tags
|
|
1092
|
+
];
|
|
1093
|
+
rows.push(row.map(escapeCsvField).join(","));
|
|
1094
|
+
}
|
|
1095
|
+
return rows.join(`
|
|
1096
|
+
`);
|
|
1097
|
+
}
|
|
1098
|
+
function escapeVcfValue(val) {
|
|
1099
|
+
if (!val)
|
|
1100
|
+
return "";
|
|
1101
|
+
return val.replace(/\\/g, "\\\\").replace(/,/g, "\\,").replace(/;/g, "\\;").replace(/\n/g, "\\n");
|
|
1102
|
+
}
|
|
1103
|
+
function foldVcfLine(line) {
|
|
1104
|
+
if (line.length <= 75)
|
|
1105
|
+
return line;
|
|
1106
|
+
const parts = [line.slice(0, 75)];
|
|
1107
|
+
let i = 75;
|
|
1108
|
+
while (i < line.length) {
|
|
1109
|
+
parts.push(" " + line.slice(i, i + 74));
|
|
1110
|
+
i += 74;
|
|
1111
|
+
}
|
|
1112
|
+
return parts.join(`\r
|
|
1113
|
+
`);
|
|
1114
|
+
}
|
|
1115
|
+
function toVcf(contacts) {
|
|
1116
|
+
const cards = [];
|
|
1117
|
+
for (const c of contacts) {
|
|
1118
|
+
const lines = ["BEGIN:VCARD", "VERSION:3.0"];
|
|
1119
|
+
lines.push(`FN:${escapeVcfValue(c.display_name)}`);
|
|
1120
|
+
lines.push(`N:${escapeVcfValue(c.last_name)};${escapeVcfValue(c.first_name)};;;`);
|
|
1121
|
+
if (c.nickname)
|
|
1122
|
+
lines.push(`NICKNAME:${escapeVcfValue(c.nickname)}`);
|
|
1123
|
+
if (c.job_title)
|
|
1124
|
+
lines.push(`TITLE:${escapeVcfValue(c.job_title)}`);
|
|
1125
|
+
if (c.company?.name)
|
|
1126
|
+
lines.push(`ORG:${escapeVcfValue(c.company.name)}`);
|
|
1127
|
+
if (c.birthday)
|
|
1128
|
+
lines.push(`BDAY:${c.birthday.replace(/-/g, "")}`);
|
|
1129
|
+
for (let i = 0;i < (c.emails ?? []).length; i++) {
|
|
1130
|
+
const e = c.emails[i];
|
|
1131
|
+
const pref = i === 0 || e.is_primary ? ";PREF" : "";
|
|
1132
|
+
lines.push(`EMAIL;TYPE=${e.type.toUpperCase()}${pref}:${escapeVcfValue(e.address)}`);
|
|
1133
|
+
}
|
|
1134
|
+
for (let i = 0;i < (c.phones ?? []).length; i++) {
|
|
1135
|
+
const p = c.phones[i];
|
|
1136
|
+
const pref = i === 0 || p.is_primary ? ";PREF" : "";
|
|
1137
|
+
const vcfType = p.type === "mobile" ? "CELL" : p.type.toUpperCase();
|
|
1138
|
+
lines.push(`TEL;TYPE=${vcfType}${pref}:${escapeVcfValue(p.number)}`);
|
|
1139
|
+
}
|
|
1140
|
+
for (let i = 0;i < (c.addresses ?? []).length; i++) {
|
|
1141
|
+
const a = c.addresses[i];
|
|
1142
|
+
const pref = i === 0 || a.is_primary ? ";PREF" : "";
|
|
1143
|
+
lines.push(`ADR;TYPE=${a.type.toUpperCase()}${pref}:;;${escapeVcfValue(a.street)};${escapeVcfValue(a.city)};${escapeVcfValue(a.state)};${escapeVcfValue(a.zip)};${escapeVcfValue(a.country)}`);
|
|
1144
|
+
}
|
|
1145
|
+
for (const sp of c.social_profiles ?? []) {
|
|
1146
|
+
if (sp.url)
|
|
1147
|
+
lines.push(`URL;TYPE=${sp.platform.toUpperCase()}:${escapeVcfValue(sp.url)}`);
|
|
1148
|
+
if (sp.handle)
|
|
1149
|
+
lines.push(`X-SOCIALPROFILE;TYPE=${sp.platform.toLowerCase()}:${escapeVcfValue(sp.handle)}`);
|
|
1150
|
+
}
|
|
1151
|
+
if (c.notes)
|
|
1152
|
+
lines.push(`NOTE:${escapeVcfValue(c.notes)}`);
|
|
1153
|
+
if (c.tags && c.tags.length > 0) {
|
|
1154
|
+
lines.push(`CATEGORIES:${c.tags.map((t) => escapeVcfValue(t.name)).join(",")}`);
|
|
1155
|
+
}
|
|
1156
|
+
lines.push(`UID:${c.id}`);
|
|
1157
|
+
lines.push("END:VCARD");
|
|
1158
|
+
cards.push(lines.map(foldVcfLine).join(`\r
|
|
1159
|
+
`));
|
|
1160
|
+
}
|
|
1161
|
+
return cards.join(`\r
|
|
1162
|
+
`);
|
|
1163
|
+
}
|
|
1164
|
+
async function exportContacts(format, contacts) {
|
|
1165
|
+
switch (format) {
|
|
1166
|
+
case "json":
|
|
1167
|
+
return toJson(contacts);
|
|
1168
|
+
case "csv":
|
|
1169
|
+
return toCsv(contacts);
|
|
1170
|
+
case "vcf":
|
|
1171
|
+
return toVcf(contacts);
|
|
1172
|
+
default:
|
|
1173
|
+
throw new Error(`Unsupported export format: ${format}`);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// src/server/serve.ts
|
|
1178
|
+
var DASHBOARD_DIST = join2(import.meta.dir, "../../dashboard/dist");
|
|
1179
|
+
function json(data, status = 200) {
|
|
1180
|
+
return new Response(JSON.stringify(data), {
|
|
1181
|
+
status,
|
|
1182
|
+
headers: { "Content-Type": "application/json" }
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
function apiError(message, status = 400) {
|
|
1186
|
+
return json({ error: message }, status);
|
|
1187
|
+
}
|
|
1188
|
+
async function parseJson(req) {
|
|
1189
|
+
try {
|
|
1190
|
+
return await req.json();
|
|
1191
|
+
} catch {
|
|
1192
|
+
return null;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
function getSegments(url) {
|
|
1196
|
+
return url.pathname.split("/").filter(Boolean);
|
|
1197
|
+
}
|
|
1198
|
+
async function handleContacts(req, url, segments) {
|
|
1199
|
+
const method = req.method;
|
|
1200
|
+
const id = segments[2];
|
|
1201
|
+
if (method === "GET" && !id) {
|
|
1202
|
+
const q = url.searchParams.get("q");
|
|
1203
|
+
if (q) {
|
|
1204
|
+
const contacts = searchContacts(q);
|
|
1205
|
+
return json(contacts);
|
|
1206
|
+
}
|
|
1207
|
+
const result = listContacts({
|
|
1208
|
+
tag_id: url.searchParams.get("tag_id") ?? url.searchParams.get("tag") ?? undefined,
|
|
1209
|
+
company_id: url.searchParams.get("company_id") ?? undefined,
|
|
1210
|
+
limit: parseInt(url.searchParams.get("limit") ?? "50", 10),
|
|
1211
|
+
offset: parseInt(url.searchParams.get("offset") ?? "0", 10)
|
|
1212
|
+
});
|
|
1213
|
+
return json(result);
|
|
1214
|
+
}
|
|
1215
|
+
if (method === "POST" && !id) {
|
|
1216
|
+
const body = await parseJson(req);
|
|
1217
|
+
if (!body || typeof body !== "object")
|
|
1218
|
+
return apiError("Invalid body");
|
|
1219
|
+
try {
|
|
1220
|
+
const contact = createContact(body);
|
|
1221
|
+
return json(contact, 201);
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
return apiError(err instanceof Error ? err.message : "Failed to create contact");
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
if (method === "GET" && id) {
|
|
1227
|
+
try {
|
|
1228
|
+
const contact = getContact(id);
|
|
1229
|
+
return json(contact);
|
|
1230
|
+
} catch {
|
|
1231
|
+
return apiError("Contact not found", 404);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
if (method === "PATCH" && id) {
|
|
1235
|
+
const body = await parseJson(req);
|
|
1236
|
+
if (!body || typeof body !== "object")
|
|
1237
|
+
return apiError("Invalid body");
|
|
1238
|
+
try {
|
|
1239
|
+
const contact = updateContact(id, body);
|
|
1240
|
+
return json(contact);
|
|
1241
|
+
} catch {
|
|
1242
|
+
return apiError("Contact not found", 404);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
if (method === "DELETE" && id) {
|
|
1246
|
+
try {
|
|
1247
|
+
deleteContact(id);
|
|
1248
|
+
return json({ ok: true });
|
|
1249
|
+
} catch {
|
|
1250
|
+
return apiError("Contact not found", 404);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
return apiError("Method not allowed", 405);
|
|
1254
|
+
}
|
|
1255
|
+
async function handleCompanies(req, url, segments) {
|
|
1256
|
+
const method = req.method;
|
|
1257
|
+
const id = segments[2];
|
|
1258
|
+
if (method === "GET" && !id) {
|
|
1259
|
+
const result = listCompanies({
|
|
1260
|
+
tag_id: url.searchParams.get("tag_id") ?? undefined,
|
|
1261
|
+
industry: url.searchParams.get("industry") ?? undefined,
|
|
1262
|
+
limit: parseInt(url.searchParams.get("limit") ?? "50", 10),
|
|
1263
|
+
offset: parseInt(url.searchParams.get("offset") ?? "0", 10)
|
|
1264
|
+
});
|
|
1265
|
+
return json(result);
|
|
1266
|
+
}
|
|
1267
|
+
if (method === "POST" && !id) {
|
|
1268
|
+
const body = await parseJson(req);
|
|
1269
|
+
if (!body || typeof body !== "object")
|
|
1270
|
+
return apiError("Invalid body");
|
|
1271
|
+
try {
|
|
1272
|
+
const company = createCompany(body);
|
|
1273
|
+
return json(company, 201);
|
|
1274
|
+
} catch (err) {
|
|
1275
|
+
return apiError(err instanceof Error ? err.message : "Failed to create company");
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
if (method === "GET" && id) {
|
|
1279
|
+
const company = getCompany(id);
|
|
1280
|
+
if (!company)
|
|
1281
|
+
return apiError("Company not found", 404);
|
|
1282
|
+
return json(company);
|
|
1283
|
+
}
|
|
1284
|
+
if (method === "PATCH" && id) {
|
|
1285
|
+
const body = await parseJson(req);
|
|
1286
|
+
if (!body || typeof body !== "object")
|
|
1287
|
+
return apiError("Invalid body");
|
|
1288
|
+
try {
|
|
1289
|
+
const company = updateCompany(id, body);
|
|
1290
|
+
return json(company);
|
|
1291
|
+
} catch {
|
|
1292
|
+
return apiError("Company not found", 404);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
if (method === "DELETE" && id) {
|
|
1296
|
+
try {
|
|
1297
|
+
deleteCompany(id);
|
|
1298
|
+
return json({ ok: true });
|
|
1299
|
+
} catch {
|
|
1300
|
+
return apiError("Company not found", 404);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return apiError("Method not allowed", 405);
|
|
1304
|
+
}
|
|
1305
|
+
async function handleTags(req, _url, segments) {
|
|
1306
|
+
const method = req.method;
|
|
1307
|
+
const id = segments[2];
|
|
1308
|
+
if (method === "GET" && !id) {
|
|
1309
|
+
return json(listTags());
|
|
1310
|
+
}
|
|
1311
|
+
if (method === "POST" && !id) {
|
|
1312
|
+
const body = await parseJson(req);
|
|
1313
|
+
if (!body || typeof body !== "object")
|
|
1314
|
+
return apiError("Invalid body");
|
|
1315
|
+
const b = body;
|
|
1316
|
+
if (!b.name)
|
|
1317
|
+
return apiError("name is required");
|
|
1318
|
+
const tag = createTag({ name: b.name, color: b.color, description: b.description });
|
|
1319
|
+
return json(tag, 201);
|
|
1320
|
+
}
|
|
1321
|
+
if (method === "DELETE" && id) {
|
|
1322
|
+
try {
|
|
1323
|
+
deleteTag(id);
|
|
1324
|
+
return json({ ok: true });
|
|
1325
|
+
} catch {
|
|
1326
|
+
return apiError("Tag not found", 404);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
return apiError("Method not allowed", 405);
|
|
1330
|
+
}
|
|
1331
|
+
function handleStats() {
|
|
1332
|
+
const db = getDatabase();
|
|
1333
|
+
const contactCount = db.prepare("SELECT COUNT(*) as count FROM contacts").get().count;
|
|
1334
|
+
const companyCount = db.prepare("SELECT COUNT(*) as count FROM companies").get().count;
|
|
1335
|
+
const tagCount = db.prepare("SELECT COUNT(*) as count FROM tags").get().count;
|
|
1336
|
+
return json({ contacts: contactCount, companies: companyCount, tags: tagCount });
|
|
1337
|
+
}
|
|
1338
|
+
async function handleImport(req) {
|
|
1339
|
+
const body = await parseJson(req);
|
|
1340
|
+
if (!body || typeof body !== "object")
|
|
1341
|
+
return apiError("Invalid body");
|
|
1342
|
+
const { format, data } = body;
|
|
1343
|
+
if (!format || !data)
|
|
1344
|
+
return apiError("format and data are required");
|
|
1345
|
+
if (!["json", "csv", "vcf"].includes(format))
|
|
1346
|
+
return apiError("format must be json, csv, or vcf");
|
|
1347
|
+
try {
|
|
1348
|
+
const inputs = await importContacts(format, data);
|
|
1349
|
+
let importedCount = 0;
|
|
1350
|
+
const errors = [];
|
|
1351
|
+
for (const input of inputs) {
|
|
1352
|
+
try {
|
|
1353
|
+
createContact(input);
|
|
1354
|
+
importedCount++;
|
|
1355
|
+
} catch (err) {
|
|
1356
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return json({ imported: importedCount, errors: errors.length, error_details: errors });
|
|
1360
|
+
} catch (err) {
|
|
1361
|
+
return apiError(err instanceof Error ? err.message : "Import failed");
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
async function handleExport(req) {
|
|
1365
|
+
const url = new URL(req.url);
|
|
1366
|
+
const format = url.searchParams.get("format") ?? "json";
|
|
1367
|
+
if (!["json", "csv", "vcf"].includes(format))
|
|
1368
|
+
return apiError("format must be json, csv, or vcf");
|
|
1369
|
+
const { contacts } = listContacts({ limit: 1e5 });
|
|
1370
|
+
const output = await exportContacts(format, contacts);
|
|
1371
|
+
const contentTypes = {
|
|
1372
|
+
json: "application/json",
|
|
1373
|
+
csv: "text/csv",
|
|
1374
|
+
vcf: "text/vcard"
|
|
1375
|
+
};
|
|
1376
|
+
return new Response(output, {
|
|
1377
|
+
headers: {
|
|
1378
|
+
"Content-Type": contentTypes[format] ?? "text/plain",
|
|
1379
|
+
"Content-Disposition": `attachment; filename="contacts.${format}"`
|
|
1380
|
+
}
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
function serveStaticFile(filePath) {
|
|
1384
|
+
if (!existsSync2(filePath))
|
|
1385
|
+
return null;
|
|
1386
|
+
return new Response(Bun.file(filePath));
|
|
1387
|
+
}
|
|
1388
|
+
function startServer(port) {
|
|
1389
|
+
Bun.serve({
|
|
1390
|
+
port,
|
|
1391
|
+
async fetch(req) {
|
|
1392
|
+
const url = new URL(req.url);
|
|
1393
|
+
const segments = getSegments(url);
|
|
1394
|
+
const corsHeaders = {
|
|
1395
|
+
"Access-Control-Allow-Origin": "*",
|
|
1396
|
+
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
|
1397
|
+
"Access-Control-Allow-Headers": "Content-Type"
|
|
1398
|
+
};
|
|
1399
|
+
if (req.method === "OPTIONS") {
|
|
1400
|
+
return new Response(null, { status: 204, headers: corsHeaders });
|
|
1401
|
+
}
|
|
1402
|
+
let response;
|
|
1403
|
+
try {
|
|
1404
|
+
if (segments[0] === "api") {
|
|
1405
|
+
switch (segments[1]) {
|
|
1406
|
+
case "contacts":
|
|
1407
|
+
response = await handleContacts(req, url, segments);
|
|
1408
|
+
break;
|
|
1409
|
+
case "companies":
|
|
1410
|
+
response = await handleCompanies(req, url, segments);
|
|
1411
|
+
break;
|
|
1412
|
+
case "tags":
|
|
1413
|
+
response = await handleTags(req, url, segments);
|
|
1414
|
+
break;
|
|
1415
|
+
case "stats":
|
|
1416
|
+
response = handleStats();
|
|
1417
|
+
break;
|
|
1418
|
+
case "import":
|
|
1419
|
+
response = req.method === "POST" ? await handleImport(req) : apiError("Method not allowed", 405);
|
|
1420
|
+
break;
|
|
1421
|
+
case "export":
|
|
1422
|
+
response = req.method === "GET" ? await handleExport(req) : apiError("Method not allowed", 405);
|
|
1423
|
+
break;
|
|
1424
|
+
default:
|
|
1425
|
+
response = apiError("Not found", 404);
|
|
1426
|
+
}
|
|
1427
|
+
} else {
|
|
1428
|
+
const filePath = join2(DASHBOARD_DIST, url.pathname === "/" ? "index.html" : url.pathname);
|
|
1429
|
+
response = serveStaticFile(filePath) ?? serveStaticFile(join2(DASHBOARD_DIST, "index.html")) ?? new Response("Not Found", { status: 404 });
|
|
1430
|
+
}
|
|
1431
|
+
} catch (err) {
|
|
1432
|
+
console.error("Request error:", err);
|
|
1433
|
+
response = apiError("Internal server error", 500);
|
|
1434
|
+
}
|
|
1435
|
+
const headers = new Headers(response.headers);
|
|
1436
|
+
for (const [k, v] of Object.entries(corsHeaders)) {
|
|
1437
|
+
headers.set(k, v);
|
|
1438
|
+
}
|
|
1439
|
+
return new Response(response.body, { status: response.status, headers });
|
|
1440
|
+
}
|
|
1441
|
+
});
|
|
1442
|
+
console.log(`Contacts server running at http://localhost:${port}`);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// src/server/index.ts
|
|
1446
|
+
var DEFAULT_PORT = 19428;
|
|
1447
|
+
function parsePort() {
|
|
1448
|
+
const portArg = process.argv.find((a) => a === "--port" || a.startsWith("--port="));
|
|
1449
|
+
if (portArg) {
|
|
1450
|
+
if (portArg.includes("=")) {
|
|
1451
|
+
return parseInt(portArg.split("=")[1], 10) || DEFAULT_PORT;
|
|
1452
|
+
}
|
|
1453
|
+
const idx = process.argv.indexOf(portArg);
|
|
1454
|
+
return parseInt(process.argv[idx + 1], 10) || DEFAULT_PORT;
|
|
1455
|
+
}
|
|
1456
|
+
return DEFAULT_PORT;
|
|
1457
|
+
}
|
|
1458
|
+
async function findFreePort(start) {
|
|
1459
|
+
for (let port = start;port < start + 100; port++) {
|
|
1460
|
+
try {
|
|
1461
|
+
const server = Bun.serve({ port, fetch: () => new Response("") });
|
|
1462
|
+
server.stop(true);
|
|
1463
|
+
return port;
|
|
1464
|
+
} catch {}
|
|
1465
|
+
}
|
|
1466
|
+
return start;
|
|
1467
|
+
}
|
|
1468
|
+
async function main() {
|
|
1469
|
+
const requested = parsePort();
|
|
1470
|
+
const port = await findFreePort(requested);
|
|
1471
|
+
if (port !== requested) {
|
|
1472
|
+
console.log(`Port ${requested} in use, using ${port}`);
|
|
1473
|
+
}
|
|
1474
|
+
startServer(port);
|
|
1475
|
+
}
|
|
1476
|
+
main();
|