mcp-icloud 2.1.1
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/DISCLOSURE +35 -0
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/auth/index.js +175 -0
- package/calendar/caldav-client.js +320 -0
- package/calendar/index.js +252 -0
- package/calendar/local-client.js +223 -0
- package/config.js +69 -0
- package/contacts/carddav-client.js +331 -0
- package/contacts/index.js +350 -0
- package/contacts/local-client.js +372 -0
- package/email/imap-client.js +351 -0
- package/email/index.js +278 -0
- package/email/local-client.js +257 -0
- package/email/smtp-client.js +79 -0
- package/index.js +123 -0
- package/messages/index.js +157 -0
- package/messages/local-client.js +74 -0
- package/mode.js +82 -0
- package/notes/index.js +162 -0
- package/notes/local-client.js +203 -0
- package/package.json +76 -0
- package/reminders/index.js +215 -0
- package/reminders/local-client.js +269 -0
- package/safari/index.js +122 -0
- package/safari/local-client.js +156 -0
- package/utils/applescript.js +214 -0
- package/utils/date-utils.js +72 -0
- package/utils/error-handler.js +71 -0
- package/utils/schemas.js +33 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Calendar Client
|
|
3
|
+
* Accesses Calendar.app via AppleScript
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { runAppleScript, runJXA, escapeAppleScript, escapeJXA, asInt, formatAppleScriptDate } = require('../utils/applescript');
|
|
7
|
+
const config = require('../config');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* List upcoming events from every calendar
|
|
11
|
+
* @param {number} count - Number of events to retrieve
|
|
12
|
+
* @param {number} daysAhead - Number of days to look ahead
|
|
13
|
+
* @returns {Promise<Array>} - List of events
|
|
14
|
+
*/
|
|
15
|
+
async function listEvents(count = 25, daysAhead = 30) {
|
|
16
|
+
const now = new Date();
|
|
17
|
+
const future = new Date(now.getTime() + asInt(daysAhead, 30) * 24 * 60 * 60 * 1000);
|
|
18
|
+
|
|
19
|
+
// Batch property access: per-event JXA calls trigger AppleEvent errors.
|
|
20
|
+
// The window is filtered inside the script so a busy calendar cannot push
|
|
21
|
+
// upcoming events out of the result set before we ever see them.
|
|
22
|
+
const script = `
|
|
23
|
+
const calendar = Application('Calendar');
|
|
24
|
+
const cals = calendar.calendars;
|
|
25
|
+
const calNames = cals.name();
|
|
26
|
+
const rangeStart = new Date(${JSON.stringify(now.toISOString())});
|
|
27
|
+
const rangeEnd = new Date(${JSON.stringify(future.toISOString())});
|
|
28
|
+
let allEvents = [];
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < calNames.length; i++) {
|
|
31
|
+
try {
|
|
32
|
+
// Filter the window app-side: reading every property of every event
|
|
33
|
+
// of every calendar transfers whole subscribed-calendar histories
|
|
34
|
+
// and hangs on real accounts.
|
|
35
|
+
const evts = cals[i].events.whose({
|
|
36
|
+
_and: [
|
|
37
|
+
{ startDate: { _greaterThan: rangeStart } },
|
|
38
|
+
{ startDate: { _lessThan: rangeEnd } }
|
|
39
|
+
]
|
|
40
|
+
});
|
|
41
|
+
const uids = evts.uid();
|
|
42
|
+
const summaries = evts.summary();
|
|
43
|
+
const starts = evts.startDate();
|
|
44
|
+
const ends = evts.endDate();
|
|
45
|
+
const locations = evts.location();
|
|
46
|
+
|
|
47
|
+
for (let j = 0; j < uids.length; j++) {
|
|
48
|
+
const start = starts[j];
|
|
49
|
+
if (!start) continue;
|
|
50
|
+
if (start < rangeStart || start > rangeEnd) continue;
|
|
51
|
+
|
|
52
|
+
allEvents.push({
|
|
53
|
+
id: uids[j],
|
|
54
|
+
summary: summaries[j] || '',
|
|
55
|
+
startDate: start.toISOString(),
|
|
56
|
+
endDate: ends[j] ? ends[j].toISOString() : null,
|
|
57
|
+
location: locations[j] || '',
|
|
58
|
+
calendar: calNames[i]
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
} catch (e) {
|
|
62
|
+
// One unreadable calendar must not hide the rest, but say which.
|
|
63
|
+
console.log('calendar skipped: ' + calNames[i] + ' (' + e + ')');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
JSON.stringify(allEvents);
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
const result = await runJXA(script);
|
|
71
|
+
if (!result) return [];
|
|
72
|
+
|
|
73
|
+
const events = JSON.parse(result);
|
|
74
|
+
events.sort((a, b) => new Date(a.startDate) - new Date(b.startDate));
|
|
75
|
+
return events.slice(0, asInt(count, 25));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* List all calendars
|
|
80
|
+
* @returns {Promise<Array>} - List of calendars
|
|
81
|
+
*/
|
|
82
|
+
async function listCalendars() {
|
|
83
|
+
// Use batch property access - JXA iteration causes AppleEvent errors
|
|
84
|
+
const script = `
|
|
85
|
+
const calendar = Application('Calendar');
|
|
86
|
+
const cals = calendar.calendars;
|
|
87
|
+
const names = cals.name();
|
|
88
|
+
const writables = cals.writable();
|
|
89
|
+
|
|
90
|
+
let result = [];
|
|
91
|
+
for (let i = 0; i < names.length; i++) {
|
|
92
|
+
result.push({
|
|
93
|
+
name: names[i],
|
|
94
|
+
id: names[i], // Use name as ID since cal.id() causes errors
|
|
95
|
+
writable: writables[i]
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
JSON.stringify(result);
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
const result = await runJXA(script);
|
|
103
|
+
return result ? JSON.parse(result) : [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create a new event
|
|
108
|
+
* @param {Object} options - Event options
|
|
109
|
+
* @returns {Promise<Object>} - Created event info
|
|
110
|
+
*/
|
|
111
|
+
async function createEvent({ summary, start, end, location, description, calendarName, allDay = false }) {
|
|
112
|
+
const startDate = new Date(start);
|
|
113
|
+
const endDate = new Date(end);
|
|
114
|
+
|
|
115
|
+
// Use default calendar if none specified
|
|
116
|
+
const targetCalendar = calendarName || 'Calendar';
|
|
117
|
+
|
|
118
|
+
if (allDay) {
|
|
119
|
+
// All-day event
|
|
120
|
+
const script = `
|
|
121
|
+
tell application "Calendar"
|
|
122
|
+
tell calendar "${escapeAppleScript(targetCalendar)}"
|
|
123
|
+
set newEvent to make new event with properties {summary:"${escapeAppleScript(summary)}", start date:date "${formatAppleScriptDate(startDate)}", end date:date "${formatAppleScriptDate(endDate)}", allday event:true}
|
|
124
|
+
${location ? `set location of newEvent to "${escapeAppleScript(location)}"` : ''}
|
|
125
|
+
${description ? `set description of newEvent to "${escapeAppleScript(description)}"` : ''}
|
|
126
|
+
return uid of newEvent
|
|
127
|
+
end tell
|
|
128
|
+
end tell
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
const uid = await runAppleScript(script);
|
|
132
|
+
return { success: true, id: uid, message: 'Event created successfully' };
|
|
133
|
+
} else {
|
|
134
|
+
const script = `
|
|
135
|
+
tell application "Calendar"
|
|
136
|
+
tell calendar "${escapeAppleScript(targetCalendar)}"
|
|
137
|
+
set newEvent to make new event with properties {summary:"${escapeAppleScript(summary)}", start date:date "${formatAppleScriptDate(startDate)}", end date:date "${formatAppleScriptDate(endDate)}"}
|
|
138
|
+
${location ? `set location of newEvent to "${escapeAppleScript(location)}"` : ''}
|
|
139
|
+
${description ? `set description of newEvent to "${escapeAppleScript(description)}"` : ''}
|
|
140
|
+
return uid of newEvent
|
|
141
|
+
end tell
|
|
142
|
+
end tell
|
|
143
|
+
`;
|
|
144
|
+
|
|
145
|
+
const uid = await runAppleScript(script);
|
|
146
|
+
return { success: true, id: uid, message: 'Event created successfully' };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Update an existing event
|
|
152
|
+
* @param {string} eventId - Event UID
|
|
153
|
+
* @param {Object} updates - Fields to update
|
|
154
|
+
* @returns {Promise<Object>} - Result
|
|
155
|
+
*/
|
|
156
|
+
async function updateEvent(eventId, { summary, start, end, location, description }) {
|
|
157
|
+
let updateCommands = [];
|
|
158
|
+
|
|
159
|
+
if (summary) updateCommands.push(`set summary of theEvent to "${escapeAppleScript(summary)}"`);
|
|
160
|
+
if (start) updateCommands.push(`set start date of theEvent to date "${formatAppleScriptDate(new Date(start))}"`);
|
|
161
|
+
if (end) updateCommands.push(`set end date of theEvent to date "${formatAppleScriptDate(new Date(end))}"`);
|
|
162
|
+
if (location !== undefined) updateCommands.push(`set location of theEvent to "${escapeAppleScript(location || '')}"`);
|
|
163
|
+
if (description !== undefined) updateCommands.push(`set description of theEvent to "${escapeAppleScript(description || '')}"`);
|
|
164
|
+
|
|
165
|
+
if (updateCommands.length === 0) {
|
|
166
|
+
return { success: false, message: 'No updates provided' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const script = `
|
|
170
|
+
tell application "Calendar"
|
|
171
|
+
set allCalendars to calendars
|
|
172
|
+
repeat with cal in allCalendars
|
|
173
|
+
try
|
|
174
|
+
set theEvent to first event of cal whose uid is "${escapeAppleScript(eventId)}"
|
|
175
|
+
${updateCommands.join('\n ')}
|
|
176
|
+
return "updated"
|
|
177
|
+
end try
|
|
178
|
+
end repeat
|
|
179
|
+
return "not found"
|
|
180
|
+
end tell
|
|
181
|
+
`;
|
|
182
|
+
|
|
183
|
+
const result = await runAppleScript(script);
|
|
184
|
+
if (result === 'not found') {
|
|
185
|
+
return { success: false, message: 'Event not found' };
|
|
186
|
+
}
|
|
187
|
+
return { success: true, message: 'Event updated successfully' };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Delete an event
|
|
192
|
+
* @param {string} eventId - Event UID
|
|
193
|
+
* @returns {Promise<Object>} - Result
|
|
194
|
+
*/
|
|
195
|
+
async function deleteEvent(eventId) {
|
|
196
|
+
const script = `
|
|
197
|
+
tell application "Calendar"
|
|
198
|
+
set allCalendars to calendars
|
|
199
|
+
repeat with cal in allCalendars
|
|
200
|
+
try
|
|
201
|
+
set theEvent to first event of cal whose uid is "${escapeAppleScript(eventId)}"
|
|
202
|
+
delete theEvent
|
|
203
|
+
return "deleted"
|
|
204
|
+
end try
|
|
205
|
+
end repeat
|
|
206
|
+
return "not found"
|
|
207
|
+
end tell
|
|
208
|
+
`;
|
|
209
|
+
|
|
210
|
+
const result = await runAppleScript(script);
|
|
211
|
+
if (result === 'not found') {
|
|
212
|
+
return { success: false, message: 'Event not found' };
|
|
213
|
+
}
|
|
214
|
+
return { success: true, message: 'Event deleted successfully' };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = {
|
|
218
|
+
listEvents,
|
|
219
|
+
listCalendars,
|
|
220
|
+
createEvent,
|
|
221
|
+
updateEvent,
|
|
222
|
+
deleteEvent
|
|
223
|
+
};
|
package/config.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iCloud MCP Configuration
|
|
3
|
+
* Centralized configuration for all iCloud services
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Load .env from the module dir, not the cwd — MCP clients like Claude
|
|
7
|
+
// Desktop launch the server from an arbitrary working directory (issue #3)
|
|
8
|
+
require('dotenv').config({ path: require('path').join(__dirname, '.env') });
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
// Mode flags
|
|
12
|
+
USE_TEST_MODE: process.env.USE_TEST_MODE === 'true',
|
|
13
|
+
USE_LOCAL_MODE: process.env.USE_LOCAL_MODE !== 'false', // Default to true (local mode)
|
|
14
|
+
|
|
15
|
+
// Check if running on macOS (required for local mode)
|
|
16
|
+
IS_MACOS: process.platform === 'darwin',
|
|
17
|
+
|
|
18
|
+
// iCloud credentials
|
|
19
|
+
ICLOUD_EMAIL: process.env.ICLOUD_EMAIL,
|
|
20
|
+
ICLOUD_APP_PASSWORD: process.env.ICLOUD_APP_PASSWORD,
|
|
21
|
+
|
|
22
|
+
// IMAP settings for iCloud Mail
|
|
23
|
+
IMAP: {
|
|
24
|
+
HOST: 'imap.mail.me.com',
|
|
25
|
+
PORT: 993,
|
|
26
|
+
TLS: true,
|
|
27
|
+
AUTH_TIMEOUT: 10000,
|
|
28
|
+
CONN_TIMEOUT: 30000
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
// SMTP settings for sending mail
|
|
32
|
+
SMTP: {
|
|
33
|
+
HOST: 'smtp.mail.me.com',
|
|
34
|
+
PORT: 587,
|
|
35
|
+
SECURE: false // Uses STARTTLS
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
// CalDAV settings for Calendar
|
|
39
|
+
CALDAV: {
|
|
40
|
+
SERVER_URL: 'https://caldav.icloud.com',
|
|
41
|
+
// Principal URL will be discovered during auth
|
|
42
|
+
AUTH_METHOD: 'Basic'
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
// CardDAV settings for Contacts
|
|
46
|
+
CARDDAV: {
|
|
47
|
+
SERVER_URL: 'https://contacts.icloud.com',
|
|
48
|
+
AUTH_METHOD: 'Basic'
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
// Default settings
|
|
52
|
+
DEFAULTS: {
|
|
53
|
+
TIMEZONE: 'Europe/Madrid',
|
|
54
|
+
PAGE_SIZE: 25,
|
|
55
|
+
MAX_RESULTS: 50,
|
|
56
|
+
EMAIL_BODY_MAX_LENGTH: 50000,
|
|
57
|
+
DATE_FORMAT: 'es-ES'
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
// Email folder mappings
|
|
61
|
+
EMAIL_FOLDERS: {
|
|
62
|
+
inbox: 'INBOX',
|
|
63
|
+
sent: 'Sent Messages',
|
|
64
|
+
drafts: 'Drafts',
|
|
65
|
+
trash: 'Deleted Messages',
|
|
66
|
+
archive: 'Archive',
|
|
67
|
+
junk: 'Junk'
|
|
68
|
+
}
|
|
69
|
+
};
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CardDAV client for iCloud Contacts
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { DAVClient } = require('tsdav');
|
|
6
|
+
const config = require('../config');
|
|
7
|
+
const { getCredentials } = require('../auth');
|
|
8
|
+
|
|
9
|
+
let cachedClient = null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Get or create CardDAV client
|
|
13
|
+
*/
|
|
14
|
+
async function getClient() {
|
|
15
|
+
if (cachedClient) {
|
|
16
|
+
return cachedClient;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const creds = getCredentials();
|
|
20
|
+
|
|
21
|
+
const client = new DAVClient({
|
|
22
|
+
serverUrl: config.CARDDAV.SERVER_URL,
|
|
23
|
+
credentials: {
|
|
24
|
+
username: creds.email,
|
|
25
|
+
password: creds.password
|
|
26
|
+
},
|
|
27
|
+
authMethod: 'Basic',
|
|
28
|
+
defaultAccountType: 'carddav'
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
await client.login();
|
|
33
|
+
cachedClient = client;
|
|
34
|
+
return client;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error.message?.includes('401') || error.message?.includes('auth')) {
|
|
37
|
+
throw new Error('UNAUTHORIZED');
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Clear cached client
|
|
45
|
+
*/
|
|
46
|
+
function clearClient() {
|
|
47
|
+
cachedClient = null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse vCard to simple object
|
|
52
|
+
*/
|
|
53
|
+
function parseVCard(vcardData, url) {
|
|
54
|
+
try {
|
|
55
|
+
const contact = {
|
|
56
|
+
url,
|
|
57
|
+
uid: '',
|
|
58
|
+
displayName: '',
|
|
59
|
+
firstName: '',
|
|
60
|
+
lastName: '',
|
|
61
|
+
emails: [],
|
|
62
|
+
phones: [],
|
|
63
|
+
organization: '',
|
|
64
|
+
title: '',
|
|
65
|
+
notes: ''
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const lines = vcardData.split(/\r?\n/);
|
|
69
|
+
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
const colonIndex = line.indexOf(':');
|
|
72
|
+
if (colonIndex === -1) continue;
|
|
73
|
+
|
|
74
|
+
const key = line.substring(0, colonIndex).toUpperCase();
|
|
75
|
+
const value = line.substring(colonIndex + 1);
|
|
76
|
+
|
|
77
|
+
// Handle property parameters (like TYPE=WORK)
|
|
78
|
+
const keyParts = key.split(';');
|
|
79
|
+
const mainKey = keyParts[0];
|
|
80
|
+
|
|
81
|
+
switch (mainKey) {
|
|
82
|
+
case 'UID':
|
|
83
|
+
contact.uid = value;
|
|
84
|
+
break;
|
|
85
|
+
case 'FN':
|
|
86
|
+
contact.displayName = decodeVCardValue(value);
|
|
87
|
+
break;
|
|
88
|
+
case 'N':
|
|
89
|
+
const nameParts = value.split(';');
|
|
90
|
+
contact.lastName = decodeVCardValue(nameParts[0] || '');
|
|
91
|
+
contact.firstName = decodeVCardValue(nameParts[1] || '');
|
|
92
|
+
break;
|
|
93
|
+
case 'EMAIL':
|
|
94
|
+
const emailType = extractType(keyParts) || 'other';
|
|
95
|
+
contact.emails.push({ type: emailType, value: decodeVCardValue(value) });
|
|
96
|
+
break;
|
|
97
|
+
case 'TEL':
|
|
98
|
+
const phoneType = extractType(keyParts) || 'other';
|
|
99
|
+
contact.phones.push({ type: phoneType, value: decodeVCardValue(value) });
|
|
100
|
+
break;
|
|
101
|
+
case 'ORG':
|
|
102
|
+
contact.organization = decodeVCardValue(value.split(';')[0]);
|
|
103
|
+
break;
|
|
104
|
+
case 'TITLE':
|
|
105
|
+
contact.title = decodeVCardValue(value);
|
|
106
|
+
break;
|
|
107
|
+
case 'NOTE':
|
|
108
|
+
contact.notes = decodeVCardValue(value);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Use first/last name if no display name
|
|
114
|
+
if (!contact.displayName && (contact.firstName || contact.lastName)) {
|
|
115
|
+
contact.displayName = `${contact.firstName} ${contact.lastName}`.trim();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return contact;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error('Error parsing vCard:', error.message);
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Extract TYPE parameter from vCard property
|
|
127
|
+
*/
|
|
128
|
+
function extractType(keyParts) {
|
|
129
|
+
for (const part of keyParts) {
|
|
130
|
+
if (part.startsWith('TYPE=')) {
|
|
131
|
+
return part.substring(5).toLowerCase();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Decode vCard escaped value
|
|
139
|
+
*/
|
|
140
|
+
function decodeVCardValue(value) {
|
|
141
|
+
if (!value) return '';
|
|
142
|
+
return value
|
|
143
|
+
.replace(/\\n/gi, '\n')
|
|
144
|
+
.replace(/\\,/g, ',')
|
|
145
|
+
.replace(/\\;/g, ';')
|
|
146
|
+
.replace(/\\\\/g, '\\');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Encode value for vCard
|
|
151
|
+
*/
|
|
152
|
+
function encodeVCardValue(value) {
|
|
153
|
+
if (!value) return '';
|
|
154
|
+
return value
|
|
155
|
+
.replace(/\\/g, '\\\\')
|
|
156
|
+
.replace(/;/g, '\\;')
|
|
157
|
+
.replace(/,/g, '\\,')
|
|
158
|
+
.replace(/\n/g, '\\n');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get all address books
|
|
163
|
+
*/
|
|
164
|
+
async function getAddressBooks() {
|
|
165
|
+
const client = await getClient();
|
|
166
|
+
const addressBooks = await client.fetchAddressBooks();
|
|
167
|
+
return addressBooks.map(ab => ({
|
|
168
|
+
url: ab.url,
|
|
169
|
+
displayName: ab.displayName || 'Contacts'
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Fetch every contact from every address book, sorted by display name
|
|
175
|
+
*/
|
|
176
|
+
async function fetchAllContacts() {
|
|
177
|
+
const client = await getClient();
|
|
178
|
+
const addressBooks = await client.fetchAddressBooks();
|
|
179
|
+
|
|
180
|
+
const allContacts = [];
|
|
181
|
+
|
|
182
|
+
for (const addressBook of addressBooks) {
|
|
183
|
+
try {
|
|
184
|
+
const vcards = await client.fetchVCards({ addressBook });
|
|
185
|
+
|
|
186
|
+
for (const vcard of vcards) {
|
|
187
|
+
const contact = parseVCard(vcard.data, vcard.url);
|
|
188
|
+
if (contact && contact.displayName) {
|
|
189
|
+
allContacts.push(contact);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
console.error(`Error fetching from address book:`, error.message);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Sort by display name
|
|
198
|
+
allContacts.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
199
|
+
|
|
200
|
+
return allContacts;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* List contacts
|
|
205
|
+
*/
|
|
206
|
+
async function listContacts(count = 25) {
|
|
207
|
+
const allContacts = await fetchAllContacts();
|
|
208
|
+
return allContacts.slice(0, count);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Search contacts
|
|
213
|
+
*/
|
|
214
|
+
async function searchContacts(query, count = 25) {
|
|
215
|
+
// Search the FULL contact list; capping before filtering missed anyone
|
|
216
|
+
// past the first `count * 2` in alphabetical order (issue behind PR #4/#7)
|
|
217
|
+
const allContacts = await fetchAllContacts();
|
|
218
|
+
const lowerQuery = query.toLowerCase();
|
|
219
|
+
|
|
220
|
+
const matches = allContacts.filter(contact => {
|
|
221
|
+
const searchText = [
|
|
222
|
+
contact.displayName,
|
|
223
|
+
contact.firstName,
|
|
224
|
+
contact.lastName,
|
|
225
|
+
contact.organization,
|
|
226
|
+
...contact.emails.map(e => e.value),
|
|
227
|
+
...contact.phones.map(p => p.value)
|
|
228
|
+
].join(' ').toLowerCase();
|
|
229
|
+
|
|
230
|
+
return searchText.includes(lowerQuery);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
return matches.slice(0, count);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Get contact by URL
|
|
238
|
+
*/
|
|
239
|
+
async function getContact(contactUrl) {
|
|
240
|
+
const client = await getClient();
|
|
241
|
+
|
|
242
|
+
const vcard = await client.fetchVCards({
|
|
243
|
+
addressBook: { url: contactUrl.substring(0, contactUrl.lastIndexOf('/') + 1) },
|
|
244
|
+
objectUrls: [contactUrl]
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
if (vcard && vcard[0]) {
|
|
248
|
+
return parseVCard(vcard[0].data, vcard[0].url);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
throw new Error('Contact not found');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Create a new contact
|
|
256
|
+
*/
|
|
257
|
+
async function createContact({ displayName, firstName, lastName, email, phone, organization, title, notes }) {
|
|
258
|
+
const client = await getClient();
|
|
259
|
+
const addressBooks = await client.fetchAddressBooks();
|
|
260
|
+
|
|
261
|
+
if (addressBooks.length === 0) {
|
|
262
|
+
throw new Error('No address book found');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const addressBook = addressBooks[0];
|
|
266
|
+
const uid = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
267
|
+
|
|
268
|
+
let vcard = `BEGIN:VCARD
|
|
269
|
+
VERSION:3.0
|
|
270
|
+
UID:${uid}
|
|
271
|
+
FN:${encodeVCardValue(displayName || `${firstName || ''} ${lastName || ''}`.trim())}
|
|
272
|
+
N:${encodeVCardValue(lastName || '')};${encodeVCardValue(firstName || '')};;;`;
|
|
273
|
+
|
|
274
|
+
if (email) {
|
|
275
|
+
vcard += `\nEMAIL;TYPE=INTERNET:${encodeVCardValue(email)}`;
|
|
276
|
+
}
|
|
277
|
+
if (phone) {
|
|
278
|
+
vcard += `\nTEL;TYPE=CELL:${encodeVCardValue(phone)}`;
|
|
279
|
+
}
|
|
280
|
+
if (organization) {
|
|
281
|
+
vcard += `\nORG:${encodeVCardValue(organization)}`;
|
|
282
|
+
}
|
|
283
|
+
if (title) {
|
|
284
|
+
vcard += `\nTITLE:${encodeVCardValue(title)}`;
|
|
285
|
+
}
|
|
286
|
+
if (notes) {
|
|
287
|
+
vcard += `\nNOTE:${encodeVCardValue(notes)}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
vcard += `\nEND:VCARD`;
|
|
291
|
+
|
|
292
|
+
const result = await client.createVCard({
|
|
293
|
+
addressBook,
|
|
294
|
+
filename: `${uid}.vcf`,
|
|
295
|
+
vCardString: vcard
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
success: true,
|
|
300
|
+
uid,
|
|
301
|
+
url: result?.url
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Delete a contact
|
|
307
|
+
*/
|
|
308
|
+
async function deleteContact(contactUrl) {
|
|
309
|
+
const client = await getClient();
|
|
310
|
+
|
|
311
|
+
await client.deleteVCard({
|
|
312
|
+
vCard: {
|
|
313
|
+
url: contactUrl,
|
|
314
|
+
etag: ''
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
return { success: true };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
module.exports = {
|
|
322
|
+
getClient,
|
|
323
|
+
clearClient,
|
|
324
|
+
getAddressBooks,
|
|
325
|
+
listContacts,
|
|
326
|
+
searchContacts,
|
|
327
|
+
getContact,
|
|
328
|
+
createContact,
|
|
329
|
+
deleteContact,
|
|
330
|
+
parseVCard
|
|
331
|
+
};
|