apple-tools-mcp 1.1.0 → 1.1.2
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/README.md +2 -2
- package/index.js +59 -48
- package/indexer.js +208 -58
- package/lib/shell.js +12 -5
- package/lib/validators.js +64 -2
- package/package.json +1 -1
- package/search.js +104 -38
package/README.md
CHANGED
|
@@ -137,10 +137,10 @@ Once configured, Claude can use these tools:
|
|
|
137
137
|
| Tool | Description |
|
|
138
138
|
|------|-------------|
|
|
139
139
|
| `calendar_search` | Semantic search for events with filters |
|
|
140
|
-
| `calendar_date` | Get events on a specific date |
|
|
140
|
+
| `calendar_date` | Get events on a specific date (live Calendar.app, not the search index) |
|
|
141
141
|
| `calendar_upcoming` | Get next N upcoming events |
|
|
142
142
|
| `calendar_week` | Get all events for current or future week |
|
|
143
|
-
| `calendar_free_time` | Find available time slots on a date |
|
|
143
|
+
| `calendar_free_time` | Find available time slots on a date (live Calendar.app, not the search index) |
|
|
144
144
|
| `calendar_recurring` | List recurring events |
|
|
145
145
|
|
|
146
146
|
### Contacts Tools
|
package/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
9
9
|
import fs from "fs";
|
|
10
10
|
import path from "path";
|
|
11
|
-
import { validateEmailPath, stripHtmlTags } from "./lib/validators.js";
|
|
11
|
+
import { validateEmailPath, stripHtmlTags, unfoldRfc822Headers, validateLimit, validateDaysBack, validateWeekOffset, toUnixMillis } from "./lib/validators.js";
|
|
12
12
|
|
|
13
13
|
// Lock file to prevent duplicate indexing processes
|
|
14
14
|
const LOCK_FILE = path.join(process.env.HOME, ".apple-tools-mcp", "indexer.lock");
|
|
@@ -234,12 +234,12 @@ function runIndexCycle() {
|
|
|
234
234
|
progressCheckTimer = setInterval(() => {
|
|
235
235
|
const timeSinceProgress = Date.now() - lastProgressTime;
|
|
236
236
|
if (timeSinceProgress > MAX_NO_PROGRESS_MS) {
|
|
237
|
-
console.error(`⚠️ No indexing progress for ${Math.round(timeSinceProgress / 60000)} minutes.
|
|
237
|
+
console.error(`⚠️ No indexing progress for ${Math.round(timeSinceProgress / 60000)} minutes. Indexing still running; not starting another cycle.`);
|
|
238
238
|
clearInterval(progressCheckTimer);
|
|
239
239
|
progressCheckTimer = null;
|
|
240
|
-
indexingInProgress
|
|
240
|
+
// Allow searches, but do NOT clear indexingInProgress or release the lock
|
|
241
|
+
// while indexAll() is still running — overlapping writes can corrupt LanceDB.
|
|
241
242
|
sessionIndexComplete = true;
|
|
242
|
-
releaseLock();
|
|
243
243
|
}
|
|
244
244
|
}, PROGRESS_CHECK_INTERVAL_MS);
|
|
245
245
|
|
|
@@ -436,29 +436,11 @@ async function calendarSearch(query, options = {}) {
|
|
|
436
436
|
}
|
|
437
437
|
|
|
438
438
|
async function calendarDate(date) {
|
|
439
|
-
if (!sessionIndexComplete) {
|
|
440
|
-
return getIndexingMessage();
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const ready = await isIndexReady("calendar");
|
|
444
|
-
if (!ready) {
|
|
445
|
-
return "Calendar index not available. Please try again shortly.";
|
|
446
|
-
}
|
|
447
|
-
|
|
448
439
|
const result = await getCalendarDateResults(date);
|
|
449
440
|
return formatCalendarResults(result);
|
|
450
441
|
}
|
|
451
442
|
|
|
452
443
|
async function calendarFreeTime(date, options = {}) {
|
|
453
|
-
if (!sessionIndexComplete) {
|
|
454
|
-
return getIndexingMessage();
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
const ready = await isIndexReady("calendar");
|
|
458
|
-
if (!ready) {
|
|
459
|
-
return "Calendar index not available. Please try again shortly.";
|
|
460
|
-
}
|
|
461
|
-
|
|
462
444
|
const result = await calculateFreeTime(date, options);
|
|
463
445
|
return formatFreeTimeResults(result);
|
|
464
446
|
}
|
|
@@ -482,7 +464,7 @@ function readFullEmail(filePath) {
|
|
|
482
464
|
return "Email file not found.";
|
|
483
465
|
}
|
|
484
466
|
|
|
485
|
-
const content = fs.readFileSync(validatedPath, 'utf-8');
|
|
467
|
+
const content = unfoldRfc822Headers(fs.readFileSync(validatedPath, 'utf-8'));
|
|
486
468
|
|
|
487
469
|
// Parse email headers and body
|
|
488
470
|
const fromMatch = content.match(/^From:\s*(.+)$/m);
|
|
@@ -583,7 +565,7 @@ function formatSmartSearchResults(results, synthesizedGroups = null) {
|
|
|
583
565
|
sections.push(` [${r.rank}] Score: ${r.score}`);
|
|
584
566
|
sections.push(` From: ${r.sender}${r.isGroupChat ? ' (Group)' : ''}`);
|
|
585
567
|
sections.push(` Date: ${r.date}`);
|
|
586
|
-
sections.push(` Text: ${r.text.substring(0, 100)}
|
|
568
|
+
sections.push(` Text: ${(r.text || "").substring(0, 100)}${(r.text || "").length > 100 ? "..." : ""}`);
|
|
587
569
|
}
|
|
588
570
|
sections.push("");
|
|
589
571
|
}
|
|
@@ -609,6 +591,10 @@ function formatSmartSearchResults(results, synthesizedGroups = null) {
|
|
|
609
591
|
|
|
610
592
|
// Smart search - routes to appropriate sources and optionally synthesizes results
|
|
611
593
|
async function smartSearch(query, options = {}) {
|
|
594
|
+
if (!sessionIndexComplete) {
|
|
595
|
+
return getIndexingMessage();
|
|
596
|
+
}
|
|
597
|
+
|
|
612
598
|
const { limit = 5, synthesize = true } = options;
|
|
613
599
|
|
|
614
600
|
const sources = detectSources(query);
|
|
@@ -689,21 +675,21 @@ function synthesizeResults(mailResults, messageResults, calendarResults) {
|
|
|
689
675
|
|
|
690
676
|
// Add mail results
|
|
691
677
|
for (const r of mailResults) {
|
|
692
|
-
const ts = r.dateTimestamp || new Date(r.date).getTime();
|
|
678
|
+
const ts = toUnixMillis(r.dateTimestamp) || new Date(r.date).getTime();
|
|
693
679
|
const bucket = getBucket(ts);
|
|
694
680
|
if (bucket) bucket.mail.push(r);
|
|
695
681
|
}
|
|
696
682
|
|
|
697
683
|
// Add message results
|
|
698
684
|
for (const r of messageResults) {
|
|
699
|
-
const ts = r.dateTimestamp || new Date(r.date).getTime();
|
|
685
|
+
const ts = toUnixMillis(r.dateTimestamp) || new Date(r.date).getTime();
|
|
700
686
|
const bucket = getBucket(ts);
|
|
701
687
|
if (bucket) bucket.messages.push(r);
|
|
702
688
|
}
|
|
703
689
|
|
|
704
690
|
// Add calendar results
|
|
705
691
|
for (const r of calendarResults) {
|
|
706
|
-
const ts = r.startTimestamp || new Date(r.start).getTime();
|
|
692
|
+
const ts = toUnixMillis(r.startTimestamp) || new Date(r.start).getTime();
|
|
707
693
|
const bucket = getBucket(ts);
|
|
708
694
|
if (bucket) bucket.calendar.push(r);
|
|
709
695
|
}
|
|
@@ -777,6 +763,10 @@ function formatContactLookupResult(contact) {
|
|
|
777
763
|
// ============ PERSON SEARCH (CROSS-SOURCE) ============
|
|
778
764
|
|
|
779
765
|
async function personSearch(name, limit = 10) {
|
|
766
|
+
if (!sessionIndexComplete) {
|
|
767
|
+
return getIndexingMessage();
|
|
768
|
+
}
|
|
769
|
+
|
|
780
770
|
// First, try to find the contact to get all their identifiers
|
|
781
771
|
const contacts = searchContacts(name, 5);
|
|
782
772
|
|
|
@@ -905,7 +895,7 @@ function formatPersonSearchResults(results) {
|
|
|
905
895
|
if (results.messages && results.messages.results && results.messages.results.length > 0) {
|
|
906
896
|
sections.push(`💬 MESSAGES (${results.messages.results.length}):`);
|
|
907
897
|
for (const r of results.messages.results.slice(0, 10)) {
|
|
908
|
-
sections.push(` • ${r.text.substring(0, 80)}${r.text.length > 80 ? "..." : ""}`);
|
|
898
|
+
sections.push(` • ${(r.text || "").substring(0, 80)}${(r.text || "").length > 80 ? "..." : ""}`);
|
|
909
899
|
sections.push(` ${r.date}`);
|
|
910
900
|
}
|
|
911
901
|
sections.push("");
|
|
@@ -1262,7 +1252,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1262
1252
|
// Smart search (agentic)
|
|
1263
1253
|
case "smart_search":
|
|
1264
1254
|
result = await smartSearch(args.query, {
|
|
1265
|
-
limit: args?.limit
|
|
1255
|
+
limit: validateLimit(args?.limit, 5, 100),
|
|
1266
1256
|
synthesize: args?.synthesize !== false
|
|
1267
1257
|
});
|
|
1268
1258
|
break;
|
|
@@ -1270,8 +1260,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1270
1260
|
// Email tools
|
|
1271
1261
|
case "mail_search":
|
|
1272
1262
|
result = await mailSearch(args.query, {
|
|
1273
|
-
limit: args?.limit
|
|
1274
|
-
daysBack: args?.days_back
|
|
1263
|
+
limit: validateLimit(args?.limit, 30),
|
|
1264
|
+
daysBack: validateDaysBack(args?.days_back),
|
|
1275
1265
|
sender: args?.sender || null,
|
|
1276
1266
|
recipient: args?.recipient || null,
|
|
1277
1267
|
hasAttachment: args?.has_attachment ?? null,
|
|
@@ -1284,7 +1274,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1284
1274
|
break;
|
|
1285
1275
|
|
|
1286
1276
|
case "mail_recent":
|
|
1287
|
-
result = await mailRecent(
|
|
1277
|
+
result = await mailRecent(
|
|
1278
|
+
validateLimit(args?.limit, 30),
|
|
1279
|
+
validateDaysBack(args?.days_back) || 7,
|
|
1280
|
+
args?.unread_only || false,
|
|
1281
|
+
args?.include_junk || false
|
|
1282
|
+
);
|
|
1288
1283
|
break;
|
|
1289
1284
|
|
|
1290
1285
|
case "mail_date":
|
|
@@ -1298,8 +1293,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1298
1293
|
// Messages tools
|
|
1299
1294
|
case "messages_search":
|
|
1300
1295
|
result = await messagesSearch(args.query, {
|
|
1301
|
-
limit: args?.limit
|
|
1302
|
-
daysBack: args?.days_back
|
|
1296
|
+
limit: validateLimit(args?.limit, 30),
|
|
1297
|
+
daysBack: validateDaysBack(args?.days_back),
|
|
1303
1298
|
contact: args?.contact || null,
|
|
1304
1299
|
groupChatOnly: args?.group_chat_only || false,
|
|
1305
1300
|
groupChatName: args?.group_chat_name || null,
|
|
@@ -1309,19 +1304,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1309
1304
|
break;
|
|
1310
1305
|
|
|
1311
1306
|
case "messages_recent":
|
|
1312
|
-
result = await messagesRecent(
|
|
1307
|
+
result = await messagesRecent(
|
|
1308
|
+
validateLimit(args?.limit, 30),
|
|
1309
|
+
validateDaysBack(args?.days_back) || 1
|
|
1310
|
+
);
|
|
1313
1311
|
break;
|
|
1314
1312
|
|
|
1315
1313
|
case "messages_conversation":
|
|
1316
|
-
result = await messagesConversation(args.contact, args?.limit
|
|
1314
|
+
result = await messagesConversation(args.contact, validateLimit(args?.limit, 50));
|
|
1317
1315
|
break;
|
|
1318
1316
|
|
|
1319
1317
|
// Calendar tools
|
|
1320
1318
|
case "calendar_search":
|
|
1321
1319
|
result = await calendarSearch(args.query, {
|
|
1322
|
-
limit: args?.limit
|
|
1323
|
-
daysBack: args?.days_back
|
|
1324
|
-
daysAhead: args?.days_ahead
|
|
1320
|
+
limit: validateLimit(args?.limit, 30),
|
|
1321
|
+
daysBack: validateDaysBack(args?.days_back),
|
|
1322
|
+
daysAhead: validateDaysBack(args?.days_ahead),
|
|
1325
1323
|
calendarName: args?.calendar_name || null,
|
|
1326
1324
|
allDayOnly: args?.all_day_only || false,
|
|
1327
1325
|
sortBy: args?.sort_by || "relevance"
|
|
@@ -1344,7 +1342,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1344
1342
|
|
|
1345
1343
|
// Mail tools
|
|
1346
1344
|
case "mail_senders":
|
|
1347
|
-
|
|
1345
|
+
if (!sessionIndexComplete) {
|
|
1346
|
+
result = getIndexingMessage();
|
|
1347
|
+
break;
|
|
1348
|
+
}
|
|
1349
|
+
result = formatSendersResults(await getFrequentSenders(
|
|
1350
|
+
validateLimit(args?.limit, 30),
|
|
1351
|
+
validateDaysBack(args?.days_back),
|
|
1352
|
+
args?.include_junk || false
|
|
1353
|
+
));
|
|
1348
1354
|
break;
|
|
1349
1355
|
|
|
1350
1356
|
case "rebuild_index":
|
|
@@ -1362,6 +1368,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1362
1368
|
|
|
1363
1369
|
// Start rebuild in background and return immediately
|
|
1364
1370
|
indexingInProgress = true;
|
|
1371
|
+
sessionIndexComplete = false;
|
|
1365
1372
|
const rebuildSources = args?.sources || ["emails", "messages", "calendar"];
|
|
1366
1373
|
|
|
1367
1374
|
// Fire and forget - don't await
|
|
@@ -1399,34 +1406,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1399
1406
|
|
|
1400
1407
|
// Messages tools
|
|
1401
1408
|
case "messages_contacts":
|
|
1402
|
-
result = formatMessageContactsResults(getMessageContacts(args?.limit
|
|
1409
|
+
result = formatMessageContactsResults(getMessageContacts(validateLimit(args?.limit, 50, 500)));
|
|
1403
1410
|
break;
|
|
1404
1411
|
|
|
1405
1412
|
// Calendar tools
|
|
1406
1413
|
case "calendar_upcoming":
|
|
1407
|
-
result = formatUpcomingEventsResults(getUpcomingEvents(args?.limit
|
|
1414
|
+
result = formatUpcomingEventsResults(getUpcomingEvents(validateLimit(args?.limit, 30, 100)));
|
|
1408
1415
|
break;
|
|
1409
1416
|
|
|
1410
1417
|
// ============ NEW TOOLS - PHASE 2 ============
|
|
1411
1418
|
|
|
1412
1419
|
case "calendar_week":
|
|
1413
|
-
result = formatWeekEventsResults(getWeekEvents(args?.week_offset
|
|
1420
|
+
result = formatWeekEventsResults(getWeekEvents(validateWeekOffset(args?.week_offset)));
|
|
1414
1421
|
break;
|
|
1415
1422
|
|
|
1416
1423
|
// ============ NEW TOOLS - PHASE 3 ============
|
|
1417
1424
|
|
|
1418
1425
|
case "mail_thread":
|
|
1419
|
-
|
|
1426
|
+
if (!sessionIndexComplete) {
|
|
1427
|
+
result = getIndexingMessage();
|
|
1428
|
+
break;
|
|
1429
|
+
}
|
|
1430
|
+
result = formatEmailThreadResults(await getEmailThread(args.file_path, validateLimit(args?.limit, 30)));
|
|
1420
1431
|
break;
|
|
1421
1432
|
|
|
1422
1433
|
case "calendar_recurring":
|
|
1423
|
-
result = formatRecurringEventsResults(getRecurringEvents(args?.limit
|
|
1434
|
+
result = formatRecurringEventsResults(getRecurringEvents(validateLimit(args?.limit, 30, 100)));
|
|
1424
1435
|
break;
|
|
1425
1436
|
|
|
1426
1437
|
// ============ CONTACTS TOOLS ============
|
|
1427
1438
|
|
|
1428
1439
|
case "contacts_search":
|
|
1429
|
-
result = formatContactsSearchResults(searchContacts(args.query, args?.limit
|
|
1440
|
+
result = formatContactsSearchResults(searchContacts(args.query, validateLimit(args?.limit, 30)));
|
|
1430
1441
|
break;
|
|
1431
1442
|
|
|
1432
1443
|
case "contacts_lookup":
|
|
@@ -1434,7 +1445,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1434
1445
|
break;
|
|
1435
1446
|
|
|
1436
1447
|
case "person_search":
|
|
1437
|
-
result = await personSearch(args.name, args?.limit
|
|
1448
|
+
result = await personSearch(args.name, validateLimit(args?.limit, 10));
|
|
1438
1449
|
break;
|
|
1439
1450
|
|
|
1440
1451
|
default:
|
|
@@ -1454,7 +1465,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1454
1465
|
async function main() {
|
|
1455
1466
|
const transport = new StdioServerTransport();
|
|
1456
1467
|
await server.connect(transport);
|
|
1457
|
-
console.error("Apple Tools MCP server running (v1.1.
|
|
1468
|
+
console.error("Apple Tools MCP server running (v1.1.2)");
|
|
1458
1469
|
// Background indexing runs automatically on startup and every INDEX_INTERVAL
|
|
1459
1470
|
}
|
|
1460
1471
|
|
package/indexer.js
CHANGED
|
@@ -9,7 +9,10 @@ import {
|
|
|
9
9
|
validateLimit,
|
|
10
10
|
validateLanceDBId,
|
|
11
11
|
escapeSQL,
|
|
12
|
-
stripHtmlTags
|
|
12
|
+
stripHtmlTags,
|
|
13
|
+
toUnixMillis,
|
|
14
|
+
unfoldRfc822Headers,
|
|
15
|
+
stripSubjectPrefixes
|
|
13
16
|
} from "./lib/validators.js";
|
|
14
17
|
import { safeSqlite3Json, safeOsascript, safeFind } from "./lib/shell.js";
|
|
15
18
|
|
|
@@ -63,6 +66,7 @@ const BATCH_DELAY_MS = 0; // No delay needed - benchmarks showed no thermal thro
|
|
|
63
66
|
|
|
64
67
|
// Mac Absolute Time epoch: Jan 1, 2001 00:00:00 UTC
|
|
65
68
|
const MAC_ABSOLUTE_EPOCH = 978307200;
|
|
69
|
+
export const CALENDAR_TMP_PREFIX = "apple-tools-cal-";
|
|
66
70
|
|
|
67
71
|
let embeddingPipeline = null;
|
|
68
72
|
let db = null;
|
|
@@ -251,14 +255,17 @@ function parseEmlx(filePath) {
|
|
|
251
255
|
content = lines.slice(1).join("\n");
|
|
252
256
|
}
|
|
253
257
|
|
|
258
|
+
// Unfold RFC 822 wrapped headers so long From/Subject/To values are complete
|
|
259
|
+
const unfolded = unfoldRfc822Headers(content);
|
|
260
|
+
|
|
254
261
|
// Extract headers
|
|
255
|
-
const fromMatch =
|
|
256
|
-
const subjectMatch =
|
|
257
|
-
const dateMatch =
|
|
258
|
-
const toMatch =
|
|
259
|
-
const ccMatch =
|
|
260
|
-
const messageIdMatch =
|
|
261
|
-
const flaggedMatch =
|
|
262
|
+
const fromMatch = unfolded.match(/^From:\s*(.+)$/m);
|
|
263
|
+
const subjectMatch = unfolded.match(/^Subject:\s*(.+)$/m);
|
|
264
|
+
const dateMatch = unfolded.match(/^Date:\s*(.+)$/m);
|
|
265
|
+
const toMatch = unfolded.match(/^To:\s*(.+)$/m);
|
|
266
|
+
const ccMatch = unfolded.match(/^Cc:\s*(.+)$/m);
|
|
267
|
+
const messageIdMatch = unfolded.match(/^Message-ID:\s*(.+)$/im);
|
|
268
|
+
const flaggedMatch = unfolded.match(/^X-Flagged:\s*(.+)$/im) || unfolded.match(/flags.*flagged/i);
|
|
262
269
|
|
|
263
270
|
// Check for attachments
|
|
264
271
|
const hasAttachment = /Content-Disposition:\s*attachment/i.test(content) ||
|
|
@@ -334,13 +341,10 @@ function parseEmlx(filePath) {
|
|
|
334
341
|
// Full scan - used for first run and rebuild_index
|
|
335
342
|
// Includes both .emlx and .partial.emlx files (partial = not fully downloaded via IMAP)
|
|
336
343
|
async function findAllEmlxFiles() {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
console.error("Error finding emlx files:", e.message);
|
|
342
|
-
return [];
|
|
343
|
-
}
|
|
344
|
+
// "*.emlx" also matches "*.partial.emlx"
|
|
345
|
+
// Let permission/IO errors propagate so indexEmails does not persist a
|
|
346
|
+
// timestamp for an empty scan and skip real mail forever.
|
|
347
|
+
return safeFind(MAIL_DIR, { name: "*.emlx", type: "f" });
|
|
344
348
|
}
|
|
345
349
|
|
|
346
350
|
// Fast incremental scan - uses find with -mtime filter (more reliable than mdfind/Spotlight)
|
|
@@ -578,6 +582,9 @@ function getMessages(sinceTimestamp = null) {
|
|
|
578
582
|
ORDER BY m.date DESC
|
|
579
583
|
`;
|
|
580
584
|
const results = safeSqlite3Json(MESSAGES_DB, query, { timeout: 60000 });
|
|
585
|
+
if (!Array.isArray(results)) {
|
|
586
|
+
throw new Error("Unexpected sqlite3 result for messages");
|
|
587
|
+
}
|
|
581
588
|
|
|
582
589
|
// Post-process: extract text from attributedBody where text is NULL
|
|
583
590
|
let extractedCount = 0;
|
|
@@ -606,7 +613,7 @@ function getMessages(sinceTimestamp = null) {
|
|
|
606
613
|
return results.filter(msg => msg.text && msg.text.trim() !== '');
|
|
607
614
|
} catch (e) {
|
|
608
615
|
console.error("Error reading messages:", e.message);
|
|
609
|
-
|
|
616
|
+
throw e;
|
|
610
617
|
}
|
|
611
618
|
}
|
|
612
619
|
|
|
@@ -644,19 +651,24 @@ function getParticipantStatus(status) {
|
|
|
644
651
|
}
|
|
645
652
|
|
|
646
653
|
function getCalendarEvents() {
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
654
|
+
// NOTE: We index ALL calendar events, not filtered by date
|
|
655
|
+
// Calendar events don't have a "file modification time" like emails do,
|
|
656
|
+
// so we can't use the mdfind + DAYS_BACK approach.
|
|
657
|
+
// Calendar indexing is always comprehensive - filtering by date would lose historical context.
|
|
658
|
+
// Must throw on source-read failure: a silent [] would look like "every event
|
|
659
|
+
// was deleted" and the stale-entry pass would wipe the calendar index.
|
|
660
|
+
if (!fs.existsSync(CALENDAR_DB)) {
|
|
661
|
+
throw new Error("Calendar database not found");
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const now = Date.now();
|
|
665
|
+
const pastDate = unixMsToMacAbsolute(now - 10 * 365 * 24 * 60 * 60 * 1000); // 10 years back
|
|
666
|
+
const futureDate = unixMsToMacAbsolute(now + 10 * 365 * 24 * 60 * 60 * 1000); // 10 years ahead
|
|
667
|
+
|
|
668
|
+
// Query OccurrenceCache for recurring events and their calculated occurrences
|
|
669
|
+
// This includes both recurring and non-recurring events
|
|
670
|
+
// GROUP BY to avoid duplicate entries for recurring events
|
|
671
|
+
const query = `
|
|
660
672
|
SELECT
|
|
661
673
|
ci.ROWID as id,
|
|
662
674
|
ci.summary,
|
|
@@ -678,10 +690,7 @@ function getCalendarEvents() {
|
|
|
678
690
|
ORDER BY MIN(oc.day) ASC
|
|
679
691
|
`;
|
|
680
692
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
// Get attendees for events that have them (separate query for efficiency)
|
|
684
|
-
const attendeesQuery = `
|
|
693
|
+
const attendeesQuery = `
|
|
685
694
|
SELECT
|
|
686
695
|
p.owner_id,
|
|
687
696
|
COALESCE(i.display_name, p.email, 'Unknown') as name,
|
|
@@ -691,11 +700,13 @@ function getCalendarEvents() {
|
|
|
691
700
|
WHERE p.entity_type = 0
|
|
692
701
|
`;
|
|
693
702
|
|
|
703
|
+
return withCalendarCopy((dbPath) => {
|
|
704
|
+
const rows = safeSqlite3Json(dbPath, query, { timeout: 30000 });
|
|
705
|
+
|
|
694
706
|
let attendeesMap = new Map();
|
|
695
707
|
try {
|
|
696
|
-
const attendeesRows = safeSqlite3Json(
|
|
708
|
+
const attendeesRows = safeSqlite3Json(dbPath, attendeesQuery, { timeout: 10000 });
|
|
697
709
|
|
|
698
|
-
// Group attendees by owner_id (event id)
|
|
699
710
|
for (const att of attendeesRows) {
|
|
700
711
|
if (!attendeesMap.has(att.owner_id)) {
|
|
701
712
|
attendeesMap.set(att.owner_id, []);
|
|
@@ -733,10 +744,7 @@ function getCalendarEvents() {
|
|
|
733
744
|
|
|
734
745
|
console.error(`Calendar: Retrieved ${events.length} events via SQLite (~${Math.round((Date.now() - now))}ms)`);
|
|
735
746
|
return events;
|
|
736
|
-
}
|
|
737
|
-
console.error("Error reading calendar:", e.message);
|
|
738
|
-
return [];
|
|
739
|
-
}
|
|
747
|
+
});
|
|
740
748
|
}
|
|
741
749
|
|
|
742
750
|
// ============ DATABASE FUNCTIONS ============
|
|
@@ -926,7 +934,13 @@ export async function indexEmails(progressCallback = null, forceFullScan = false
|
|
|
926
934
|
// Use fast incremental scan if we have a previous timestamp
|
|
927
935
|
const startTime = Date.now();
|
|
928
936
|
console.error(`Calling findNewEmlxFiles with timestamp: ${lastEmailIndexTime ? new Date(lastEmailIndexTime).toISOString() : 'null (full scan)'}`);
|
|
929
|
-
|
|
937
|
+
let newFiles;
|
|
938
|
+
try {
|
|
939
|
+
newFiles = await findNewEmlxFiles(lastEmailIndexTime);
|
|
940
|
+
} catch (e) {
|
|
941
|
+
console.error(`Email source read failed; skipping this cycle so the lookback timestamp is not advanced: ${e.message}`);
|
|
942
|
+
return { indexed: 0, added: 0, error: e.message };
|
|
943
|
+
}
|
|
930
944
|
console.error(`Found ${newFiles.length} new/modified email files (${Date.now() - startTime}ms)`);
|
|
931
945
|
|
|
932
946
|
const indexedPaths = await getIndexedIdsWithRetry("emails", "filePath");
|
|
@@ -1040,6 +1054,13 @@ export async function indexEmails(progressCallback = null, forceFullScan = false
|
|
|
1040
1054
|
if (uniqueRecords.length > 0) {
|
|
1041
1055
|
if (!tables.emails) {
|
|
1042
1056
|
tables.emails = await db.createTable("emails", uniqueRecords, { mode: "overwrite" });
|
|
1057
|
+
// Track messageIds from the first batch so later batches can
|
|
1058
|
+
// dedupe IMAP copies (INBOX vs Junk) of the same Message-ID.
|
|
1059
|
+
for (const record of uniqueRecords) {
|
|
1060
|
+
if (record.messageId) {
|
|
1061
|
+
indexedMessageIds.add(record.messageId);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1043
1064
|
} else {
|
|
1044
1065
|
// Double-check: verify these IDs truly aren't in the index
|
|
1045
1066
|
const currentIndexed = await getIndexedIdsWithRetry("emails", "filePath");
|
|
@@ -1129,7 +1150,13 @@ export async function indexMessages(forceFullScan = false) {
|
|
|
1129
1150
|
const indexStartTime = Date.now();
|
|
1130
1151
|
|
|
1131
1152
|
// Use incremental scan if we have a previous timestamp
|
|
1132
|
-
|
|
1153
|
+
let messages;
|
|
1154
|
+
try {
|
|
1155
|
+
messages = getMessages(lastMessageIndexTime);
|
|
1156
|
+
} catch (e) {
|
|
1157
|
+
console.error(`Messages source read failed; skipping this cycle so the lookback timestamp is not advanced: ${e.message}`);
|
|
1158
|
+
return { indexed: 0, added: 0, error: e.message };
|
|
1159
|
+
}
|
|
1133
1160
|
console.error(`Found ${messages.length} messages${lastMessageIndexTime ? ' (incremental)' : ' (full scan)'}`);
|
|
1134
1161
|
|
|
1135
1162
|
const indexed = await getIndexedIdsWithRetry("messages", "id");
|
|
@@ -1185,7 +1212,7 @@ export async function indexMessages(forceFullScan = false) {
|
|
|
1185
1212
|
return {
|
|
1186
1213
|
id: String(msg.id),
|
|
1187
1214
|
date: msg.date,
|
|
1188
|
-
dateTimestamp: msg.dateTimestamp
|
|
1215
|
+
dateTimestamp: toUnixMillis(msg.dateTimestamp),
|
|
1189
1216
|
sender: msg.sender,
|
|
1190
1217
|
text: msg.text?.substring(0, 500) || "",
|
|
1191
1218
|
chatId: String(msg.chatId || ""),
|
|
@@ -1269,7 +1296,15 @@ export async function indexMessages(forceFullScan = false) {
|
|
|
1269
1296
|
export async function indexCalendar() {
|
|
1270
1297
|
await initDB();
|
|
1271
1298
|
|
|
1272
|
-
|
|
1299
|
+
let events;
|
|
1300
|
+
try {
|
|
1301
|
+
events = getCalendarEvents();
|
|
1302
|
+
} catch (e) {
|
|
1303
|
+
// Do not treat a source-read failure as "zero events" — that would mark
|
|
1304
|
+
// every indexed row stale and delete the calendar index.
|
|
1305
|
+
console.error(`Calendar source read failed; skipping index update to avoid data loss: ${e.message}`);
|
|
1306
|
+
return { indexed: 0, added: 0, removed: 0, error: e.message };
|
|
1307
|
+
}
|
|
1273
1308
|
console.error(`Found ${events.length} calendar events`);
|
|
1274
1309
|
|
|
1275
1310
|
// Get already indexed event IDs for incremental indexing
|
|
@@ -1492,14 +1527,14 @@ export async function getRecentMessages(limit = 10, daysBack = 1) {
|
|
|
1492
1527
|
if (!tables.messages) return { messages: [], hasMore: false };
|
|
1493
1528
|
|
|
1494
1529
|
try {
|
|
1495
|
-
const cutoff = Date.now()
|
|
1530
|
+
const cutoff = Date.now() - (daysBack * 24 * 60 * 60 * 1000);
|
|
1496
1531
|
const results = await tables.messages.query()
|
|
1497
1532
|
.select(["id", "date", "dateTimestamp", "sender", "text", "chatId", "isGroupChat"])
|
|
1498
1533
|
.toArray();
|
|
1499
1534
|
|
|
1500
1535
|
const filtered = results
|
|
1501
|
-
.filter(r => r.dateTimestamp >= cutoff)
|
|
1502
|
-
.sort((a, b) => b.dateTimestamp - a.dateTimestamp);
|
|
1536
|
+
.filter(r => toUnixMillis(r.dateTimestamp) >= cutoff)
|
|
1537
|
+
.sort((a, b) => toUnixMillis(b.dateTimestamp) - toUnixMillis(a.dateTimestamp));
|
|
1503
1538
|
|
|
1504
1539
|
const hasMore = filtered.length > limit;
|
|
1505
1540
|
const messages = filtered.slice(0, limit);
|
|
@@ -1529,7 +1564,7 @@ export async function getConversation(contact, limit = 50) {
|
|
|
1529
1564
|
|
|
1530
1565
|
// Sort chronologically (oldest first for conversation view)
|
|
1531
1566
|
return filtered
|
|
1532
|
-
.sort((a, b) => a.dateTimestamp - b.dateTimestamp)
|
|
1567
|
+
.sort((a, b) => toUnixMillis(a.dateTimestamp) - toUnixMillis(b.dateTimestamp))
|
|
1533
1568
|
.slice(-limit); // Take last N messages
|
|
1534
1569
|
} catch (e) {
|
|
1535
1570
|
console.error("Error getting conversation:", e.message);
|
|
@@ -1647,7 +1682,7 @@ export function getMessageContacts(limit = 50) {
|
|
|
1647
1682
|
return safeSqlite3Json(MESSAGES_DB, query, { timeout: 30000 });
|
|
1648
1683
|
} catch (e) {
|
|
1649
1684
|
console.error("Error getting message contacts:", e.message);
|
|
1650
|
-
return
|
|
1685
|
+
return { error: e.message };
|
|
1651
1686
|
}
|
|
1652
1687
|
}
|
|
1653
1688
|
|
|
@@ -1677,13 +1712,126 @@ export function getUpcomingEvents(limit = 10) {
|
|
|
1677
1712
|
ORDER BY sort_day ASC
|
|
1678
1713
|
LIMIT ${fetchLimit}
|
|
1679
1714
|
`;
|
|
1680
|
-
const events =
|
|
1715
|
+
const events = withCalendarCopy((dbPath) => {
|
|
1716
|
+
return safeSqlite3Json(dbPath, query, { timeout: 10000 });
|
|
1717
|
+
});
|
|
1681
1718
|
const hasMore = events.length > safeLimit;
|
|
1682
1719
|
const limitedEvents = events.slice(0, safeLimit);
|
|
1683
1720
|
return { events: limitedEvents, showing: limitedEvents.length, hasMore };
|
|
1684
1721
|
} catch (e) {
|
|
1685
1722
|
console.error("Error getting upcoming events:", e.message);
|
|
1686
|
-
return { events: [], showing: 0, hasMore: false };
|
|
1723
|
+
return { events: [], showing: 0, hasMore: false, error: e.message };
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
// Convert local-day unix ms bounds to Apple/Core Data seconds (unix - 978307200)
|
|
1728
|
+
export function localDayToMacBounds(startMs, endMs) {
|
|
1729
|
+
const startMac = Math.floor(Number(startMs) / 1000) - MAC_ABSOLUTE_EPOCH;
|
|
1730
|
+
const endMac = Math.floor(Number(endMs) / 1000) - MAC_ABSOLUTE_EPOCH;
|
|
1731
|
+
if (!Number.isFinite(startMac) || !Number.isFinite(endMac)) {
|
|
1732
|
+
throw new Error("Invalid date bounds");
|
|
1733
|
+
}
|
|
1734
|
+
return { startMac, endMac };
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// Date-bounded OccurrenceCache query: one row per occurrence, no GROUP BY ci.ROWID
|
|
1738
|
+
export function buildEventsOnDateQuery(startMac, endMac) {
|
|
1739
|
+
const startBound = Math.floor(Number(startMac));
|
|
1740
|
+
const endBound = Math.floor(Number(endMac));
|
|
1741
|
+
if (!Number.isFinite(startBound) || !Number.isFinite(endBound)) {
|
|
1742
|
+
throw new Error("Invalid Mac Absolute Time bounds");
|
|
1743
|
+
}
|
|
1744
|
+
const timedStart = "COALESCE(oc.occurrence_end_date - (ci.end_date - ci.start_date), ci.start_date)";
|
|
1745
|
+
const occEnd = "COALESCE(oc.occurrence_end_date, ci.end_date)";
|
|
1746
|
+
// First local day = occurrence-end local date minus (durationDays - 1). Day math, not seconds (DST-safe).
|
|
1747
|
+
const allDayStartDate = `date(${occEnd} + 978307200, 'unixepoch', 'localtime', '-' || (CAST(round((ci.end_date - ci.start_date) / 86400.0) AS INTEGER) - 1) || ' days')`;
|
|
1748
|
+
const allDayStartMac = `CAST(strftime('%s', ${allDayStartDate} || ' 00:00:00', 'utc') AS INTEGER) - 978307200`;
|
|
1749
|
+
return `
|
|
1750
|
+
SELECT DISTINCT
|
|
1751
|
+
ci.ROWID as itemId,
|
|
1752
|
+
ci.summary as title,
|
|
1753
|
+
datetime(CASE WHEN ci.all_day THEN ${allDayStartMac} ELSE ${timedStart} END + 978307200, 'unixepoch', 'localtime') as start,
|
|
1754
|
+
datetime(${occEnd} + 978307200, 'unixepoch', 'localtime') as end,
|
|
1755
|
+
CASE WHEN ci.all_day THEN ${allDayStartMac} ELSE ${timedStart} END as startMac,
|
|
1756
|
+
${occEnd} as endMac,
|
|
1757
|
+
ci.all_day as isAllDay,
|
|
1758
|
+
c.title as calendar,
|
|
1759
|
+
l.title as location
|
|
1760
|
+
FROM OccurrenceCache oc
|
|
1761
|
+
INNER JOIN CalendarItem ci ON oc.event_id = ci.ROWID
|
|
1762
|
+
LEFT JOIN Calendar c ON ci.calendar_id = c.ROWID
|
|
1763
|
+
LEFT JOIN Location l ON ci.location_id = l.ROWID
|
|
1764
|
+
WHERE ci.summary IS NOT NULL AND ci.summary <> ''
|
|
1765
|
+
AND (c.title IS NULL OR c.title NOT IN ('Found in Mail', 'Found in Natural Language'))
|
|
1766
|
+
AND (
|
|
1767
|
+
(oc.day >= ${startBound} AND oc.day < ${endBound})
|
|
1768
|
+
OR (
|
|
1769
|
+
ci.all_day = 0
|
|
1770
|
+
AND ${timedStart} < ${startBound}
|
|
1771
|
+
AND ${occEnd} > ${startBound}
|
|
1772
|
+
)
|
|
1773
|
+
)
|
|
1774
|
+
ORDER BY startMac ASC, title ASC
|
|
1775
|
+
`;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// Copy Calendar.sqlitedb (+ wal/shm) to /tmp for one query, then delete the copies.
|
|
1779
|
+
// Calendar.app holds a lock on the live db; sqlite can read a snapshot copy.
|
|
1780
|
+
function withCalendarCopy(fn) {
|
|
1781
|
+
const id = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1782
|
+
const tmpDb = path.join("/tmp", `${CALENDAR_TMP_PREFIX}${id}.sqlitedb`);
|
|
1783
|
+
const tmpWal = `${tmpDb}-wal`;
|
|
1784
|
+
const tmpShm = `${tmpDb}-shm`;
|
|
1785
|
+
const srcWal = `${CALENDAR_DB}-wal`;
|
|
1786
|
+
const srcShm = `${CALENDAR_DB}-shm`;
|
|
1787
|
+
|
|
1788
|
+
try {
|
|
1789
|
+
if (!fs.existsSync(CALENDAR_DB)) {
|
|
1790
|
+
throw new Error("Calendar database not found");
|
|
1791
|
+
}
|
|
1792
|
+
fs.copyFileSync(CALENDAR_DB, tmpDb);
|
|
1793
|
+
if (fs.existsSync(srcWal)) {
|
|
1794
|
+
fs.copyFileSync(srcWal, tmpWal);
|
|
1795
|
+
}
|
|
1796
|
+
if (fs.existsSync(srcShm)) {
|
|
1797
|
+
fs.copyFileSync(srcShm, tmpShm);
|
|
1798
|
+
}
|
|
1799
|
+
return fn(tmpDb);
|
|
1800
|
+
} finally {
|
|
1801
|
+
for (const file of [tmpDb, tmpWal, tmpShm]) {
|
|
1802
|
+
try {
|
|
1803
|
+
fs.unlinkSync(file);
|
|
1804
|
+
} catch {
|
|
1805
|
+
// already gone or never created
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
// calendar_date: live OccurrenceCache for every occurrence on a local day
|
|
1812
|
+
export function getEventsOnDate(startMs, endMs) {
|
|
1813
|
+
try {
|
|
1814
|
+
if (!fs.existsSync(CALENDAR_DB)) {
|
|
1815
|
+
return { events: [], error: "Calendar database not found" };
|
|
1816
|
+
}
|
|
1817
|
+
const { startMac, endMac } = localDayToMacBounds(startMs, endMs);
|
|
1818
|
+
const query = buildEventsOnDateQuery(startMac, endMac);
|
|
1819
|
+
let lastError;
|
|
1820
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1821
|
+
try {
|
|
1822
|
+
const events = withCalendarCopy((dbPath) => {
|
|
1823
|
+
return safeSqlite3Json(dbPath, query, { timeout: 15000 });
|
|
1824
|
+
});
|
|
1825
|
+
return { events, error: null };
|
|
1826
|
+
} catch (e) {
|
|
1827
|
+
lastError = e;
|
|
1828
|
+
console.error(`Error getting events on date (attempt ${attempt + 1}):`, e.message);
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
return { events: [], error: lastError.message };
|
|
1832
|
+
} catch (e) {
|
|
1833
|
+
console.error("Error getting events on date:", e.message);
|
|
1834
|
+
return { events: [], error: e.message };
|
|
1687
1835
|
}
|
|
1688
1836
|
}
|
|
1689
1837
|
|
|
@@ -1764,7 +1912,9 @@ export function getWeekEvents(weekOffset = 0) {
|
|
|
1764
1912
|
AND ci.summary IS NOT NULL AND ci.summary <> ''
|
|
1765
1913
|
ORDER BY oc.day ASC
|
|
1766
1914
|
`;
|
|
1767
|
-
const events =
|
|
1915
|
+
const events = withCalendarCopy((dbPath) => {
|
|
1916
|
+
return safeSqlite3Json(dbPath, query, { timeout: 15000 });
|
|
1917
|
+
});
|
|
1768
1918
|
|
|
1769
1919
|
const weekStart = monday.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
|
|
1770
1920
|
const weekEnd = sunday.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
|
|
@@ -1803,15 +1953,13 @@ export async function getEmailThread(filePath, limit = 20) {
|
|
|
1803
1953
|
}
|
|
1804
1954
|
|
|
1805
1955
|
// Read the email to get subject
|
|
1806
|
-
const content = fs.readFileSync(validatedPath, "utf-8");
|
|
1956
|
+
const content = unfoldRfc822Headers(fs.readFileSync(validatedPath, "utf-8"));
|
|
1807
1957
|
const subjectMatch = content.match(/^Subject:\s*(.+)$/m);
|
|
1808
1958
|
if (!subjectMatch) {
|
|
1809
1959
|
return { error: "Could not extract subject from email", emails: [] };
|
|
1810
1960
|
}
|
|
1811
1961
|
|
|
1812
|
-
|
|
1813
|
-
let subject = subjectMatch[1].trim();
|
|
1814
|
-
const baseSubject = subject.replace(/^(Re|Fwd|Fw):\s*/gi, "").trim();
|
|
1962
|
+
const baseSubject = stripSubjectPrefixes(subjectMatch[1]);
|
|
1815
1963
|
|
|
1816
1964
|
if (baseSubject.length < 5) {
|
|
1817
1965
|
return { error: "Subject too short to find thread", emails: [] };
|
|
@@ -1825,7 +1973,7 @@ export async function getEmailThread(filePath, limit = 20) {
|
|
|
1825
1973
|
// Filter to emails with matching base subject
|
|
1826
1974
|
const threadEmails = allEmails
|
|
1827
1975
|
.filter(e => {
|
|
1828
|
-
const eBaseSubject = (e.subject || "")
|
|
1976
|
+
const eBaseSubject = stripSubjectPrefixes(e.subject || "");
|
|
1829
1977
|
return eBaseSubject.toLowerCase() === baseSubject.toLowerCase();
|
|
1830
1978
|
})
|
|
1831
1979
|
.sort((a, b) => a.dateTimestamp - b.dateTimestamp)
|
|
@@ -1869,12 +2017,14 @@ export function getRecurringEvents(limit = 20) {
|
|
|
1869
2017
|
ORDER BY occurrenceCount DESC, MIN(oc.day) ASC
|
|
1870
2018
|
LIMIT ${fetchLimit}
|
|
1871
2019
|
`;
|
|
1872
|
-
const events =
|
|
2020
|
+
const events = withCalendarCopy((dbPath) => {
|
|
2021
|
+
return safeSqlite3Json(dbPath, query, { timeout: 30000 });
|
|
2022
|
+
});
|
|
1873
2023
|
const hasMore = events.length > safeLimit;
|
|
1874
2024
|
const limitedEvents = events.slice(0, safeLimit);
|
|
1875
2025
|
return { events: limitedEvents, showing: limitedEvents.length, hasMore };
|
|
1876
2026
|
} catch (e) {
|
|
1877
2027
|
console.error("Error getting recurring events:", e.message);
|
|
1878
|
-
return { events: [], showing: 0, hasMore: false };
|
|
2028
|
+
return { events: [], showing: 0, hasMore: false, error: e.message };
|
|
1879
2029
|
}
|
|
1880
2030
|
}
|
package/lib/shell.js
CHANGED
|
@@ -93,8 +93,7 @@ export function safeSqlite3Json(dbPath, query, options = {}) {
|
|
|
93
93
|
try {
|
|
94
94
|
return JSON.parse(output);
|
|
95
95
|
} catch (e) {
|
|
96
|
-
|
|
97
|
-
return [];
|
|
96
|
+
throw new Error(`Failed to parse sqlite3 JSON output: ${e.message}`);
|
|
98
97
|
}
|
|
99
98
|
}
|
|
100
99
|
|
|
@@ -267,12 +266,20 @@ export function safeFind(searchPath, options = {}) {
|
|
|
267
266
|
throw result.error;
|
|
268
267
|
}
|
|
269
268
|
|
|
270
|
-
|
|
271
|
-
// We still want to return whatever paths it found
|
|
272
|
-
return result.stdout
|
|
269
|
+
const paths = (result.stdout || '')
|
|
273
270
|
.split('\n')
|
|
274
271
|
.map(line => line.trim())
|
|
275
272
|
.filter(line => line.length > 0);
|
|
273
|
+
|
|
274
|
+
// Partial permission errors can still yield some paths; keep those.
|
|
275
|
+
// A complete failure (no paths + non-zero status) must not be treated as
|
|
276
|
+
// "zero files" — callers persist index timestamps on empty scans.
|
|
277
|
+
if (result.status !== 0 && paths.length === 0) {
|
|
278
|
+
const errorMsg = (result.stderr || '').trim() || `find exited with code ${result.status}`;
|
|
279
|
+
throw new Error(errorMsg);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return paths;
|
|
276
283
|
}
|
|
277
284
|
|
|
278
285
|
// ============ GENERIC SAFE SPAWN ============
|
package/lib/validators.js
CHANGED
|
@@ -118,10 +118,14 @@ export function escapeAppleScript(str) {
|
|
|
118
118
|
if (!str || typeof str !== 'string') {
|
|
119
119
|
return '';
|
|
120
120
|
}
|
|
121
|
-
// Escape backslashes first, then
|
|
121
|
+
// Escape backslashes first, then quotes, then line breaks.
|
|
122
|
+
// Unescaped CR/LF inside an AppleScript double-quoted string can close the
|
|
123
|
+
// string and inject additional statements.
|
|
122
124
|
return str
|
|
123
125
|
.replace(/\\/g, '\\\\')
|
|
124
|
-
.replace(/"/g, '\\"')
|
|
126
|
+
.replace(/"/g, '\\"')
|
|
127
|
+
.replace(/\r/g, '\\r')
|
|
128
|
+
.replace(/\n/g, '\\n');
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
/**
|
|
@@ -407,6 +411,64 @@ export function stripHtmlTags(html, maxLength = 100000) {
|
|
|
407
411
|
*/
|
|
408
412
|
const MAC_ABSOLUTE_EPOCH = 978307200;
|
|
409
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Normalize a Unix timestamp to milliseconds.
|
|
416
|
+
* Messages chat.db values are stored as seconds; email/calendar timestamps
|
|
417
|
+
* are milliseconds. Treating seconds as ms drops every date-filtered message.
|
|
418
|
+
*
|
|
419
|
+
* Threshold 1e11 ms is 1973-03-03 — well before any real mail/message date,
|
|
420
|
+
* and far above Unix seconds for year 2100 (~4e9).
|
|
421
|
+
*
|
|
422
|
+
* @param {number|string|null|undefined} ts
|
|
423
|
+
* @returns {number} Timestamp in milliseconds, or 0 if invalid
|
|
424
|
+
*/
|
|
425
|
+
export function toUnixMillis(ts) {
|
|
426
|
+
if (ts === null || ts === undefined || ts === '') {
|
|
427
|
+
return 0;
|
|
428
|
+
}
|
|
429
|
+
const n = Number(ts);
|
|
430
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
431
|
+
return 0;
|
|
432
|
+
}
|
|
433
|
+
return n < 1e11 ? n * 1000 : n;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Unfold RFC 822 wrapped header lines (CRLF + WSP continuation) so
|
|
438
|
+
* From/Subject/To matchers see the full header value.
|
|
439
|
+
*
|
|
440
|
+
* @param {string} content - Raw email content (headers + body)
|
|
441
|
+
* @returns {string} Content with folded headers joined into single lines
|
|
442
|
+
*/
|
|
443
|
+
export function unfoldRfc822Headers(content) {
|
|
444
|
+
if (!content || typeof content !== 'string') {
|
|
445
|
+
return '';
|
|
446
|
+
}
|
|
447
|
+
const split = content.search(/\r?\n\r?\n/);
|
|
448
|
+
const headers = split >= 0 ? content.slice(0, split) : content;
|
|
449
|
+
const body = split >= 0 ? content.slice(split) : '';
|
|
450
|
+
return headers.replace(/\r?\n[ \t]+/g, ' ') + body;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Strip repeated Re:/Fwd:/Fw: prefixes from an email subject for thread matching.
|
|
455
|
+
*
|
|
456
|
+
* @param {string} subject
|
|
457
|
+
* @returns {string}
|
|
458
|
+
*/
|
|
459
|
+
export function stripSubjectPrefixes(subject) {
|
|
460
|
+
if (!subject || typeof subject !== 'string') {
|
|
461
|
+
return '';
|
|
462
|
+
}
|
|
463
|
+
let s = subject.trim();
|
|
464
|
+
let prev;
|
|
465
|
+
do {
|
|
466
|
+
prev = s;
|
|
467
|
+
s = s.replace(/^(re|fwd|fw)\s*:\s*/i, '').trim();
|
|
468
|
+
} while (s !== prev);
|
|
469
|
+
return s;
|
|
470
|
+
}
|
|
471
|
+
|
|
410
472
|
/**
|
|
411
473
|
* Convert Mac Absolute Time to Unix timestamp (milliseconds)
|
|
412
474
|
* Handles both seconds and nanoseconds formats
|
package/package.json
CHANGED
package/search.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as lancedb from "@lancedb/lancedb";
|
|
2
2
|
import * as chrono from "chrono-node";
|
|
3
3
|
import { safeOsascript } from "./lib/shell.js";
|
|
4
|
-
import { safeMatch, validateSearchQuery } from "./lib/validators.js";
|
|
5
|
-
import { embed, INDEX_DIR, getRecentEmails, getEmailsByDateRange, getRecentMessages, getConversation,
|
|
4
|
+
import { safeMatch, validateSearchQuery, toUnixMillis } from "./lib/validators.js";
|
|
5
|
+
import { embed, INDEX_DIR, getRecentEmails, getEmailsByDateRange, getRecentMessages, getConversation, getEventsOnDate, resolveEmail, resolvePhone, formatContact } from "./indexer.js";
|
|
6
6
|
|
|
7
7
|
let db = null;
|
|
8
8
|
let tables = {};
|
|
@@ -102,13 +102,16 @@ function resolvePronouns(query) {
|
|
|
102
102
|
return query;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (pronounPattern.test(query) && queryContext.lastPerson) {
|
|
108
|
-
return query.replace(pronounPattern, queryContext.lastPerson);
|
|
105
|
+
if (!queryContext.lastPerson) {
|
|
106
|
+
return query;
|
|
109
107
|
}
|
|
110
108
|
|
|
111
|
-
|
|
109
|
+
// Do not use RegExp#test with /g — lastIndex is stateful and can skip the
|
|
110
|
+
// first (or only) pronoun. replace() with a fresh regex is sufficient.
|
|
111
|
+
return query.replace(
|
|
112
|
+
/\b(they|them|their|he|him|his|she|her|hers)\b/gi,
|
|
113
|
+
queryContext.lastPerson
|
|
114
|
+
);
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
// Extract entities (people, dates) from natural language query and convert to filters
|
|
@@ -525,8 +528,10 @@ export function getDateRange(dateStr) {
|
|
|
525
528
|
const start = parseNaturalDate(dateStr);
|
|
526
529
|
if (!start) return null;
|
|
527
530
|
|
|
528
|
-
|
|
529
|
-
|
|
531
|
+
// Local next midnight — not +24h, which is wrong on DST transition days
|
|
532
|
+
const endDate = new Date(start);
|
|
533
|
+
endDate.setDate(endDate.getDate() + 1);
|
|
534
|
+
return { start, end: endDate.getTime() };
|
|
530
535
|
}
|
|
531
536
|
|
|
532
537
|
// Parse various date formats and return timestamp (for filtering results)
|
|
@@ -559,7 +564,7 @@ function filterByDateRange(results, daysBack, daysAhead, dateField = "date") {
|
|
|
559
564
|
if (daysBack === 0 && daysAhead === 0) return results;
|
|
560
565
|
|
|
561
566
|
return results.filter(r => {
|
|
562
|
-
const ts = r.dateTimestamp || parseDate(r[dateField]);
|
|
567
|
+
const ts = toUnixMillis(r.dateTimestamp) || parseDate(r[dateField]);
|
|
563
568
|
if (!ts) return daysBack === 0 && daysAhead === 0;
|
|
564
569
|
return ts >= cutoffPast && ts <= cutoffFuture;
|
|
565
570
|
});
|
|
@@ -568,8 +573,8 @@ function filterByDateRange(results, daysBack, daysAhead, dateField = "date") {
|
|
|
568
573
|
// Sort results by date (newest first)
|
|
569
574
|
function sortByDate(results, descending = true) {
|
|
570
575
|
return results.sort((a, b) => {
|
|
571
|
-
const tsA = a.dateTimestamp || parseDate(a.date) || parseDate(a.start) || 0;
|
|
572
|
-
const tsB = b.dateTimestamp || parseDate(b.date) || parseDate(b.start) || 0;
|
|
576
|
+
const tsA = toUnixMillis(a.dateTimestamp) || parseDate(a.date) || parseDate(a.start) || 0;
|
|
577
|
+
const tsB = toUnixMillis(b.dateTimestamp) || parseDate(b.date) || parseDate(b.start) || 0;
|
|
573
578
|
return descending ? tsB - tsA : tsA - tsB;
|
|
574
579
|
});
|
|
575
580
|
}
|
|
@@ -582,7 +587,7 @@ export async function searchEmails(query, options = {}) {
|
|
|
582
587
|
try {
|
|
583
588
|
validatedQuery = validateSearchQuery(query);
|
|
584
589
|
} catch (e) {
|
|
585
|
-
return { results: [], error: e.message };
|
|
590
|
+
return { success: false, results: [], error: e.message };
|
|
586
591
|
}
|
|
587
592
|
|
|
588
593
|
// Check result cache first
|
|
@@ -714,6 +719,7 @@ export async function searchEmails(query, options = {}) {
|
|
|
714
719
|
to: row.to || "Unknown",
|
|
715
720
|
subject: row.subject || "No subject",
|
|
716
721
|
date: formatLocalDate(row.date) || "Unknown",
|
|
722
|
+
dateTimestamp: toUnixMillis(row.dateTimestamp) || null,
|
|
717
723
|
mailbox: row.mailbox || "Unknown",
|
|
718
724
|
hasAttachment: row.hasAttachment || false,
|
|
719
725
|
isFlagged: row.isFlagged || false,
|
|
@@ -950,7 +956,7 @@ export async function getEmailDateResults(dateStr, includeJunk = false) {
|
|
|
950
956
|
}
|
|
951
957
|
|
|
952
958
|
export function formatEmailResults(searchResult) {
|
|
953
|
-
if (!searchResult.success) return searchResult.error;
|
|
959
|
+
if (!searchResult.success) return searchResult.error || "Email search failed";
|
|
954
960
|
if (searchResult.results.length === 0) return searchResult.message;
|
|
955
961
|
|
|
956
962
|
const results = searchResult.results.map(r => {
|
|
@@ -965,6 +971,9 @@ export function formatEmailResults(searchResult) {
|
|
|
965
971
|
return result + "\n---";
|
|
966
972
|
}).join("\n");
|
|
967
973
|
|
|
974
|
+
if (searchResult.hasMore) {
|
|
975
|
+
return results + `\n\nShowing ${searchResult.showing} results. More matches exist; increase limit to see them.`;
|
|
976
|
+
}
|
|
968
977
|
return results;
|
|
969
978
|
}
|
|
970
979
|
|
|
@@ -976,7 +985,7 @@ export async function searchMessages(query, options = {}) {
|
|
|
976
985
|
try {
|
|
977
986
|
validatedQuery = validateSearchQuery(query);
|
|
978
987
|
} catch (e) {
|
|
979
|
-
return { results: [], error: e.message };
|
|
988
|
+
return { success: false, results: [], error: e.message };
|
|
980
989
|
}
|
|
981
990
|
|
|
982
991
|
// Check result cache first
|
|
@@ -1084,6 +1093,7 @@ export async function searchMessages(query, options = {}) {
|
|
|
1084
1093
|
rank: idx + 1,
|
|
1085
1094
|
score: row._hybridScore ? row._hybridScore.toFixed(3) : (row._distance ? (1 - row._distance).toFixed(3) : "N/A"),
|
|
1086
1095
|
date: formatLocalDate(row.date) || "Unknown",
|
|
1096
|
+
dateTimestamp: toUnixMillis(row.dateTimestamp) || null,
|
|
1087
1097
|
sender: sender,
|
|
1088
1098
|
senderContact: senderContact, // Resolved contact name (if found)
|
|
1089
1099
|
text: row.text || "",
|
|
@@ -1126,6 +1136,7 @@ export async function getRecentMessageResults(limit = 10, daysBack = 1) {
|
|
|
1126
1136
|
return {
|
|
1127
1137
|
rank: idx + 1,
|
|
1128
1138
|
date: formatLocalDate(row.date) || "Unknown",
|
|
1139
|
+
dateTimestamp: toUnixMillis(row.dateTimestamp) || null,
|
|
1129
1140
|
sender: sender,
|
|
1130
1141
|
senderContact: senderContact,
|
|
1131
1142
|
text: row.text || "",
|
|
@@ -1166,7 +1177,7 @@ export async function getConversationResults(contact, limit = 50) {
|
|
|
1166
1177
|
}
|
|
1167
1178
|
|
|
1168
1179
|
export function formatMessageResults(searchResult) {
|
|
1169
|
-
if (!searchResult.success) return searchResult.error;
|
|
1180
|
+
if (!searchResult.success) return searchResult.error || "Message search failed";
|
|
1170
1181
|
if (searchResult.results.length === 0) return searchResult.message;
|
|
1171
1182
|
|
|
1172
1183
|
const results = searchResult.results.map(r => {
|
|
@@ -1180,6 +1191,9 @@ export function formatMessageResults(searchResult) {
|
|
|
1180
1191
|
return result + "\n---";
|
|
1181
1192
|
}).join("\n");
|
|
1182
1193
|
|
|
1194
|
+
if (searchResult.hasMore) {
|
|
1195
|
+
return results + `\n\nShowing ${searchResult.showing} results. More matches exist; increase limit to see them.`;
|
|
1196
|
+
}
|
|
1183
1197
|
return results;
|
|
1184
1198
|
}
|
|
1185
1199
|
|
|
@@ -1202,7 +1216,7 @@ export async function searchCalendar(query, options = {}) {
|
|
|
1202
1216
|
try {
|
|
1203
1217
|
validatedQuery = validateSearchQuery(query);
|
|
1204
1218
|
} catch (e) {
|
|
1205
|
-
return { results: [], error: e.message };
|
|
1219
|
+
return { success: false, results: [], error: e.message };
|
|
1206
1220
|
}
|
|
1207
1221
|
|
|
1208
1222
|
// Check result cache first
|
|
@@ -1319,28 +1333,40 @@ export async function searchCalendar(query, options = {}) {
|
|
|
1319
1333
|
}
|
|
1320
1334
|
}
|
|
1321
1335
|
|
|
1322
|
-
//
|
|
1336
|
+
// Local midnight of parsed date through next local midnight (handles DST; not +24h)
|
|
1337
|
+
export function getLocalDayBounds(dateStr) {
|
|
1338
|
+
const start = parseNaturalDate(dateStr);
|
|
1339
|
+
if (start == null) return null;
|
|
1340
|
+
const endDate = new Date(start);
|
|
1341
|
+
endDate.setDate(endDate.getDate() + 1);
|
|
1342
|
+
return { start, end: endDate.getTime() };
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// Get events on a specific date from live Calendar.sqlitedb (not the vector index)
|
|
1323
1346
|
export async function getCalendarDateResults(dateStr) {
|
|
1324
1347
|
try {
|
|
1325
|
-
const range =
|
|
1348
|
+
const range = getLocalDayBounds(dateStr);
|
|
1326
1349
|
if (!range) {
|
|
1327
1350
|
return { success: false, error: `Could not parse date: ${dateStr}` };
|
|
1328
1351
|
}
|
|
1329
1352
|
|
|
1330
1353
|
console.error(`[Calendar Date] Query for "${dateStr}"`);
|
|
1331
|
-
console.error(`[Calendar Date] Range: ${new Date(range.start).
|
|
1354
|
+
console.error(`[Calendar Date] Range: ${new Date(range.start).toString()} to ${new Date(range.end).toString()}`);
|
|
1332
1355
|
|
|
1333
|
-
const
|
|
1356
|
+
const { events, error } = getEventsOnDate(range.start, range.end);
|
|
1357
|
+
if (error) {
|
|
1358
|
+
return { success: false, error: `Error getting calendar events: ${error}` };
|
|
1359
|
+
}
|
|
1334
1360
|
|
|
1335
|
-
const formattedResults =
|
|
1361
|
+
const formattedResults = events.map((row, idx) => ({
|
|
1336
1362
|
index: idx + 1,
|
|
1337
1363
|
title: row.title || "No title",
|
|
1338
|
-
start: formatLocalDate(row.start) || "Unknown",
|
|
1339
|
-
startTimestamp: row.
|
|
1340
|
-
end: formatLocalDate(row.end) || "Unknown",
|
|
1364
|
+
start: formatLocalDate(row.start) || row.start || "Unknown",
|
|
1365
|
+
startTimestamp: row.startMac != null ? (Number(row.startMac) + 978307200) * 1000 : null,
|
|
1366
|
+
end: formatLocalDate(row.end) || row.end || "Unknown",
|
|
1341
1367
|
calendar: row.calendar || "Unknown",
|
|
1342
1368
|
location: row.location || "",
|
|
1343
|
-
isAllDay: row.isAllDay
|
|
1369
|
+
isAllDay: !!row.isAllDay
|
|
1344
1370
|
}));
|
|
1345
1371
|
|
|
1346
1372
|
const dateLabel = new Date(range.start).toLocaleDateString("en-US", {
|
|
@@ -1360,6 +1386,24 @@ export async function getCalendarDateResults(dateStr) {
|
|
|
1360
1386
|
}
|
|
1361
1387
|
}
|
|
1362
1388
|
|
|
1389
|
+
// Clamp a timed event to [dayStartMs, dayEndMs) and return minutes from local midnight.
|
|
1390
|
+
// Overnight events (23:00–01:00) contribute only the slice that falls on this day.
|
|
1391
|
+
export function clampBusyToLocalDay(evtStartMs, evtEndMs, dayStartMs, dayEndMs) {
|
|
1392
|
+
const clampedStart = Math.max(evtStartMs, dayStartMs);
|
|
1393
|
+
const clampedEnd = Math.min(evtEndMs, dayEndMs);
|
|
1394
|
+
if (!(clampedEnd > clampedStart)) return null;
|
|
1395
|
+
|
|
1396
|
+
const startMinutes = clampedStart <= dayStartMs
|
|
1397
|
+
? 0
|
|
1398
|
+
: new Date(clampedStart).getHours() * 60 + new Date(clampedStart).getMinutes();
|
|
1399
|
+
const endMinutes = clampedEnd >= dayEndMs
|
|
1400
|
+
? 24 * 60
|
|
1401
|
+
: new Date(clampedEnd).getHours() * 60 + new Date(clampedEnd).getMinutes();
|
|
1402
|
+
|
|
1403
|
+
if (!(endMinutes > startMinutes)) return null;
|
|
1404
|
+
return { startMinutes, endMinutes };
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1363
1407
|
// Calculate free time slots on a specific date
|
|
1364
1408
|
export async function calculateFreeTime(dateStr, options = {}) {
|
|
1365
1409
|
const {
|
|
@@ -1369,12 +1413,17 @@ export async function calculateFreeTime(dateStr, options = {}) {
|
|
|
1369
1413
|
} = options;
|
|
1370
1414
|
|
|
1371
1415
|
try {
|
|
1372
|
-
const range =
|
|
1416
|
+
const range = getLocalDayBounds(dateStr);
|
|
1373
1417
|
if (!range) {
|
|
1374
1418
|
return { success: false, error: `Could not parse date: ${dateStr}` };
|
|
1375
1419
|
}
|
|
1376
1420
|
|
|
1377
|
-
|
|
1421
|
+
const { events: liveEvents, error } = getEventsOnDate(range.start, range.end);
|
|
1422
|
+
if (error) {
|
|
1423
|
+
return { success: false, error: `Error calculating free time: ${error}` };
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
let events = liveEvents;
|
|
1378
1427
|
|
|
1379
1428
|
// Filter by calendar if specified
|
|
1380
1429
|
if (calendarName) {
|
|
@@ -1390,16 +1439,21 @@ export async function calculateFreeTime(dateStr, options = {}) {
|
|
|
1390
1439
|
continue;
|
|
1391
1440
|
}
|
|
1392
1441
|
|
|
1393
|
-
const evtStart =
|
|
1394
|
-
|
|
1442
|
+
const evtStart = evt.startMac != null
|
|
1443
|
+
? (Number(evt.startMac) + 978307200) * 1000
|
|
1444
|
+
: (evt.startTimestamp || parseDate(evt.start));
|
|
1445
|
+
const evtEnd = evt.endMac != null
|
|
1446
|
+
? (Number(evt.endMac) + 978307200) * 1000
|
|
1447
|
+
: (evt.end ? parseDate(evt.end) : evtStart + (60 * 60 * 1000));
|
|
1395
1448
|
|
|
1396
|
-
const
|
|
1397
|
-
|
|
1449
|
+
const clamped = clampBusyToLocalDay(evtStart, evtEnd, range.start, range.end);
|
|
1450
|
+
if (!clamped) continue;
|
|
1398
1451
|
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1452
|
+
const start = Math.max(clamped.startMinutes, startHour * 60);
|
|
1453
|
+
const end = Math.min(clamped.endMinutes, endHour * 60);
|
|
1454
|
+
if (end > start) {
|
|
1455
|
+
busyPeriods.push({ start, end });
|
|
1456
|
+
}
|
|
1403
1457
|
}
|
|
1404
1458
|
|
|
1405
1459
|
// Sort busy periods
|
|
@@ -1458,7 +1512,7 @@ function formatMinutes(minutes) {
|
|
|
1458
1512
|
}
|
|
1459
1513
|
|
|
1460
1514
|
export function formatCalendarResults(searchResult) {
|
|
1461
|
-
if (!searchResult.success) return searchResult.error;
|
|
1515
|
+
if (!searchResult.success) return searchResult.error || "Calendar search failed";
|
|
1462
1516
|
if (searchResult.results.length === 0) return searchResult.message;
|
|
1463
1517
|
|
|
1464
1518
|
let header = "";
|
|
@@ -1481,6 +1535,9 @@ export function formatCalendarResults(searchResult) {
|
|
|
1481
1535
|
return result + "\n---";
|
|
1482
1536
|
}).join("\n");
|
|
1483
1537
|
|
|
1538
|
+
if (searchResult.hasMore) {
|
|
1539
|
+
return header + results + `\n\nShowing ${searchResult.showing} results. More matches exist; increase limit to see them.`;
|
|
1540
|
+
}
|
|
1484
1541
|
return header + results;
|
|
1485
1542
|
}
|
|
1486
1543
|
|
|
@@ -1527,6 +1584,9 @@ export function formatSendersResults(senders) {
|
|
|
1527
1584
|
|
|
1528
1585
|
// Format messages_contacts results
|
|
1529
1586
|
export function formatMessageContactsResults(contacts) {
|
|
1587
|
+
if (contacts?.error) {
|
|
1588
|
+
return `Error getting message contacts: ${contacts.error}`;
|
|
1589
|
+
}
|
|
1530
1590
|
if (!contacts || contacts.length === 0) {
|
|
1531
1591
|
return "No message contacts found.";
|
|
1532
1592
|
}
|
|
@@ -1541,6 +1601,9 @@ export function formatMessageContactsResults(contacts) {
|
|
|
1541
1601
|
|
|
1542
1602
|
// Format calendar_upcoming results
|
|
1543
1603
|
export function formatUpcomingEventsResults(result) {
|
|
1604
|
+
if (result?.error) {
|
|
1605
|
+
return `Error getting upcoming events: ${result.error}`;
|
|
1606
|
+
}
|
|
1544
1607
|
const events = result.events || result; // Handle both new {events, showing, hasMore} and old array format
|
|
1545
1608
|
if (!events || events.length === 0) {
|
|
1546
1609
|
return "No upcoming events found.";
|
|
@@ -1633,6 +1696,9 @@ export function formatEmailThreadResults(result) {
|
|
|
1633
1696
|
|
|
1634
1697
|
// Format calendar_recurring results
|
|
1635
1698
|
export function formatRecurringEventsResults(result) {
|
|
1699
|
+
if (result?.error) {
|
|
1700
|
+
return `Error getting recurring events: ${result.error}`;
|
|
1701
|
+
}
|
|
1636
1702
|
const events = result.events || result; // Handle both new {events, showing, hasMore} and old array format
|
|
1637
1703
|
if (!events || events.length === 0) {
|
|
1638
1704
|
return "No recurring events found.";
|
|
@@ -1649,4 +1715,4 @@ export function formatRecurringEventsResults(result) {
|
|
|
1649
1715
|
}
|
|
1650
1716
|
|
|
1651
1717
|
// Export internal functions for testing
|
|
1652
|
-
export { expandQuery, parseNegation, extractKeywords };
|
|
1718
|
+
export { expandQuery, parseNegation, extractKeywords, resolvePronouns, updateContext };
|