apple-tools-mcp 1.0.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 +21 -0
- package/README.md +163 -0
- package/contacts.js +433 -0
- package/index.js +1502 -0
- package/indexer.js +1849 -0
- package/lib/audit.js +1083 -0
- package/lib/shell.js +321 -0
- package/lib/validators.js +432 -0
- package/package.json +116 -0
- package/scripts/audit-index.js +94 -0
- package/search.js +1652 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Peter Coates
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# apple-tools-mcp
|
|
2
|
+
|
|
3
|
+
An MCP (Model Context Protocol) server that provides semantic search across Apple Mail, Messages, and Calendar on macOS. Use natural language to search your emails, iMessages, and calendar events directly from Claude.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Semantic Search**: Find emails, messages, and events using natural language queries
|
|
8
|
+
- **Vector Indexing**: Uses LanceDB for fast similarity search with local embeddings
|
|
9
|
+
- **Privacy-First**: All processing happens locally on your Mac - no data leaves your machine
|
|
10
|
+
- **Smart Deduplication**: Handles IMAP duplicates, prioritizing INBOX over Junk/Trash
|
|
11
|
+
- **Date Intelligence**: Understands queries like "last week", "yesterday", "March 2024"
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- **macOS** (Ventura 13.0 or later recommended)
|
|
16
|
+
- **Node.js** 18.0 or later
|
|
17
|
+
- **Claude Desktop** app
|
|
18
|
+
- **Full Disk Access** permission for the Node.js binary
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
### 1. Install the package
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install -g apple-tools-mcp
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### 2. Grant Full Disk Access
|
|
29
|
+
|
|
30
|
+
The MCP server needs access to read your Mail, Messages, and Calendar databases.
|
|
31
|
+
|
|
32
|
+
1. Open **System Settings** → **Privacy & Security** → **Full Disk Access**
|
|
33
|
+
2. Click the **+** button
|
|
34
|
+
3. Navigate to your Node.js binary:
|
|
35
|
+
- For Homebrew: `/opt/homebrew/bin/node`
|
|
36
|
+
- For nvm: `~/.nvm/versions/node/v[VERSION]/bin/node`
|
|
37
|
+
- To find yours: `which node`
|
|
38
|
+
4. Enable the toggle for Node.js
|
|
39
|
+
|
|
40
|
+
### 3. Configure Claude Desktop
|
|
41
|
+
|
|
42
|
+
Add to your Claude Desktop config file:
|
|
43
|
+
|
|
44
|
+
**Location:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"apple-tools": {
|
|
50
|
+
"command": "npx",
|
|
51
|
+
"args": ["-y", "apple-tools-mcp"]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 4. Restart Claude Desktop
|
|
58
|
+
|
|
59
|
+
Quit and reopen Claude Desktop to load the MCP server.
|
|
60
|
+
|
|
61
|
+
## Building the Index
|
|
62
|
+
|
|
63
|
+
On first use, the server will automatically build a vector index of your recent emails, messages, and calendar events. This may take a few minutes depending on the volume of data.
|
|
64
|
+
|
|
65
|
+
You can manually rebuild the index:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# Index last 30 days (default)
|
|
69
|
+
npx apple-tools-mcp build-index
|
|
70
|
+
|
|
71
|
+
# Index more history
|
|
72
|
+
APPLE_TOOLS_INDEX_DAYS_BACK=90 npx apple-tools-mcp build-index
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The index is stored in `~/.apple-tools-mcp/vector-index/`.
|
|
76
|
+
|
|
77
|
+
## Available Tools
|
|
78
|
+
|
|
79
|
+
Once configured, Claude can use these tools:
|
|
80
|
+
|
|
81
|
+
| Tool | Description |
|
|
82
|
+
|------|-------------|
|
|
83
|
+
| `search_emails` | Search emails by content, sender, subject |
|
|
84
|
+
| `search_messages` | Search iMessages and SMS |
|
|
85
|
+
| `search_calendar` | Search calendar events |
|
|
86
|
+
| `search_all` | Search across all sources |
|
|
87
|
+
| `get_email` | Get full email by ID |
|
|
88
|
+
| `get_message` | Get full message thread |
|
|
89
|
+
| `get_calendar_event` | Get event details |
|
|
90
|
+
|
|
91
|
+
## Example Queries
|
|
92
|
+
|
|
93
|
+
Ask Claude things like:
|
|
94
|
+
|
|
95
|
+
- "Find emails from John about the quarterly report"
|
|
96
|
+
- "What messages did I get from Mom last week?"
|
|
97
|
+
- "When is my next dentist appointment?"
|
|
98
|
+
- "Search for emails about the AWS bill from November"
|
|
99
|
+
- "Find all calendar events with Zoom links"
|
|
100
|
+
|
|
101
|
+
## Privacy & Security
|
|
102
|
+
|
|
103
|
+
- **Local Processing**: All embeddings are generated locally using Xenova/Transformers
|
|
104
|
+
- **No Cloud Services**: No data is sent to external servers
|
|
105
|
+
- **Read-Only**: The server only reads data, never modifies your Mail/Messages/Calendar
|
|
106
|
+
- **Your Data**: The vector index is stored locally in your home directory
|
|
107
|
+
|
|
108
|
+
## Troubleshooting
|
|
109
|
+
|
|
110
|
+
### "Authorization denied" errors
|
|
111
|
+
|
|
112
|
+
Ensure Node.js has Full Disk Access (see Installation step 2).
|
|
113
|
+
|
|
114
|
+
### Empty search results
|
|
115
|
+
|
|
116
|
+
1. Check that the index was built: `ls ~/.apple-tools-mcp/vector-index/`
|
|
117
|
+
2. Rebuild the index if needed: `npx apple-tools-mcp build-index`
|
|
118
|
+
|
|
119
|
+
### Server not appearing in Claude
|
|
120
|
+
|
|
121
|
+
1. Verify your config file syntax is valid JSON
|
|
122
|
+
2. Restart Claude Desktop completely (Cmd+Q, then reopen)
|
|
123
|
+
3. Check Claude's MCP logs for errors
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
# Clone the repo
|
|
129
|
+
git clone https://github.com/sfls1397/apple-tools-mcp.git
|
|
130
|
+
cd apple-tools-mcp
|
|
131
|
+
|
|
132
|
+
# Install dependencies
|
|
133
|
+
npm install
|
|
134
|
+
|
|
135
|
+
# Run tests
|
|
136
|
+
npm test
|
|
137
|
+
|
|
138
|
+
# Build index with debug output
|
|
139
|
+
npm run build-index
|
|
140
|
+
|
|
141
|
+
# Run audit to check index health
|
|
142
|
+
npm run audit
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Contributing
|
|
146
|
+
|
|
147
|
+
Contributions are welcome! Please:
|
|
148
|
+
|
|
149
|
+
1. Fork the repository
|
|
150
|
+
2. Create a feature branch
|
|
151
|
+
3. Make your changes
|
|
152
|
+
4. Run the tests: `npm test`
|
|
153
|
+
5. Submit a pull request
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
|
158
|
+
|
|
159
|
+
## Acknowledgments
|
|
160
|
+
|
|
161
|
+
- Built with the [Model Context Protocol SDK](https://github.com/modelcontextprotocol/sdk)
|
|
162
|
+
- Vector search powered by [LanceDB](https://lancedb.com/)
|
|
163
|
+
- Local embeddings via [Xenova/Transformers](https://github.com/xenova/transformers.js)
|
package/contacts.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contact Resolution Service
|
|
3
|
+
*
|
|
4
|
+
* Provides unified contact lookup across email addresses, phone numbers, and names.
|
|
5
|
+
* Uses macOS AddressBook database to resolve identifiers to full contact records.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { safeSqlite3, safeSqlite3Json } from "./lib/shell.js";
|
|
11
|
+
|
|
12
|
+
// Database location - iCloud synced contacts are in Sources subdirectory
|
|
13
|
+
const ADDRESSBOOK_DIR = path.join(process.env.HOME, "Library", "Application Support", "AddressBook");
|
|
14
|
+
const SOURCES_DIR = path.join(ADDRESSBOOK_DIR, "Sources");
|
|
15
|
+
|
|
16
|
+
// In-memory lookup maps for fast resolution
|
|
17
|
+
let contactsLoaded = false;
|
|
18
|
+
let contacts = []; // Array of all contact records
|
|
19
|
+
const emailToContact = new Map(); // email (lowercase) -> contact
|
|
20
|
+
const phoneToContact = new Map(); // normalized phone -> contact
|
|
21
|
+
const nameToContact = new Map(); // "firstname lastname" (lowercase) -> contact[]
|
|
22
|
+
|
|
23
|
+
// Cache settings
|
|
24
|
+
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
25
|
+
let lastLoadTime = 0;
|
|
26
|
+
|
|
27
|
+
// Memory bounds - prevent excessive memory usage with very large contact databases
|
|
28
|
+
const MAX_CONTACTS = 50000; // Maximum contacts to load
|
|
29
|
+
const MAX_EMAILS_PER_CONTACT = 10; // Maximum email addresses per contact
|
|
30
|
+
const MAX_PHONES_PER_CONTACT = 10; // Maximum phone numbers per contact
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Find the contacts database file (handles iCloud sync location)
|
|
34
|
+
*/
|
|
35
|
+
function findContactsDatabase() {
|
|
36
|
+
// First check Sources directory for iCloud-synced contacts
|
|
37
|
+
if (fs.existsSync(SOURCES_DIR)) {
|
|
38
|
+
const sources = fs.readdirSync(SOURCES_DIR);
|
|
39
|
+
for (const source of sources) {
|
|
40
|
+
const dbPath = path.join(SOURCES_DIR, source, "AddressBook-v22.abcddb");
|
|
41
|
+
if (fs.existsSync(dbPath)) {
|
|
42
|
+
// Check if it has actual data (not empty)
|
|
43
|
+
try {
|
|
44
|
+
const result = safeSqlite3(dbPath, "SELECT COUNT(*) FROM ZABCDRECORD", { json: false, timeout: 5000 });
|
|
45
|
+
const count = parseInt(result.trim());
|
|
46
|
+
if (count > 0) {
|
|
47
|
+
return dbPath;
|
|
48
|
+
}
|
|
49
|
+
} catch (e) {
|
|
50
|
+
// Continue to next source
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Fallback to main AddressBook database
|
|
57
|
+
const mainDb = path.join(ADDRESSBOOK_DIR, "AddressBook-v22.abcddb");
|
|
58
|
+
if (fs.existsSync(mainDb)) {
|
|
59
|
+
return mainDb;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Normalize phone number for consistent matching
|
|
67
|
+
* Strips all non-digit characters except leading +
|
|
68
|
+
*/
|
|
69
|
+
function normalizePhone(phone) {
|
|
70
|
+
if (!phone) return "";
|
|
71
|
+
// Keep leading + for international, then only digits
|
|
72
|
+
const hasPlus = phone.startsWith("+");
|
|
73
|
+
const digits = phone.replace(/\D/g, "");
|
|
74
|
+
// For US numbers, normalize to 10 digits (remove leading 1)
|
|
75
|
+
const wasUSNumber = digits.length === 11 && digits.startsWith("1");
|
|
76
|
+
const normalized = wasUSNumber
|
|
77
|
+
? digits.slice(1)
|
|
78
|
+
: digits;
|
|
79
|
+
// Only keep + if it wasn't a US number (didn't remove leading 1)
|
|
80
|
+
return (hasPlus && !wasUSNumber) ? `+${normalized}` : normalized;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Load all contacts from the AddressBook database
|
|
85
|
+
*/
|
|
86
|
+
export function loadContacts() {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
|
|
89
|
+
// Return cached if still valid
|
|
90
|
+
if (contactsLoaded && (now - lastLoadTime) < CACHE_TTL) {
|
|
91
|
+
return contacts;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const dbPath = findContactsDatabase();
|
|
95
|
+
if (!dbPath) {
|
|
96
|
+
console.error("Contacts database not found");
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
// Query all contacts with their basic info (with limit for memory safety)
|
|
102
|
+
const contactQuery = `
|
|
103
|
+
SELECT
|
|
104
|
+
Z_PK as id,
|
|
105
|
+
ZFIRSTNAME as firstName,
|
|
106
|
+
ZLASTNAME as lastName,
|
|
107
|
+
ZNICKNAME as nickname,
|
|
108
|
+
ZORGANIZATION as organization,
|
|
109
|
+
ZDEPARTMENT as department,
|
|
110
|
+
ZJOBTITLE as jobTitle
|
|
111
|
+
FROM ZABCDRECORD
|
|
112
|
+
WHERE ZFIRSTNAME IS NOT NULL OR ZLASTNAME IS NOT NULL OR ZORGANIZATION IS NOT NULL
|
|
113
|
+
LIMIT ${MAX_CONTACTS}
|
|
114
|
+
`;
|
|
115
|
+
|
|
116
|
+
const rawContacts = safeSqlite3Json(dbPath, contactQuery, { timeout: 10000 });
|
|
117
|
+
|
|
118
|
+
if (rawContacts.length >= MAX_CONTACTS) {
|
|
119
|
+
console.error(`Warning: Contact limit reached (${MAX_CONTACTS}). Some contacts may not be searchable.`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Query all email addresses
|
|
123
|
+
const emailQuery = `
|
|
124
|
+
SELECT
|
|
125
|
+
ZOWNER as contactId,
|
|
126
|
+
ZADDRESS as email,
|
|
127
|
+
ZLABEL as label
|
|
128
|
+
FROM ZABCDEMAILADDRESS
|
|
129
|
+
WHERE ZADDRESS IS NOT NULL
|
|
130
|
+
`;
|
|
131
|
+
|
|
132
|
+
const emails = safeSqlite3Json(dbPath, emailQuery, { timeout: 10000 });
|
|
133
|
+
|
|
134
|
+
// Query all phone numbers
|
|
135
|
+
const phoneQuery = `
|
|
136
|
+
SELECT
|
|
137
|
+
ZOWNER as contactId,
|
|
138
|
+
ZFULLNUMBER as phone,
|
|
139
|
+
ZLABEL as label
|
|
140
|
+
FROM ZABCDPHONENUMBER
|
|
141
|
+
WHERE ZFULLNUMBER IS NOT NULL
|
|
142
|
+
`;
|
|
143
|
+
|
|
144
|
+
const phones = safeSqlite3Json(dbPath, phoneQuery, { timeout: 10000 });
|
|
145
|
+
|
|
146
|
+
// Group emails and phones by contact ID (with per-contact limits)
|
|
147
|
+
const emailsByContact = new Map();
|
|
148
|
+
for (const e of emails) {
|
|
149
|
+
if (!emailsByContact.has(e.contactId)) {
|
|
150
|
+
emailsByContact.set(e.contactId, []);
|
|
151
|
+
}
|
|
152
|
+
const contactEmails = emailsByContact.get(e.contactId);
|
|
153
|
+
// Limit emails per contact to prevent memory issues
|
|
154
|
+
if (contactEmails.length < MAX_EMAILS_PER_CONTACT) {
|
|
155
|
+
contactEmails.push({
|
|
156
|
+
email: e.email,
|
|
157
|
+
label: e.label || "other"
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const phonesByContact = new Map();
|
|
163
|
+
for (const p of phones) {
|
|
164
|
+
if (!phonesByContact.has(p.contactId)) {
|
|
165
|
+
phonesByContact.set(p.contactId, []);
|
|
166
|
+
}
|
|
167
|
+
const contactPhones = phonesByContact.get(p.contactId);
|
|
168
|
+
// Limit phones per contact to prevent memory issues
|
|
169
|
+
if (contactPhones.length < MAX_PHONES_PER_CONTACT) {
|
|
170
|
+
contactPhones.push({
|
|
171
|
+
phone: p.phone,
|
|
172
|
+
normalized: normalizePhone(p.phone),
|
|
173
|
+
label: p.label || "other"
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Build complete contact records and lookup maps
|
|
179
|
+
contacts = [];
|
|
180
|
+
emailToContact.clear();
|
|
181
|
+
phoneToContact.clear();
|
|
182
|
+
nameToContact.clear();
|
|
183
|
+
|
|
184
|
+
for (const c of rawContacts) {
|
|
185
|
+
const contact = {
|
|
186
|
+
id: c.id,
|
|
187
|
+
firstName: c.firstName || "",
|
|
188
|
+
lastName: c.lastName || "",
|
|
189
|
+
nickname: c.nickname || "",
|
|
190
|
+
organization: c.organization || "",
|
|
191
|
+
department: c.department || "",
|
|
192
|
+
jobTitle: c.jobTitle || "",
|
|
193
|
+
emails: emailsByContact.get(c.id) || [],
|
|
194
|
+
phones: phonesByContact.get(c.id) || [],
|
|
195
|
+
displayName: formatDisplayName(c)
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
contacts.push(contact);
|
|
199
|
+
|
|
200
|
+
// Build email lookup
|
|
201
|
+
for (const e of contact.emails) {
|
|
202
|
+
const emailLower = e.email.toLowerCase();
|
|
203
|
+
emailToContact.set(emailLower, contact);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Build phone lookup (using normalized phone)
|
|
207
|
+
for (const p of contact.phones) {
|
|
208
|
+
if (p.normalized) {
|
|
209
|
+
phoneToContact.set(p.normalized, contact);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Build name lookup
|
|
214
|
+
const fullName = `${contact.firstName} ${contact.lastName}`.trim().toLowerCase();
|
|
215
|
+
if (fullName) {
|
|
216
|
+
if (!nameToContact.has(fullName)) {
|
|
217
|
+
nameToContact.set(fullName, []);
|
|
218
|
+
}
|
|
219
|
+
nameToContact.get(fullName).push(contact);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Also index by first name only for fuzzy matching
|
|
223
|
+
if (contact.firstName) {
|
|
224
|
+
const firstLower = contact.firstName.toLowerCase();
|
|
225
|
+
if (!nameToContact.has(firstLower)) {
|
|
226
|
+
nameToContact.set(firstLower, []);
|
|
227
|
+
}
|
|
228
|
+
nameToContact.get(firstLower).push(contact);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Index by nickname
|
|
232
|
+
if (contact.nickname) {
|
|
233
|
+
const nickLower = contact.nickname.toLowerCase();
|
|
234
|
+
if (!nameToContact.has(nickLower)) {
|
|
235
|
+
nameToContact.set(nickLower, []);
|
|
236
|
+
}
|
|
237
|
+
nameToContact.get(nickLower).push(contact);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
contactsLoaded = true;
|
|
242
|
+
lastLoadTime = now;
|
|
243
|
+
console.error(`Contacts: Loaded ${contacts.length} contacts with ${emailToContact.size} emails and ${phoneToContact.size} phones`);
|
|
244
|
+
|
|
245
|
+
return contacts;
|
|
246
|
+
} catch (e) {
|
|
247
|
+
console.error("Error loading contacts:", e.message);
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Format display name from contact record
|
|
254
|
+
*/
|
|
255
|
+
function formatDisplayName(contact) {
|
|
256
|
+
const parts = [];
|
|
257
|
+
if (contact.firstName) parts.push(contact.firstName);
|
|
258
|
+
if (contact.lastName) parts.push(contact.lastName);
|
|
259
|
+
if (parts.length === 0 && contact.organization) {
|
|
260
|
+
return contact.organization;
|
|
261
|
+
}
|
|
262
|
+
return parts.join(" ");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Resolve an email address to a contact record
|
|
267
|
+
* @param {string} email - Email address to look up
|
|
268
|
+
* @returns {object|null} Contact record or null if not found
|
|
269
|
+
*/
|
|
270
|
+
export function resolveEmail(email) {
|
|
271
|
+
if (!email) return null;
|
|
272
|
+
loadContacts(); // Ensure contacts are loaded
|
|
273
|
+
return emailToContact.get(email.toLowerCase()) || null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Resolve a phone number to a contact record
|
|
278
|
+
* @param {string} phone - Phone number to look up (any format)
|
|
279
|
+
* @returns {object|null} Contact record or null if not found
|
|
280
|
+
*/
|
|
281
|
+
export function resolvePhone(phone) {
|
|
282
|
+
if (!phone) return null;
|
|
283
|
+
loadContacts(); // Ensure contacts are loaded
|
|
284
|
+
const normalized = normalizePhone(phone);
|
|
285
|
+
return phoneToContact.get(normalized) || null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Resolve a name to matching contact records (fuzzy match)
|
|
290
|
+
* @param {string} name - Name to search for
|
|
291
|
+
* @returns {object[]} Array of matching contacts (may be empty)
|
|
292
|
+
*/
|
|
293
|
+
export function resolveByName(name) {
|
|
294
|
+
if (!name) return [];
|
|
295
|
+
loadContacts(); // Ensure contacts are loaded
|
|
296
|
+
|
|
297
|
+
const nameLower = name.toLowerCase().trim();
|
|
298
|
+
|
|
299
|
+
// Exact match first
|
|
300
|
+
const exact = nameToContact.get(nameLower);
|
|
301
|
+
if (exact && exact.length > 0) {
|
|
302
|
+
return exact;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Partial match - search all contacts
|
|
306
|
+
const matches = [];
|
|
307
|
+
for (const contact of contacts) {
|
|
308
|
+
const fullName = `${contact.firstName} ${contact.lastName}`.toLowerCase();
|
|
309
|
+
const orgName = contact.organization?.toLowerCase() || "";
|
|
310
|
+
const nick = contact.nickname?.toLowerCase() || "";
|
|
311
|
+
|
|
312
|
+
if (fullName.includes(nameLower) ||
|
|
313
|
+
orgName.includes(nameLower) ||
|
|
314
|
+
nick.includes(nameLower) ||
|
|
315
|
+
nameLower.includes(contact.firstName?.toLowerCase() || "---") ||
|
|
316
|
+
nameLower.includes(contact.lastName?.toLowerCase() || "---")) {
|
|
317
|
+
matches.push(contact);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return matches;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Get all identifiers (emails and phones) for a contact
|
|
326
|
+
* @param {number} contactId - Contact ID
|
|
327
|
+
* @returns {object} { emails: string[], phones: string[] }
|
|
328
|
+
*/
|
|
329
|
+
export function getContactIdentifiers(contactId) {
|
|
330
|
+
loadContacts();
|
|
331
|
+
const contact = contacts.find(c => c.id === contactId);
|
|
332
|
+
if (!contact) {
|
|
333
|
+
return { emails: [], phones: [] };
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
emails: contact.emails.map(e => e.email),
|
|
337
|
+
phones: contact.phones.map(p => p.phone)
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Search contacts by query (name, email, phone, or organization)
|
|
343
|
+
* @param {string} query - Search query
|
|
344
|
+
* @param {number} limit - Maximum results
|
|
345
|
+
* @returns {object[]} Matching contacts
|
|
346
|
+
*/
|
|
347
|
+
export function searchContacts(query, limit = 30) {
|
|
348
|
+
loadContacts();
|
|
349
|
+
|
|
350
|
+
if (!query) {
|
|
351
|
+
return contacts.slice(0, limit);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const queryLower = query.toLowerCase();
|
|
355
|
+
const matches = [];
|
|
356
|
+
|
|
357
|
+
for (const contact of contacts) {
|
|
358
|
+
// Check all searchable fields
|
|
359
|
+
const searchText = [
|
|
360
|
+
contact.firstName,
|
|
361
|
+
contact.lastName,
|
|
362
|
+
contact.nickname,
|
|
363
|
+
contact.organization,
|
|
364
|
+
contact.department,
|
|
365
|
+
contact.jobTitle,
|
|
366
|
+
...contact.emails.map(e => e.email),
|
|
367
|
+
...contact.phones.map(p => p.phone)
|
|
368
|
+
].filter(Boolean).join(" ").toLowerCase();
|
|
369
|
+
|
|
370
|
+
if (searchText.includes(queryLower)) {
|
|
371
|
+
matches.push(contact);
|
|
372
|
+
if (matches.length >= limit) break;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return matches;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Look up a contact by any identifier (email, phone, or name)
|
|
381
|
+
* @param {string} identifier - Email, phone, or name
|
|
382
|
+
* @returns {object|null} Contact record or null
|
|
383
|
+
*/
|
|
384
|
+
export function lookupContact(identifier) {
|
|
385
|
+
if (!identifier) return null;
|
|
386
|
+
|
|
387
|
+
// Try email first (most specific)
|
|
388
|
+
let contact = resolveEmail(identifier);
|
|
389
|
+
if (contact) return contact;
|
|
390
|
+
|
|
391
|
+
// Try phone
|
|
392
|
+
contact = resolvePhone(identifier);
|
|
393
|
+
if (contact) return contact;
|
|
394
|
+
|
|
395
|
+
// Try name (return first match)
|
|
396
|
+
const nameMatches = resolveByName(identifier);
|
|
397
|
+
if (nameMatches.length > 0) {
|
|
398
|
+
return nameMatches[0];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Format contact for display
|
|
406
|
+
* @param {object} contact - Contact record
|
|
407
|
+
* @returns {string} Formatted contact string
|
|
408
|
+
*/
|
|
409
|
+
export function formatContact(contact) {
|
|
410
|
+
if (!contact) return "Unknown";
|
|
411
|
+
|
|
412
|
+
let result = contact.displayName;
|
|
413
|
+
if (contact.organization && contact.displayName !== contact.organization) {
|
|
414
|
+
result += ` (${contact.organization})`;
|
|
415
|
+
}
|
|
416
|
+
return result;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Get contact stats
|
|
421
|
+
* @returns {object} Stats about loaded contacts
|
|
422
|
+
*/
|
|
423
|
+
export function getContactStats() {
|
|
424
|
+
loadContacts();
|
|
425
|
+
return {
|
|
426
|
+
total: contacts.length,
|
|
427
|
+
totalContacts: contacts.length,
|
|
428
|
+
withEmail: [...emailToContact.values()].length,
|
|
429
|
+
withPhone: [...phoneToContact.values()].length,
|
|
430
|
+
uniqueEmails: emailToContact.size,
|
|
431
|
+
uniquePhones: phoneToContact.size
|
|
432
|
+
};
|
|
433
|
+
}
|