@hasna/contacts 0.1.0 → 0.2.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/dashboard/dist/assets/index-0l6aQb1t.css +1 -0
- package/dashboard/dist/assets/index-opnZdkVD.js +229 -0
- package/dashboard/dist/index.html +2 -2
- package/dist/cli/index.js +530 -16
- package/dist/db/companies.d.ts.map +1 -1
- package/dist/db/companies.test.d.ts +2 -0
- package/dist/db/companies.test.d.ts.map +1 -0
- package/dist/db/contacts.d.ts +1 -0
- package/dist/db/contacts.d.ts.map +1 -1
- package/dist/db/contacts.test.d.ts +2 -0
- package/dist/db/contacts.test.d.ts.map +1 -0
- package/dist/db/database.d.ts +1 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/groups.d.ts +12 -0
- package/dist/db/groups.d.ts.map +1 -0
- package/dist/db/relationships.test.d.ts +2 -0
- package/dist/db/relationships.test.d.ts.map +1 -0
- package/dist/db/tags.d.ts.map +1 -1
- package/dist/db/tags.test.d.ts +2 -0
- package/dist/db/tags.test.d.ts.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +102 -5
- package/dist/lib/config.d.ts +7 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/dedup.d.ts +10 -0
- package/dist/lib/dedup.d.ts.map +1 -0
- package/dist/lib/import.test.d.ts +2 -0
- package/dist/lib/import.test.d.ts.map +1 -0
- package/dist/mcp/index.js +567 -76
- package/dist/mcp/mcp.test.d.ts +2 -0
- package/dist/mcp/mcp.test.d.ts.map +1 -0
- package/dist/server/index.js +38 -3
- package/dist/types/index.d.ts +25 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/dashboard/dist/assets/index-B4ndI7Qt.js +0 -49
- package/dashboard/dist/assets/index-C5bn2HWO.css +0 -1
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Open Contacts</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-opnZdkVD.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-0l6aQb1t.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
package/dist/cli/index.js
CHANGED
|
@@ -2093,6 +2093,14 @@ var init_types = __esm(() => {
|
|
|
2093
2093
|
});
|
|
2094
2094
|
|
|
2095
2095
|
// src/db/database.ts
|
|
2096
|
+
var exports_database = {};
|
|
2097
|
+
__export(exports_database, {
|
|
2098
|
+
uuid: () => uuid,
|
|
2099
|
+
resetDatabase: () => resetDatabase,
|
|
2100
|
+
now: () => now,
|
|
2101
|
+
getDbPath: () => getDbPath,
|
|
2102
|
+
getDatabase: () => getDatabase
|
|
2103
|
+
});
|
|
2096
2104
|
import { Database } from "bun:sqlite";
|
|
2097
2105
|
import { existsSync, mkdirSync } from "fs";
|
|
2098
2106
|
import { dirname, join, resolve } from "path";
|
|
@@ -2121,6 +2129,9 @@ function getDatabase(path) {
|
|
|
2121
2129
|
_db = db;
|
|
2122
2130
|
return db;
|
|
2123
2131
|
}
|
|
2132
|
+
function resetDatabase() {
|
|
2133
|
+
_db = null;
|
|
2134
|
+
}
|
|
2124
2135
|
function uuid() {
|
|
2125
2136
|
return crypto.randomUUID();
|
|
2126
2137
|
}
|
|
@@ -2303,6 +2314,25 @@ var init_database = __esm(() => {
|
|
|
2303
2314
|
END;
|
|
2304
2315
|
|
|
2305
2316
|
CREATE TABLE IF NOT EXISTS _migrations (version INTEGER PRIMARY KEY);
|
|
2317
|
+
`,
|
|
2318
|
+
`
|
|
2319
|
+
ALTER TABLE contacts ADD COLUMN last_contacted_at TEXT;
|
|
2320
|
+
ALTER TABLE contacts ADD COLUMN website TEXT;
|
|
2321
|
+
ALTER TABLE contacts ADD COLUMN preferred_contact_method TEXT;
|
|
2322
|
+
|
|
2323
|
+
CREATE TABLE IF NOT EXISTS groups (
|
|
2324
|
+
id TEXT PRIMARY KEY,
|
|
2325
|
+
name TEXT NOT NULL UNIQUE,
|
|
2326
|
+
description TEXT,
|
|
2327
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
2328
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
2329
|
+
);
|
|
2330
|
+
|
|
2331
|
+
CREATE TABLE IF NOT EXISTS contact_groups (
|
|
2332
|
+
contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
|
2333
|
+
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
|
2334
|
+
PRIMARY KEY (contact_id, group_id)
|
|
2335
|
+
);
|
|
2306
2336
|
`
|
|
2307
2337
|
];
|
|
2308
2338
|
});
|
|
@@ -2322,7 +2352,8 @@ function rowToContact(row) {
|
|
|
2322
2352
|
return {
|
|
2323
2353
|
...row,
|
|
2324
2354
|
source: row.source,
|
|
2325
|
-
custom_fields: JSON.parse(row.custom_fields || "{}")
|
|
2355
|
+
custom_fields: JSON.parse(row.custom_fields || "{}"),
|
|
2356
|
+
preferred_contact_method: row.preferred_contact_method ?? null
|
|
2326
2357
|
};
|
|
2327
2358
|
}
|
|
2328
2359
|
function rowToEmail(row) {
|
|
@@ -2403,8 +2434,8 @@ function createContact(input, db) {
|
|
|
2403
2434
|
const firstName = input.first_name ?? "";
|
|
2404
2435
|
const lastName = input.last_name ?? "";
|
|
2405
2436
|
const displayName = input.display_name ?? (firstName || lastName ? `${firstName} ${lastName}`.trim() : "Unnamed Contact");
|
|
2406
|
-
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)
|
|
2407
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
2437
|
+
d.run(`INSERT INTO contacts (id, first_name, last_name, display_name, nickname, avatar_url, notes, birthday, company_id, job_title, source, custom_fields, last_contacted_at, website, preferred_contact_method, created_at, updated_at)
|
|
2438
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
2408
2439
|
id,
|
|
2409
2440
|
firstName,
|
|
2410
2441
|
lastName,
|
|
@@ -2417,6 +2448,9 @@ function createContact(input, db) {
|
|
|
2417
2448
|
input.job_title ?? null,
|
|
2418
2449
|
input.source ?? "manual",
|
|
2419
2450
|
JSON.stringify(input.custom_fields ?? {}),
|
|
2451
|
+
input.last_contacted_at ?? null,
|
|
2452
|
+
input.website ?? null,
|
|
2453
|
+
input.preferred_contact_method ?? null,
|
|
2420
2454
|
timestamp,
|
|
2421
2455
|
timestamp
|
|
2422
2456
|
]);
|
|
@@ -2528,6 +2562,18 @@ function updateContact(id, input, db) {
|
|
|
2528
2562
|
setClauses.push("custom_fields = ?");
|
|
2529
2563
|
params.push(JSON.stringify(input.custom_fields));
|
|
2530
2564
|
}
|
|
2565
|
+
if (input.last_contacted_at !== undefined) {
|
|
2566
|
+
setClauses.push("last_contacted_at = ?");
|
|
2567
|
+
params.push(input.last_contacted_at);
|
|
2568
|
+
}
|
|
2569
|
+
if (input.website !== undefined) {
|
|
2570
|
+
setClauses.push("website = ?");
|
|
2571
|
+
params.push(input.website);
|
|
2572
|
+
}
|
|
2573
|
+
if (input.preferred_contact_method !== undefined) {
|
|
2574
|
+
setClauses.push("preferred_contact_method = ?");
|
|
2575
|
+
params.push(input.preferred_contact_method);
|
|
2576
|
+
}
|
|
2531
2577
|
params.push(id);
|
|
2532
2578
|
d.run(`UPDATE contacts SET ${setClauses.join(", ")} WHERE id = ?`, params);
|
|
2533
2579
|
logActivity(d, { contact_id: id, action: "contact.updated", details: `Updated contact: ${existing.display_name}` });
|
|
@@ -2573,6 +2619,11 @@ function searchContacts(query, db) {
|
|
|
2573
2619
|
}
|
|
2574
2620
|
return allRows.map((row) => loadContactDetails(d, rowToContact(row)));
|
|
2575
2621
|
}
|
|
2622
|
+
function listRecentContacts(limit, db) {
|
|
2623
|
+
const d = db || getDatabase();
|
|
2624
|
+
const rows = d.query(`SELECT * FROM contacts ORDER BY updated_at DESC LIMIT ?`).all(limit);
|
|
2625
|
+
return rows.map((row) => loadContactDetails(d, rowToContact(row)));
|
|
2626
|
+
}
|
|
2576
2627
|
var init_contacts = __esm(() => {
|
|
2577
2628
|
init_types();
|
|
2578
2629
|
init_database();
|
|
@@ -3256,8 +3307,8 @@ var exports_serve = {};
|
|
|
3256
3307
|
__export(exports_serve, {
|
|
3257
3308
|
startServer: () => startServer
|
|
3258
3309
|
});
|
|
3259
|
-
import { existsSync as
|
|
3260
|
-
import { join as
|
|
3310
|
+
import { existsSync as existsSync3 } from "fs";
|
|
3311
|
+
import { join as join3 } from "path";
|
|
3261
3312
|
function json(data, status = 200) {
|
|
3262
3313
|
return new Response(JSON.stringify(data), {
|
|
3263
3314
|
status,
|
|
@@ -3463,7 +3514,7 @@ async function handleExport(req) {
|
|
|
3463
3514
|
});
|
|
3464
3515
|
}
|
|
3465
3516
|
function serveStaticFile(filePath) {
|
|
3466
|
-
if (!
|
|
3517
|
+
if (!existsSync3(filePath))
|
|
3467
3518
|
return null;
|
|
3468
3519
|
return new Response(Bun.file(filePath));
|
|
3469
3520
|
}
|
|
@@ -3507,8 +3558,8 @@ function startServer(port) {
|
|
|
3507
3558
|
response = apiError("Not found", 404);
|
|
3508
3559
|
}
|
|
3509
3560
|
} else {
|
|
3510
|
-
const filePath =
|
|
3511
|
-
response = serveStaticFile(filePath) ?? serveStaticFile(
|
|
3561
|
+
const filePath = join3(DASHBOARD_DIST, url.pathname === "/" ? "index.html" : url.pathname);
|
|
3562
|
+
response = serveStaticFile(filePath) ?? serveStaticFile(join3(DASHBOARD_DIST, "index.html")) ?? new Response("Not Found", { status: 404 });
|
|
3512
3563
|
}
|
|
3513
3564
|
} catch (err) {
|
|
3514
3565
|
console.error("Request error:", err);
|
|
@@ -3529,7 +3580,7 @@ var init_serve = __esm(() => {
|
|
|
3529
3580
|
init_contacts();
|
|
3530
3581
|
init_companies();
|
|
3531
3582
|
init_tags();
|
|
3532
|
-
DASHBOARD_DIST =
|
|
3583
|
+
DASHBOARD_DIST = join3(import.meta.dir, "../../dashboard/dist");
|
|
3533
3584
|
});
|
|
3534
3585
|
|
|
3535
3586
|
// node_modules/commander/esm.mjs
|
|
@@ -3553,8 +3604,85 @@ init_contacts();
|
|
|
3553
3604
|
init_companies();
|
|
3554
3605
|
init_tags();
|
|
3555
3606
|
import chalk from "chalk";
|
|
3556
|
-
|
|
3557
|
-
|
|
3607
|
+
|
|
3608
|
+
// src/db/groups.ts
|
|
3609
|
+
init_database();
|
|
3610
|
+
function createGroup(db, input) {
|
|
3611
|
+
const id = uuid();
|
|
3612
|
+
db.query(`INSERT INTO groups(id, name, description, created_at, updated_at) VALUES(?,?,?,?,?)`).run(id, input.name, input.description ?? null, now(), now());
|
|
3613
|
+
return getGroup(db, id);
|
|
3614
|
+
}
|
|
3615
|
+
function getGroup(db, id) {
|
|
3616
|
+
return db.query(`SELECT * FROM groups WHERE id = ?`).get(id);
|
|
3617
|
+
}
|
|
3618
|
+
function listGroups(db) {
|
|
3619
|
+
return db.query(`SELECT g.*, COUNT(cg.contact_id) as member_count FROM groups g LEFT JOIN contact_groups cg ON g.id = cg.group_id GROUP BY g.id ORDER BY g.name`).all();
|
|
3620
|
+
}
|
|
3621
|
+
function addContactToGroup(db, contactId, groupId) {
|
|
3622
|
+
db.query(`INSERT OR IGNORE INTO contact_groups(contact_id, group_id) VALUES(?,?)`).run(contactId, groupId);
|
|
3623
|
+
}
|
|
3624
|
+
function removeContactFromGroup(db, contactId, groupId) {
|
|
3625
|
+
db.query(`DELETE FROM contact_groups WHERE contact_id = ? AND group_id = ?`).run(contactId, groupId);
|
|
3626
|
+
}
|
|
3627
|
+
function listContactsInGroup(db, groupId) {
|
|
3628
|
+
const rows = db.query(`SELECT contact_id FROM contact_groups WHERE group_id = ?`).all(groupId);
|
|
3629
|
+
return rows.map((r) => r.contact_id);
|
|
3630
|
+
}
|
|
3631
|
+
|
|
3632
|
+
// src/cli/index.tsx
|
|
3633
|
+
init_database();
|
|
3634
|
+
|
|
3635
|
+
// src/lib/dedup.ts
|
|
3636
|
+
function findEmailDuplicates(db) {
|
|
3637
|
+
const rows = db.query(`
|
|
3638
|
+
SELECT e.address as email, GROUP_CONCAT(e.contact_id) as ids
|
|
3639
|
+
FROM emails e
|
|
3640
|
+
WHERE e.contact_id IS NOT NULL
|
|
3641
|
+
GROUP BY LOWER(e.address)
|
|
3642
|
+
HAVING COUNT(*) > 1
|
|
3643
|
+
`).all();
|
|
3644
|
+
return rows.map((r) => ({ email: r.email, contact_ids: r.ids.split(",") }));
|
|
3645
|
+
}
|
|
3646
|
+
function levenshtein(a, b) {
|
|
3647
|
+
const m = a.length, n = b.length;
|
|
3648
|
+
const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_2, j) => i === 0 ? j : j === 0 ? i : 0));
|
|
3649
|
+
for (let i = 1;i <= m; i++)
|
|
3650
|
+
for (let j = 1;j <= n; j++)
|
|
3651
|
+
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
3652
|
+
return dp[m][n];
|
|
3653
|
+
}
|
|
3654
|
+
function findNameDuplicates(db) {
|
|
3655
|
+
const contacts = db.query(`SELECT id, display_name FROM contacts`).all();
|
|
3656
|
+
const pairs = [];
|
|
3657
|
+
for (let i = 0;i < contacts.length; i++) {
|
|
3658
|
+
for (let j = i + 1;j < contacts.length; j++) {
|
|
3659
|
+
const dist = levenshtein(contacts[i].display_name.toLowerCase(), contacts[j].display_name.toLowerCase());
|
|
3660
|
+
if (dist <= 2 && dist > 0) {
|
|
3661
|
+
pairs.push({ contact_ids: [contacts[i].id, contacts[j].id], similarity: dist });
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
}
|
|
3665
|
+
return pairs;
|
|
3666
|
+
}
|
|
3667
|
+
|
|
3668
|
+
// src/lib/config.ts
|
|
3669
|
+
import { existsSync as existsSync2, readFileSync, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
|
|
3670
|
+
import { join as join2 } from "path";
|
|
3671
|
+
var CONFIG_DIR = join2(process.env["HOME"] || "~", ".contacts");
|
|
3672
|
+
var CONFIG_FILE = join2(CONFIG_DIR, "config.json");
|
|
3673
|
+
function readConfig() {
|
|
3674
|
+
if (!existsSync2(CONFIG_FILE))
|
|
3675
|
+
return {};
|
|
3676
|
+
try {
|
|
3677
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
3678
|
+
} catch {
|
|
3679
|
+
return {};
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3683
|
+
// src/cli/index.tsx
|
|
3684
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync4, copyFileSync, statSync, mkdirSync as mkdirSync3, readdirSync } from "fs";
|
|
3685
|
+
import { extname, join as join4 } from "path";
|
|
3558
3686
|
function renderTable(headers, rows) {
|
|
3559
3687
|
const colWidths = headers.map((h) => h.length);
|
|
3560
3688
|
for (const row of rows) {
|
|
@@ -3660,7 +3788,43 @@ async function confirm(question) {
|
|
|
3660
3788
|
return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes";
|
|
3661
3789
|
}
|
|
3662
3790
|
program.name("contacts").description("Open Contacts \u2014 contact management for AI coding agents").version("0.1.0");
|
|
3663
|
-
|
|
3791
|
+
function collect(val, prev) {
|
|
3792
|
+
return [...prev, val];
|
|
3793
|
+
}
|
|
3794
|
+
program.command("add").description("Add a new contact (interactive or via flags)").option("--first <name>", "First name").option("--last <name>", "Last name").option("--display <name>", "Display name").option("--email <email>", "Email address").option("--phone <phone>", "Phone number").option("--title <title>", "Job title").option("--company <id>", "Company ID").option("--tag <tag>", "Tag name (can specify multiple times)", collect, []).option("--note <text>", "Notes").option("--website <url>", "Website URL").action(async (opts) => {
|
|
3795
|
+
if (opts.first || opts.last || opts.display) {
|
|
3796
|
+
const firstName = opts.first ?? "";
|
|
3797
|
+
const lastName = opts.last ?? "";
|
|
3798
|
+
const displayName = opts.display ?? (`${firstName} ${lastName}`.trim() || "Unnamed Contact");
|
|
3799
|
+
const input2 = {
|
|
3800
|
+
display_name: displayName,
|
|
3801
|
+
first_name: firstName || undefined,
|
|
3802
|
+
last_name: lastName || undefined,
|
|
3803
|
+
job_title: opts.title || undefined,
|
|
3804
|
+
notes: opts.note || undefined,
|
|
3805
|
+
website: opts.website || undefined,
|
|
3806
|
+
company_id: opts.company || undefined,
|
|
3807
|
+
emails: opts.email ? [{ address: opts.email, type: "work", is_primary: true }] : undefined,
|
|
3808
|
+
phones: opts.phone ? [{ number: opts.phone, type: "mobile", is_primary: true }] : undefined
|
|
3809
|
+
};
|
|
3810
|
+
const contact2 = createContact(input2);
|
|
3811
|
+
if (opts.tag.length > 0) {
|
|
3812
|
+
const db = getDatabase();
|
|
3813
|
+
const allTags = listTags();
|
|
3814
|
+
for (const tagName of opts.tag) {
|
|
3815
|
+
const tag = allTags.find((t) => t.name === tagName);
|
|
3816
|
+
if (tag) {
|
|
3817
|
+
db.run(`INSERT OR IGNORE INTO contact_tags (contact_id, tag_id) VALUES (?, ?)`, [contact2.id, tag.id]);
|
|
3818
|
+
} else {
|
|
3819
|
+
console.log(chalk.yellow(` ! Tag not found: ${tagName} (skipped)`));
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
console.log(chalk.green(`
|
|
3824
|
+
\u2713 Contact created: ${contact2.display_name} (${contact2.id})
|
|
3825
|
+
`));
|
|
3826
|
+
return;
|
|
3827
|
+
}
|
|
3664
3828
|
console.log(chalk.bold.blue(`
|
|
3665
3829
|
Add New Contact
|
|
3666
3830
|
`));
|
|
@@ -3718,8 +3882,40 @@ program.command("show <id>").description("Show full contact details").action((id
|
|
|
3718
3882
|
const contact = getContact(id);
|
|
3719
3883
|
formatContact(contact);
|
|
3720
3884
|
});
|
|
3721
|
-
program.command("edit <id>").description("Edit a contact
|
|
3885
|
+
program.command("edit <id>").description("Edit a contact (interactive or via flags)").option("--first <name>", "First name").option("--last <name>", "Last name").option("--display <name>", "Display name").option("--email <email>", "Email address").option("--phone <phone>", "Phone number").option("--title <title>", "Job title").option("--note <text>", "Notes").option("--website <url>", "Website URL").action(async (id, opts) => {
|
|
3722
3886
|
const contact = getContact(id);
|
|
3887
|
+
const hasFlags = opts.first || opts.last || opts.display || opts.email || opts.phone || opts.title || opts.note || opts.website;
|
|
3888
|
+
if (hasFlags) {
|
|
3889
|
+
const updates2 = {};
|
|
3890
|
+
if (opts.first !== undefined)
|
|
3891
|
+
updates2.first_name = opts.first;
|
|
3892
|
+
if (opts.last !== undefined)
|
|
3893
|
+
updates2.last_name = opts.last;
|
|
3894
|
+
if (opts.display !== undefined)
|
|
3895
|
+
updates2.display_name = opts.display;
|
|
3896
|
+
if (opts.title !== undefined)
|
|
3897
|
+
updates2.job_title = opts.title;
|
|
3898
|
+
if (opts.note !== undefined)
|
|
3899
|
+
updates2.notes = opts.note;
|
|
3900
|
+
if (opts.website !== undefined)
|
|
3901
|
+
updates2.website = opts.website;
|
|
3902
|
+
const updated2 = updateContact(id, updates2);
|
|
3903
|
+
if (opts.email) {
|
|
3904
|
+
const db = getDatabase();
|
|
3905
|
+
const { uuid: uuid2 } = await Promise.resolve().then(() => (init_database(), exports_database));
|
|
3906
|
+
db.run(`INSERT INTO emails (id, contact_id, company_id, address, type, is_primary) VALUES (?, ?, NULL, ?, 'work', 0)`, [uuid2(), id, opts.email]);
|
|
3907
|
+
}
|
|
3908
|
+
if (opts.phone) {
|
|
3909
|
+
const db = getDatabase();
|
|
3910
|
+
const { uuid: uuid2 } = await Promise.resolve().then(() => (init_database(), exports_database));
|
|
3911
|
+
db.run(`INSERT INTO phones (id, contact_id, company_id, number, country_code, type, is_primary) VALUES (?, ?, NULL, ?, NULL, 'mobile', 0)`, [uuid2(), id, opts.phone]);
|
|
3912
|
+
}
|
|
3913
|
+
console.log(chalk.green(`
|
|
3914
|
+
\u2713 Contact updated: ${updated2.display_name}
|
|
3915
|
+
`));
|
|
3916
|
+
formatContact(getContact(id));
|
|
3917
|
+
return;
|
|
3918
|
+
}
|
|
3723
3919
|
console.log(chalk.bold.blue(`
|
|
3724
3920
|
Editing: ${contact.display_name}
|
|
3725
3921
|
`));
|
|
@@ -3729,6 +3925,7 @@ Editing: ${contact.display_name}
|
|
|
3729
3925
|
const first_name = await prompt(`First name [${contact.first_name}]:`);
|
|
3730
3926
|
const last_name = await prompt(`Last name [${contact.last_name}]:`);
|
|
3731
3927
|
const job_title = await prompt(`Job title [${contact.job_title ?? ""}]:`);
|
|
3928
|
+
const website = await prompt(`Website [${contact.website ?? ""}]:`);
|
|
3732
3929
|
const notes = await prompt(`Notes [${contact.notes ? contact.notes.slice(0, 30) + "..." : ""}]:`);
|
|
3733
3930
|
const updates = {};
|
|
3734
3931
|
if (display_name)
|
|
@@ -3739,6 +3936,8 @@ Editing: ${contact.display_name}
|
|
|
3739
3936
|
updates.last_name = last_name;
|
|
3740
3937
|
if (job_title)
|
|
3741
3938
|
updates.job_title = job_title;
|
|
3939
|
+
if (website)
|
|
3940
|
+
updates.website = website;
|
|
3742
3941
|
if (notes)
|
|
3743
3942
|
updates.notes = notes;
|
|
3744
3943
|
if (Object.keys(updates).length === 0) {
|
|
@@ -3894,7 +4093,7 @@ Add New Tag
|
|
|
3894
4093
|
`));
|
|
3895
4094
|
});
|
|
3896
4095
|
program.command("import <file>").description("Import contacts from CSV, vCard (.vcf), or JSON file").action(async (file) => {
|
|
3897
|
-
if (!
|
|
4096
|
+
if (!existsSync4(file)) {
|
|
3898
4097
|
console.error(chalk.red(`
|
|
3899
4098
|
File not found: ${file}
|
|
3900
4099
|
`));
|
|
@@ -3914,7 +4113,7 @@ Unsupported file type: ${ext}. Use .csv, .vcf, or .json
|
|
|
3914
4113
|
`));
|
|
3915
4114
|
process.exit(1);
|
|
3916
4115
|
}
|
|
3917
|
-
const data =
|
|
4116
|
+
const data = readFileSync2(file, "utf8");
|
|
3918
4117
|
console.log(chalk.blue(`
|
|
3919
4118
|
Importing ${format.toUpperCase()} from ${file}...
|
|
3920
4119
|
`));
|
|
@@ -3945,7 +4144,7 @@ Invalid format: ${format}. Use csv, vcf, or json
|
|
|
3945
4144
|
const { contacts } = listContacts({ limit: 1e5 });
|
|
3946
4145
|
const output = await exportContacts(format, contacts);
|
|
3947
4146
|
if (opts.output) {
|
|
3948
|
-
|
|
4147
|
+
writeFileSync2(opts.output, output, "utf8");
|
|
3949
4148
|
console.log(chalk.green(`
|
|
3950
4149
|
\u2713 Exported ${contacts.length} contact(s) to ${opts.output}
|
|
3951
4150
|
`));
|
|
@@ -3989,4 +4188,319 @@ ${chalk.bold("Available tools (24 total):")}
|
|
|
3989
4188
|
${chalk.gray("I/O: ")}${chalk.white("import_contacts export_contacts get_stats")}
|
|
3990
4189
|
`);
|
|
3991
4190
|
});
|
|
4191
|
+
program.command("open [id]").description("Open the web dashboard in browser").action(async (id) => {
|
|
4192
|
+
const port = 19428;
|
|
4193
|
+
const url = id ? `http://localhost:${port}/#/contacts/${id}` : `http://localhost:${port}`;
|
|
4194
|
+
const platform = process.platform;
|
|
4195
|
+
const opener = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
|
|
4196
|
+
const proc = Bun.spawn([opener, url], { stdio: ["ignore", "ignore", "ignore"] });
|
|
4197
|
+
await proc.exited;
|
|
4198
|
+
console.log(chalk.green(`Opening ${url}`));
|
|
4199
|
+
});
|
|
4200
|
+
program.command("recent").description("Show recently added or modified contacts").option("--limit <n>", "Number to show", "10").action((opts) => {
|
|
4201
|
+
const limit = parseInt(opts.limit, 10);
|
|
4202
|
+
const contacts = listRecentContacts(limit);
|
|
4203
|
+
if (contacts.length === 0) {
|
|
4204
|
+
console.log(chalk.gray(`
|
|
4205
|
+
No contacts found.
|
|
4206
|
+
`));
|
|
4207
|
+
return;
|
|
4208
|
+
}
|
|
4209
|
+
console.log();
|
|
4210
|
+
const rows = contacts.map((c) => ({
|
|
4211
|
+
Name: c.display_name,
|
|
4212
|
+
Company: c.company?.name ?? "",
|
|
4213
|
+
Email: c.emails?.[0]?.address ?? "",
|
|
4214
|
+
Phone: c.phones?.[0]?.number ?? "",
|
|
4215
|
+
Updated: c.updated_at.slice(0, 10)
|
|
4216
|
+
}));
|
|
4217
|
+
renderTable(["Name", "Company", "Email", "Phone", "Updated"], rows);
|
|
4218
|
+
console.log(chalk.gray(`
|
|
4219
|
+
${contacts.length} recent contact(s)
|
|
4220
|
+
`));
|
|
4221
|
+
});
|
|
4222
|
+
program.command("dupe").description("Find potential duplicate contacts").action(() => {
|
|
4223
|
+
const db = getDatabase();
|
|
4224
|
+
const emailDupes = findEmailDuplicates(db);
|
|
4225
|
+
const nameDupes = findNameDuplicates(db);
|
|
4226
|
+
let total = 0;
|
|
4227
|
+
if (emailDupes.length > 0) {
|
|
4228
|
+
console.log(chalk.bold.yellow(`
|
|
4229
|
+
Duplicate Emails:
|
|
4230
|
+
`));
|
|
4231
|
+
for (const group of emailDupes) {
|
|
4232
|
+
console.log(` ${chalk.cyan(group.email)}`);
|
|
4233
|
+
for (const cid of group.contact_ids) {
|
|
4234
|
+
try {
|
|
4235
|
+
const c = getContact(cid);
|
|
4236
|
+
console.log(` ${chalk.gray(cid)} ${c.display_name}`);
|
|
4237
|
+
} catch {
|
|
4238
|
+
console.log(` ${chalk.gray(cid)} (not found)`);
|
|
4239
|
+
}
|
|
4240
|
+
}
|
|
4241
|
+
console.log();
|
|
4242
|
+
total++;
|
|
4243
|
+
}
|
|
4244
|
+
}
|
|
4245
|
+
if (nameDupes.length > 0) {
|
|
4246
|
+
console.log(chalk.bold.yellow(`Similar Names:
|
|
4247
|
+
`));
|
|
4248
|
+
for (const pair of nameDupes) {
|
|
4249
|
+
try {
|
|
4250
|
+
const a = getContact(pair.contact_ids[0]);
|
|
4251
|
+
const b = getContact(pair.contact_ids[1]);
|
|
4252
|
+
console.log(` ${chalk.magenta(a.display_name)} \u2194 ${chalk.magenta(b.display_name)} ${chalk.gray(`(distance: ${pair.similarity})`)}`);
|
|
4253
|
+
console.log(` ${chalk.gray(pair.contact_ids[0])} vs ${chalk.gray(pair.contact_ids[1])}`);
|
|
4254
|
+
console.log();
|
|
4255
|
+
total++;
|
|
4256
|
+
} catch {}
|
|
4257
|
+
}
|
|
4258
|
+
}
|
|
4259
|
+
if (total === 0) {
|
|
4260
|
+
console.log(chalk.green(`
|
|
4261
|
+
No duplicates found.
|
|
4262
|
+
`));
|
|
4263
|
+
} else {
|
|
4264
|
+
console.log(chalk.gray(`Found ${total} duplicate group(s). Use 'contacts show <id>' to inspect and 'contacts delete <id>' to clean up.
|
|
4265
|
+
`));
|
|
4266
|
+
}
|
|
4267
|
+
});
|
|
4268
|
+
program.command("log <id>").description("Log a contact interaction (sets last_contacted_at)").option("--note <text>", "Note to append").option("--date <YYYY-MM-DD>", "Date of contact (default: today)").action((id, opts) => {
|
|
4269
|
+
const contact = getContact(id);
|
|
4270
|
+
const date = opts.date ?? new Date().toISOString().slice(0, 10);
|
|
4271
|
+
const updates = {
|
|
4272
|
+
last_contacted_at: date
|
|
4273
|
+
};
|
|
4274
|
+
if (opts.note) {
|
|
4275
|
+
const existing = contact.notes ?? "";
|
|
4276
|
+
const separator = existing ? `
|
|
4277
|
+
` : "";
|
|
4278
|
+
updates.notes = `${existing}${separator}[${date}] ${opts.note}`;
|
|
4279
|
+
}
|
|
4280
|
+
const updated = updateContact(id, updates);
|
|
4281
|
+
console.log(chalk.green(`
|
|
4282
|
+
\u2713 Logged contact with ${updated.display_name} on ${date}
|
|
4283
|
+
`));
|
|
4284
|
+
if (opts.note) {
|
|
4285
|
+
console.log(chalk.gray(` Note: ${opts.note}
|
|
4286
|
+
`));
|
|
4287
|
+
}
|
|
4288
|
+
});
|
|
4289
|
+
var groupsCmd = program.command("groups").description("Manage contact groups").action(() => {
|
|
4290
|
+
const db = getDatabase();
|
|
4291
|
+
const groups = listGroups(db);
|
|
4292
|
+
if (groups.length === 0) {
|
|
4293
|
+
console.log(chalk.gray(`
|
|
4294
|
+
No groups found.
|
|
4295
|
+
`));
|
|
4296
|
+
return;
|
|
4297
|
+
}
|
|
4298
|
+
console.log();
|
|
4299
|
+
const rows = groups.map((g) => ({
|
|
4300
|
+
ID: g.id,
|
|
4301
|
+
Name: g.name,
|
|
4302
|
+
Description: g.description ?? "",
|
|
4303
|
+
Members: String(g.member_count ?? 0)
|
|
4304
|
+
}));
|
|
4305
|
+
renderTable(["ID", "Name", "Description", "Members"], rows);
|
|
4306
|
+
console.log(chalk.gray(`
|
|
4307
|
+
${groups.length} group(s)
|
|
4308
|
+
`));
|
|
4309
|
+
});
|
|
4310
|
+
groupsCmd.command("add").description("Create a new group").option("--name <name>", "Group name (required)").option("--description <desc>", "Description").action(async (opts) => {
|
|
4311
|
+
const db = getDatabase();
|
|
4312
|
+
let name = opts.name;
|
|
4313
|
+
if (!name) {
|
|
4314
|
+
name = await prompt("Group name (required):");
|
|
4315
|
+
if (!name) {
|
|
4316
|
+
console.error(chalk.red("Group name is required."));
|
|
4317
|
+
process.exit(1);
|
|
4318
|
+
}
|
|
4319
|
+
}
|
|
4320
|
+
const group = createGroup(db, { name, description: opts.description });
|
|
4321
|
+
console.log(chalk.green(`
|
|
4322
|
+
\u2713 Group created: ${group.name} (${group.id})
|
|
4323
|
+
`));
|
|
4324
|
+
});
|
|
4325
|
+
groupsCmd.command("show <id>").description("Show group details with members").action((id) => {
|
|
4326
|
+
const db = getDatabase();
|
|
4327
|
+
const group = getGroup(db, id);
|
|
4328
|
+
if (!group) {
|
|
4329
|
+
console.error(chalk.red(`
|
|
4330
|
+
Group not found: ${id}
|
|
4331
|
+
`));
|
|
4332
|
+
process.exit(1);
|
|
4333
|
+
}
|
|
4334
|
+
console.log(`
|
|
4335
|
+
` + chalk.bold.blue("\u2501\u2501\u2501 Group: ") + chalk.bold(group.name) + chalk.bold.blue(" \u2501\u2501\u2501"));
|
|
4336
|
+
if (group.description)
|
|
4337
|
+
console.log(chalk.gray(" Description: ") + group.description);
|
|
4338
|
+
console.log(chalk.gray(` ID: ${group.id}`));
|
|
4339
|
+
console.log();
|
|
4340
|
+
const memberIds = listContactsInGroup(db, id);
|
|
4341
|
+
if (memberIds.length === 0) {
|
|
4342
|
+
console.log(chalk.gray(` No members.
|
|
4343
|
+
`));
|
|
4344
|
+
return;
|
|
4345
|
+
}
|
|
4346
|
+
console.log(chalk.yellow(` Members (${memberIds.length}):
|
|
4347
|
+
`));
|
|
4348
|
+
for (const cid of memberIds) {
|
|
4349
|
+
try {
|
|
4350
|
+
const c = getContact(cid);
|
|
4351
|
+
console.log(` ${chalk.bold(c.display_name)} ${chalk.gray(cid)}`);
|
|
4352
|
+
} catch {
|
|
4353
|
+
console.log(` ${chalk.gray(cid)} (not found)`);
|
|
4354
|
+
}
|
|
4355
|
+
}
|
|
4356
|
+
console.log();
|
|
4357
|
+
});
|
|
4358
|
+
groupsCmd.command("add-member <group-id> <contact-id>").description("Add a contact to a group").action((groupId, contactId) => {
|
|
4359
|
+
const db = getDatabase();
|
|
4360
|
+
const group = getGroup(db, groupId);
|
|
4361
|
+
if (!group) {
|
|
4362
|
+
console.error(chalk.red(`
|
|
4363
|
+
Group not found: ${groupId}
|
|
4364
|
+
`));
|
|
4365
|
+
process.exit(1);
|
|
4366
|
+
}
|
|
4367
|
+
const contact = getContact(contactId);
|
|
4368
|
+
addContactToGroup(db, contactId, groupId);
|
|
4369
|
+
console.log(chalk.green(`
|
|
4370
|
+
\u2713 Added ${contact.display_name} to group ${group.name}
|
|
4371
|
+
`));
|
|
4372
|
+
});
|
|
4373
|
+
groupsCmd.command("remove-member <group-id> <contact-id>").description("Remove a contact from a group").action((groupId, contactId) => {
|
|
4374
|
+
const db = getDatabase();
|
|
4375
|
+
const group = getGroup(db, groupId);
|
|
4376
|
+
if (!group) {
|
|
4377
|
+
console.error(chalk.red(`
|
|
4378
|
+
Group not found: ${groupId}
|
|
4379
|
+
`));
|
|
4380
|
+
process.exit(1);
|
|
4381
|
+
}
|
|
4382
|
+
const contact = getContact(contactId);
|
|
4383
|
+
removeContactFromGroup(db, contactId, groupId);
|
|
4384
|
+
console.log(chalk.green(`
|
|
4385
|
+
\u2713 Removed ${contact.display_name} from group ${group.name}
|
|
4386
|
+
`));
|
|
4387
|
+
});
|
|
4388
|
+
program.command("init").description("Show setup info, stats, and configuration").action(() => {
|
|
4389
|
+
const dbPath = getDbPath();
|
|
4390
|
+
const config = readConfig();
|
|
4391
|
+
console.log(chalk.bold.blue(`
|
|
4392
|
+
\u2501\u2501\u2501 Open Contacts Setup \u2501\u2501\u2501
|
|
4393
|
+
`));
|
|
4394
|
+
console.log(chalk.gray(" DB path: ") + (config.db_path ?? dbPath));
|
|
4395
|
+
console.log();
|
|
4396
|
+
try {
|
|
4397
|
+
const db = getDatabase();
|
|
4398
|
+
const contactCount = db.query("SELECT COUNT(*) as n FROM contacts").get().n;
|
|
4399
|
+
const companyCount = db.query("SELECT COUNT(*) as n FROM companies").get().n;
|
|
4400
|
+
const tagCount = db.query("SELECT COUNT(*) as n FROM tags").get().n;
|
|
4401
|
+
console.log(chalk.bold(" Stats:"));
|
|
4402
|
+
console.log(` ${chalk.cyan(String(contactCount))} contacts`);
|
|
4403
|
+
console.log(` ${chalk.cyan(String(companyCount))} companies`);
|
|
4404
|
+
console.log(` ${chalk.cyan(String(tagCount))} tags`);
|
|
4405
|
+
} catch {
|
|
4406
|
+
console.log(chalk.gray(" (Database not yet initialized)"));
|
|
4407
|
+
}
|
|
4408
|
+
console.log();
|
|
4409
|
+
console.log(chalk.bold(" MCP Setup (Claude Code):"));
|
|
4410
|
+
console.log(" " + chalk.cyan("claude mcp add --transport stdio --scope user contacts -- contacts-mcp"));
|
|
4411
|
+
console.log();
|
|
4412
|
+
console.log(chalk.bold(" Shell Completion (zsh):"));
|
|
4413
|
+
console.log(" " + chalk.cyan("contacts completion zsh > ~/.zsh/completions/_contacts"));
|
|
4414
|
+
console.log(" " + chalk.cyan("contacts completion bash >> ~/.bashrc"));
|
|
4415
|
+
console.log(" " + chalk.cyan("contacts completion fish > ~/.config/fish/completions/contacts.fish"));
|
|
4416
|
+
console.log();
|
|
4417
|
+
});
|
|
4418
|
+
program.command("backup").description("Backup the contacts database").option("--output <path>", "Output path").option("--list", "List existing backups").action((opts) => {
|
|
4419
|
+
const backupDir = join4(process.env["HOME"] || "~", ".contacts", "backups");
|
|
4420
|
+
if (opts.list) {
|
|
4421
|
+
if (!existsSync4(backupDir)) {
|
|
4422
|
+
console.log(chalk.gray(`
|
|
4423
|
+
No backups found.
|
|
4424
|
+
`));
|
|
4425
|
+
return;
|
|
4426
|
+
}
|
|
4427
|
+
const files = readdirSync(backupDir).filter((f) => f.endsWith(".db")).sort().reverse();
|
|
4428
|
+
if (files.length === 0) {
|
|
4429
|
+
console.log(chalk.gray(`
|
|
4430
|
+
No backups found.
|
|
4431
|
+
`));
|
|
4432
|
+
return;
|
|
4433
|
+
}
|
|
4434
|
+
console.log(chalk.bold.blue(`
|
|
4435
|
+
Existing Backups:
|
|
4436
|
+
`));
|
|
4437
|
+
for (const f of files) {
|
|
4438
|
+
const filePath = join4(backupDir, f);
|
|
4439
|
+
const size2 = statSync(filePath).size;
|
|
4440
|
+
const mtime = statSync(filePath).mtime.toISOString().slice(0, 19).replace("T", " ");
|
|
4441
|
+
console.log(` ${chalk.cyan(f)} ${chalk.gray(`${(size2 / 1024).toFixed(1)} KB ${mtime}`)}`);
|
|
4442
|
+
}
|
|
4443
|
+
console.log();
|
|
4444
|
+
return;
|
|
4445
|
+
}
|
|
4446
|
+
const src = getDbPath();
|
|
4447
|
+
if (!existsSync4(src)) {
|
|
4448
|
+
console.error(chalk.red(`
|
|
4449
|
+
Database not found: ${src}
|
|
4450
|
+
`));
|
|
4451
|
+
process.exit(1);
|
|
4452
|
+
}
|
|
4453
|
+
mkdirSync3(backupDir, { recursive: true });
|
|
4454
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
4455
|
+
const dest = opts.output || join4(backupDir, `contacts-${ts}.db`);
|
|
4456
|
+
copyFileSync(src, dest);
|
|
4457
|
+
const size = statSync(dest).size;
|
|
4458
|
+
console.log(chalk.green(`
|
|
4459
|
+
\u2713 Backed up to ${dest} (${(size / 1024).toFixed(1)} KB)
|
|
4460
|
+
`));
|
|
4461
|
+
});
|
|
4462
|
+
program.command("completion <shell>").description("Generate shell completion script (bash, zsh, fish)").action((shell) => {
|
|
4463
|
+
const commands = [
|
|
4464
|
+
"add",
|
|
4465
|
+
"list",
|
|
4466
|
+
"show",
|
|
4467
|
+
"edit",
|
|
4468
|
+
"delete",
|
|
4469
|
+
"search",
|
|
4470
|
+
"recent",
|
|
4471
|
+
"dupe",
|
|
4472
|
+
"log",
|
|
4473
|
+
"open",
|
|
4474
|
+
"import",
|
|
4475
|
+
"export",
|
|
4476
|
+
"companies",
|
|
4477
|
+
"tags",
|
|
4478
|
+
"groups",
|
|
4479
|
+
"serve",
|
|
4480
|
+
"mcp",
|
|
4481
|
+
"init",
|
|
4482
|
+
"backup",
|
|
4483
|
+
"completion"
|
|
4484
|
+
];
|
|
4485
|
+
if (shell === "zsh") {
|
|
4486
|
+
console.log(`#compdef contacts
|
|
4487
|
+
_contacts() {
|
|
4488
|
+
local commands=(${commands.map((c) => `'${c}'`).join(" ")})
|
|
4489
|
+
_describe 'command' commands
|
|
4490
|
+
}
|
|
4491
|
+
_contacts "$@"`);
|
|
4492
|
+
} else if (shell === "bash") {
|
|
4493
|
+
console.log(`_contacts_completion() {
|
|
4494
|
+
local cur=\${COMP_WORDS[COMP_CWORD]}
|
|
4495
|
+
COMPREPLY=($(compgen -W "${commands.join(" ")}" -- "$cur"))
|
|
4496
|
+
}
|
|
4497
|
+
complete -F _contacts_completion contacts`);
|
|
4498
|
+
} else if (shell === "fish") {
|
|
4499
|
+
for (const c of commands) {
|
|
4500
|
+
console.log(`complete -c contacts -f -a ${c}`);
|
|
4501
|
+
}
|
|
4502
|
+
} else {
|
|
4503
|
+
console.error(chalk.red("Supported shells: bash, zsh, fish"));
|
|
4504
|
+
}
|
|
4505
|
+
});
|
|
3992
4506
|
program.parse(process.argv);
|