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/indexer.js
ADDED
|
@@ -0,0 +1,1849 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { exec } from "child_process";
|
|
4
|
+
import { promisify } from "util";
|
|
5
|
+
import * as lancedb from "@lancedb/lancedb";
|
|
6
|
+
import { pipeline } from "@xenova/transformers";
|
|
7
|
+
import {
|
|
8
|
+
validateEmailPath,
|
|
9
|
+
validateMailboxName,
|
|
10
|
+
escapeAppleScript,
|
|
11
|
+
validateLimit,
|
|
12
|
+
validateLanceDBId,
|
|
13
|
+
escapeSQL,
|
|
14
|
+
stripHtmlTags
|
|
15
|
+
} from "./lib/validators.js";
|
|
16
|
+
import { safeSqlite3Json, safeOsascript } from "./lib/shell.js";
|
|
17
|
+
|
|
18
|
+
// Re-export contact functions for use by other modules
|
|
19
|
+
export {
|
|
20
|
+
loadContacts,
|
|
21
|
+
resolveEmail,
|
|
22
|
+
resolvePhone,
|
|
23
|
+
resolveByName,
|
|
24
|
+
lookupContact,
|
|
25
|
+
searchContacts,
|
|
26
|
+
getContactIdentifiers,
|
|
27
|
+
formatContact,
|
|
28
|
+
getContactStats
|
|
29
|
+
} from "./contacts.js";
|
|
30
|
+
|
|
31
|
+
const execAsync = promisify(exec);
|
|
32
|
+
|
|
33
|
+
// Support env var overrides for testing with separate index
|
|
34
|
+
export const INDEX_DIR = process.env.APPLE_TOOLS_INDEX_DIR ||
|
|
35
|
+
path.join(process.env.HOME, ".apple-tools-mcp", "vector-index");
|
|
36
|
+
const META_FILE = process.env.APPLE_TOOLS_META_FILE ||
|
|
37
|
+
path.join(process.env.HOME, ".apple-tools-mcp", "index-meta.json");
|
|
38
|
+
// Support filtering by date for testing (default: null = no filter)
|
|
39
|
+
const DAYS_BACK = process.env.APPLE_TOOLS_INDEX_DAYS_BACK ?
|
|
40
|
+
parseInt(process.env.APPLE_TOOLS_INDEX_DAYS_BACK, 10) : null;
|
|
41
|
+
const MAIL_DIR = path.join(process.env.HOME, "Library", "Mail");
|
|
42
|
+
|
|
43
|
+
// Load/save index metadata (timestamps, etc.)
|
|
44
|
+
function loadIndexMeta() {
|
|
45
|
+
try {
|
|
46
|
+
if (fs.existsSync(META_FILE)) {
|
|
47
|
+
return JSON.parse(fs.readFileSync(META_FILE, "utf-8"));
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function saveIndexMeta(meta) {
|
|
54
|
+
const dir = path.dirname(META_FILE);
|
|
55
|
+
if (!fs.existsSync(dir)) {
|
|
56
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
fs.writeFileSync(META_FILE, JSON.stringify(meta, null, 2));
|
|
59
|
+
}
|
|
60
|
+
const MESSAGES_DB = path.join(process.env.HOME, "Library", "Messages", "chat.db");
|
|
61
|
+
const CALENDAR_DB = path.join(process.env.HOME, "Library", "Group Containers", "group.com.apple.calendar", "Calendar.sqlitedb");
|
|
62
|
+
const BATCH_SIZE = 32; // Optimized for batch embedding throughput
|
|
63
|
+
const BATCH_DELAY_MS = 100; // Throttle to prevent thermal crashes
|
|
64
|
+
|
|
65
|
+
// Mac Absolute Time epoch: Jan 1, 2001 00:00:00 UTC
|
|
66
|
+
const MAC_ABSOLUTE_EPOCH = 978307200;
|
|
67
|
+
|
|
68
|
+
let embeddingPipeline = null;
|
|
69
|
+
let db = null;
|
|
70
|
+
let tables = {};
|
|
71
|
+
|
|
72
|
+
async function getEmbedder() {
|
|
73
|
+
if (!embeddingPipeline) {
|
|
74
|
+
console.error("Loading embedding model (first time may take a minute)...");
|
|
75
|
+
embeddingPipeline = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2");
|
|
76
|
+
console.error("Embedding model loaded.");
|
|
77
|
+
}
|
|
78
|
+
return embeddingPipeline;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function embed(text) {
|
|
82
|
+
const embedder = await getEmbedder();
|
|
83
|
+
const result = await embedder(text, { pooling: "mean", normalize: true });
|
|
84
|
+
return Array.from(result.data);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// True batch embedding - single forward pass for multiple texts
|
|
88
|
+
const EMBEDDING_DIM = 384; // all-MiniLM-L6-v2 dimension
|
|
89
|
+
|
|
90
|
+
// Timeout wrapper for promises
|
|
91
|
+
function withTimeout(promise, timeoutMs, operation = "Operation") {
|
|
92
|
+
return Promise.race([
|
|
93
|
+
promise,
|
|
94
|
+
new Promise((_, reject) =>
|
|
95
|
+
setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
96
|
+
)
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function embedBatch(texts) {
|
|
101
|
+
if (texts.length === 0) return [];
|
|
102
|
+
|
|
103
|
+
const embedder = await getEmbedder();
|
|
104
|
+
|
|
105
|
+
// Add timeout protection: 30 seconds per batch
|
|
106
|
+
// This prevents the process from hanging if embedding gets stuck
|
|
107
|
+
const EMBEDDING_TIMEOUT_MS = 30000;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
// Process all texts in a single forward pass
|
|
111
|
+
const result = await withTimeout(
|
|
112
|
+
embedder(texts, { pooling: "mean", normalize: true }),
|
|
113
|
+
EMBEDDING_TIMEOUT_MS,
|
|
114
|
+
"Batch embedding"
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Result.data is a flat Float32Array of shape [batch_size * embedding_dim]
|
|
118
|
+
const embeddings = [];
|
|
119
|
+
for (let i = 0; i < texts.length; i++) {
|
|
120
|
+
const start = i * EMBEDDING_DIM;
|
|
121
|
+
const end = start + EMBEDDING_DIM;
|
|
122
|
+
embeddings.push(Array.from(result.data.slice(start, end)));
|
|
123
|
+
}
|
|
124
|
+
return embeddings;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error.message.includes("timed out")) {
|
|
127
|
+
console.error(`⚠️ Embedding batch timed out (${texts.length} texts). Retrying with smaller batches...`);
|
|
128
|
+
// Fall back to processing one at a time with timeouts
|
|
129
|
+
const embeddings = [];
|
|
130
|
+
for (const text of texts) {
|
|
131
|
+
try {
|
|
132
|
+
const emb = await withTimeout(embed(text), 10000, "Single embedding");
|
|
133
|
+
embeddings.push(emb);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
console.error(`Failed to embed text: ${e.message}`);
|
|
136
|
+
// Return zero vector as fallback
|
|
137
|
+
embeddings.push(new Array(EMBEDDING_DIM).fill(0));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return embeddings;
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ============ UTILITY FUNCTIONS ============
|
|
147
|
+
|
|
148
|
+
// Extract email address from "Name <email>" format
|
|
149
|
+
function extractEmail(str) {
|
|
150
|
+
if (!str) return "";
|
|
151
|
+
const match = str.match(/<([^>]+)>/);
|
|
152
|
+
if (match) return match[1].toLowerCase();
|
|
153
|
+
// If no angle brackets, check if it looks like an email
|
|
154
|
+
if (str.includes("@")) return str.trim().toLowerCase();
|
|
155
|
+
return ""; // Return empty string if no email found
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Extract all email addresses from a string (handles multiple recipients)
|
|
159
|
+
function extractEmails(str) {
|
|
160
|
+
if (!str) return [];
|
|
161
|
+
const emails = [];
|
|
162
|
+
// Split by comma and process each
|
|
163
|
+
for (const part of str.split(",")) {
|
|
164
|
+
const email = extractEmail(part.trim());
|
|
165
|
+
if (email && email.includes("@")) {
|
|
166
|
+
emails.push(email);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return emails;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Parse date string to timestamp
|
|
173
|
+
function parseDateTime(dateStr) {
|
|
174
|
+
if (!dateStr) return 0;
|
|
175
|
+
try {
|
|
176
|
+
// Try direct parsing
|
|
177
|
+
let d = new Date(dateStr);
|
|
178
|
+
if (!isNaN(d.getTime())) return d.getTime();
|
|
179
|
+
|
|
180
|
+
// Handle AppleScript format: "Friday, January 10, 2025 at 9:00:00 AM"
|
|
181
|
+
const appleMatch = dateStr.match(/(\w+), (\w+ \d+, \d+) at (\d+:\d+:\d+ [AP]M)/i);
|
|
182
|
+
if (appleMatch) {
|
|
183
|
+
d = new Date(`${appleMatch[2]} ${appleMatch[3]}`);
|
|
184
|
+
if (!isNaN(d.getTime())) return d.getTime();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return 0;
|
|
188
|
+
} catch {
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ============ EMAIL INDEXING ============
|
|
194
|
+
|
|
195
|
+
// Extract mailbox name from file path (e.g., "INBOX.mbox" -> "INBOX")
|
|
196
|
+
function extractMailbox(filePath) {
|
|
197
|
+
const match = filePath.match(/([^/]+)\.mbox/);
|
|
198
|
+
return match ? match[1] : "Unknown";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Mailbox priority for deduplication - lower number = higher priority
|
|
202
|
+
// When the same email exists in multiple folders (IMAP behavior), we prefer
|
|
203
|
+
// to index the copy from higher-priority folders so mailbox searches work correctly
|
|
204
|
+
const MAILBOX_PRIORITY = {
|
|
205
|
+
// Highest priority - user's primary mailboxes
|
|
206
|
+
"INBOX": 1,
|
|
207
|
+
"Sent": 2,
|
|
208
|
+
"Sent Messages": 2,
|
|
209
|
+
"Sent Mail": 2,
|
|
210
|
+
"Drafts": 3,
|
|
211
|
+
"Flagged": 4,
|
|
212
|
+
|
|
213
|
+
// Medium priority - organizational folders
|
|
214
|
+
"Archive": 5,
|
|
215
|
+
"Archives": 5,
|
|
216
|
+
|
|
217
|
+
// Low priority - catch-all folders
|
|
218
|
+
"All Mail": 90,
|
|
219
|
+
"[Gmail]": 90,
|
|
220
|
+
|
|
221
|
+
// Lowest priority - folders users rarely search intentionally
|
|
222
|
+
"Junk": 95,
|
|
223
|
+
"Spam": 95,
|
|
224
|
+
"Junk E-mail": 95,
|
|
225
|
+
"Trash": 99,
|
|
226
|
+
"Deleted Messages": 99,
|
|
227
|
+
"Deleted Items": 99,
|
|
228
|
+
"Bin": 99
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Get priority for a mailbox (lower = higher priority)
|
|
232
|
+
// Unknown/custom folders get priority 50 (between Archive and All Mail)
|
|
233
|
+
function getMailboxPriority(filePath) {
|
|
234
|
+
const mailbox = extractMailbox(filePath);
|
|
235
|
+
return MAILBOX_PRIORITY[mailbox] ?? 50;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function parseEmlx(filePath) {
|
|
239
|
+
try {
|
|
240
|
+
const rawContent = fs.readFileSync(filePath, "utf-8");
|
|
241
|
+
const isPartial = filePath.endsWith(".partial.emlx");
|
|
242
|
+
|
|
243
|
+
// Handle Apple Mail envelope format: first line is byte count, followed by newline
|
|
244
|
+
// Strip the preamble to get the actual RFC822 email content
|
|
245
|
+
let content = rawContent;
|
|
246
|
+
const lines = rawContent.split("\n");
|
|
247
|
+
if (lines[0] && /^\d+\s*$/.test(lines[0])) {
|
|
248
|
+
// First line is a number (byte count) with optional whitespace, skip it
|
|
249
|
+
content = lines.slice(1).join("\n");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Extract headers
|
|
253
|
+
const fromMatch = content.match(/^From:\s*(.+)$/m);
|
|
254
|
+
const subjectMatch = content.match(/^Subject:\s*(.+)$/m);
|
|
255
|
+
const dateMatch = content.match(/^Date:\s*(.+)$/m);
|
|
256
|
+
const toMatch = content.match(/^To:\s*(.+)$/m);
|
|
257
|
+
const ccMatch = content.match(/^Cc:\s*(.+)$/m);
|
|
258
|
+
const messageIdMatch = content.match(/^Message-ID:\s*(.+)$/im);
|
|
259
|
+
const flaggedMatch = content.match(/^X-Flagged:\s*(.+)$/im) || content.match(/flags.*flagged/i);
|
|
260
|
+
|
|
261
|
+
// Check for attachments
|
|
262
|
+
const hasAttachment = /Content-Disposition:\s*attachment/i.test(content) ||
|
|
263
|
+
/multipart\/mixed/i.test(content) ||
|
|
264
|
+
/filename=/i.test(content);
|
|
265
|
+
|
|
266
|
+
// Extract body
|
|
267
|
+
const headerEnd = content.search(/\r?\n\r?\n/);
|
|
268
|
+
let body = "";
|
|
269
|
+
if (headerEnd > 0) {
|
|
270
|
+
body = content.substring(headerEnd + 2, Math.min(headerEnd + 2000, content.length));
|
|
271
|
+
// Use safe HTML stripping to prevent ReDoS
|
|
272
|
+
body = stripHtmlTags(body);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const fromRaw = fromMatch?.[1]?.trim() || "";
|
|
276
|
+
const toRaw = toMatch?.[1]?.trim() || "";
|
|
277
|
+
const ccRaw = ccMatch?.[1]?.trim() || "";
|
|
278
|
+
const subject = subjectMatch?.[1]?.trim() || "";
|
|
279
|
+
const date = dateMatch?.[1]?.trim() || "";
|
|
280
|
+
|
|
281
|
+
// Extract normalized email addresses
|
|
282
|
+
const fromEmail = extractEmail(fromRaw);
|
|
283
|
+
const toEmails = extractEmails(toRaw);
|
|
284
|
+
const ccEmails = extractEmails(ccRaw);
|
|
285
|
+
const allRecipients = [...toEmails, ...ccEmails];
|
|
286
|
+
|
|
287
|
+
// Parse date to timestamp
|
|
288
|
+
const dateTimestamp = parseDateTime(date);
|
|
289
|
+
|
|
290
|
+
// Determine mailbox and if sent
|
|
291
|
+
const mailbox = extractMailbox(filePath);
|
|
292
|
+
const isSent = mailbox.toLowerCase().includes("sent");
|
|
293
|
+
const isFlagged = !!flaggedMatch;
|
|
294
|
+
|
|
295
|
+
const searchText = `From: ${fromRaw}\nTo: ${toRaw}\nSubject: ${subject}\n${body}`.substring(0, 1000);
|
|
296
|
+
const messageId = messageIdMatch?.[1]?.trim() || "";
|
|
297
|
+
|
|
298
|
+
// For partial emails, allow indexing even with minimal data
|
|
299
|
+
// Partial emails may lack complete headers but should still be indexed
|
|
300
|
+
const hasMinimalContent = subject.length > 0 || fromRaw.length > 0 || searchText.length > 10;
|
|
301
|
+
|
|
302
|
+
if (!hasMinimalContent && isPartial) {
|
|
303
|
+
console.error(`[PARTIAL] Skipping ${filePath}: insufficient content (subject: ${subject.length}, from: ${fromRaw.length}, searchText: ${searchText.length})`);
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
from: fromRaw,
|
|
309
|
+
fromEmail,
|
|
310
|
+
to: toRaw,
|
|
311
|
+
toEmails: allRecipients.join(","),
|
|
312
|
+
subject,
|
|
313
|
+
date,
|
|
314
|
+
dateTimestamp,
|
|
315
|
+
hasAttachment,
|
|
316
|
+
mailbox,
|
|
317
|
+
isSent,
|
|
318
|
+
isFlagged,
|
|
319
|
+
messageId,
|
|
320
|
+
body: body.substring(0, 500),
|
|
321
|
+
searchText,
|
|
322
|
+
filePath
|
|
323
|
+
};
|
|
324
|
+
} catch (e) {
|
|
325
|
+
const fileName = filePath.split("/").pop();
|
|
326
|
+
const isPartial = filePath.endsWith(".partial.emlx");
|
|
327
|
+
console.error(`[PARSE_ERROR] ${isPartial ? "PARTIAL" : "COMPLETE"} ${fileName}: ${e.message}`);
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Full scan - used for first run and rebuild_index
|
|
333
|
+
// Includes both .emlx and .partial.emlx files (partial = not fully downloaded via IMAP)
|
|
334
|
+
async function findAllEmlxFiles() {
|
|
335
|
+
try {
|
|
336
|
+
// Find both .emlx and .partial.emlx files
|
|
337
|
+
const cmd = `find "${MAIL_DIR}" \\( -name "*.emlx" -o -name "*.partial.emlx" \\) 2>/dev/null`;
|
|
338
|
+
const { stdout } = await execAsync(cmd, { encoding: "utf-8", maxBuffer: 50 * 1024 * 1024, timeout: 120000 });
|
|
339
|
+
return stdout.trim().split("\n").filter(f => f);
|
|
340
|
+
} catch (e) {
|
|
341
|
+
console.error("Error finding emlx files:", e.message);
|
|
342
|
+
return [];
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Fast incremental scan - uses find with -mtime filter (more reliable than mdfind/Spotlight)
|
|
347
|
+
async function findNewEmlxFiles(sinceTimestamp) {
|
|
348
|
+
if (!sinceTimestamp) {
|
|
349
|
+
// First run - fall back to full scan
|
|
350
|
+
console.error("No previous index timestamp, doing full scan...");
|
|
351
|
+
return findAllEmlxFiles();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
// Convert timestamp to days ago for -mtime filter
|
|
356
|
+
// -mtime -N means modified in the last N days
|
|
357
|
+
const daysAgo = Math.ceil((Date.now() - sinceTimestamp) / (24 * 60 * 60 * 1000));
|
|
358
|
+
|
|
359
|
+
// Use find with -mtime instead of mdfind for reliability
|
|
360
|
+
// find is more reliable than Spotlight which can have stale/incomplete indexes
|
|
361
|
+
const cmd = `find "${MAIL_DIR}" \\( -name "*.emlx" -o -name "*.partial.emlx" \\) -mtime -${daysAgo} 2>/dev/null`;
|
|
362
|
+
const { stdout } = await execAsync(cmd, { encoding: "utf-8", maxBuffer: 50 * 1024 * 1024, timeout: 120000 });
|
|
363
|
+
const files = stdout.trim().split("\n").filter(f => f);
|
|
364
|
+
console.error(`find found ${files.length} new/modified emails in last ${daysAgo} days`);
|
|
365
|
+
return files;
|
|
366
|
+
} catch (e) {
|
|
367
|
+
console.error("find failed, falling back to full scan:", e.message);
|
|
368
|
+
return findAllEmlxFiles();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ============ MESSAGES INDEXING ============
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Extract text from NSAttributedString BLOB (attributedBody field)
|
|
376
|
+
* macOS stores message text in attributedBody as NSKeyedArchiver format
|
|
377
|
+
* The text is embedded as UTF-8 after the NSString class marker
|
|
378
|
+
*/
|
|
379
|
+
/**
|
|
380
|
+
* Validate extracted text to ensure it's not garbage
|
|
381
|
+
*/
|
|
382
|
+
function validateExtractedText(text) {
|
|
383
|
+
if (!text || typeof text !== 'string') return false;
|
|
384
|
+
text = text.trim();
|
|
385
|
+
if (text.length < 1 || text.length > 10000) return false;
|
|
386
|
+
|
|
387
|
+
// Must be mostly printable characters
|
|
388
|
+
const printableRatio = (text.match(/[\x20-\x7E\u00A0-\uFFFF]/g) || []).length / text.length;
|
|
389
|
+
return printableRatio >= 0.8;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Strategy 1: Current NSString+'+' pattern (backward compatibility)
|
|
394
|
+
*/
|
|
395
|
+
function extractStrategy1_CurrentPattern(buf) {
|
|
396
|
+
const nsStringMarker = Buffer.from('NSString');
|
|
397
|
+
let markerIndex = buf.indexOf(nsStringMarker);
|
|
398
|
+
if (markerIndex === -1) return null;
|
|
399
|
+
|
|
400
|
+
const searchStart = markerIndex + nsStringMarker.length;
|
|
401
|
+
const plusIndex = buf.indexOf(0x2B, searchStart); // '+' character
|
|
402
|
+
if (plusIndex === -1 || plusIndex >= buf.length - 2) return null;
|
|
403
|
+
|
|
404
|
+
const lengthByte = buf[plusIndex + 1];
|
|
405
|
+
const textStart = plusIndex + 2;
|
|
406
|
+
if (lengthByte === 0 || textStart >= buf.length) return null;
|
|
407
|
+
|
|
408
|
+
let textLength = lengthByte;
|
|
409
|
+
let actualTextStart = textStart;
|
|
410
|
+
|
|
411
|
+
if (lengthByte & 0x80) {
|
|
412
|
+
actualTextStart = textStart;
|
|
413
|
+
textLength = Math.min(500, buf.length - actualTextStart);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const textEnd = Math.min(actualTextStart + textLength, buf.length);
|
|
417
|
+
let text = buf.slice(actualTextStart, textEnd).toString('utf-8');
|
|
418
|
+
|
|
419
|
+
const cleanEnd = text.search(/[\x00-\x08\x0B\x0C\x0E-\x1F]|(\x84\x84)/);
|
|
420
|
+
if (cleanEnd > 0) {
|
|
421
|
+
text = text.substring(0, cleanEnd);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return text.trim();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Strategy 2: Direct UTF-8 scanning - find longest printable sequence
|
|
429
|
+
*/
|
|
430
|
+
function extractStrategy2_DirectUTF8Scan(buf) {
|
|
431
|
+
const fullText = buf.toString('utf-8');
|
|
432
|
+
// Find sequences of printable characters at least 20 chars long
|
|
433
|
+
const regex = /[\x20-\x7E\u00A0-\uFFFF]{20,}/g;
|
|
434
|
+
const matches = fullText.match(regex);
|
|
435
|
+
|
|
436
|
+
if (!matches || matches.length === 0) return null;
|
|
437
|
+
|
|
438
|
+
// Return longest match
|
|
439
|
+
return matches.reduce((a, b) => a.length > b.length ? a : b).trim();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Strategy 3: NSKeyedArchiver $objects parser - handles modern macOS format
|
|
444
|
+
*/
|
|
445
|
+
function extractStrategy3_NSKeyedArchiver(buf) {
|
|
446
|
+
const objectsMarker = Buffer.from('$objects');
|
|
447
|
+
const objectsIndex = buf.indexOf(objectsMarker);
|
|
448
|
+
if (objectsIndex === -1) return null;
|
|
449
|
+
|
|
450
|
+
const searchStart = objectsIndex + objectsMarker.length;
|
|
451
|
+
const nsStringMarker = Buffer.from('NSString');
|
|
452
|
+
const extractedTexts = [];
|
|
453
|
+
|
|
454
|
+
let currentPos = searchStart;
|
|
455
|
+
while (currentPos < buf.length - 100) {
|
|
456
|
+
const nsIdx = buf.indexOf(nsStringMarker, currentPos);
|
|
457
|
+
if (nsIdx === -1) break;
|
|
458
|
+
|
|
459
|
+
// Try to extract text after this NSString marker
|
|
460
|
+
const textStart = nsIdx + nsStringMarker.length + 10; // Skip class definition
|
|
461
|
+
const potentialText = buf.slice(textStart, Math.min(textStart + 500, buf.length))
|
|
462
|
+
.toString('utf-8');
|
|
463
|
+
|
|
464
|
+
const cleanEnd = potentialText.search(/[\x00-\x08\x0B\x0C\x0E-\x1F]/);
|
|
465
|
+
if (cleanEnd > 5) {
|
|
466
|
+
const extracted = potentialText.substring(0, cleanEnd).trim();
|
|
467
|
+
if (extracted.length > 10) {
|
|
468
|
+
extractedTexts.push(extracted);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
currentPos = nsIdx + 20;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (extractedTexts.length === 0) return null;
|
|
476
|
+
// Return longest extracted text
|
|
477
|
+
return extractedTexts.reduce((a, b) => a.length > b.length ? a : b);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Strategy 4: Regex-based pattern matching for common message structures
|
|
482
|
+
*/
|
|
483
|
+
function extractStrategy4_RegexBased(buf) {
|
|
484
|
+
const text = buf.toString('utf-8', 0, Math.min(buf.length, 2000));
|
|
485
|
+
|
|
486
|
+
// Common patterns in messages
|
|
487
|
+
const patterns = [
|
|
488
|
+
/(?:wrote|said):\s*\n?\s*(.{20,})/i, // Reply pattern
|
|
489
|
+
/"(.{20,})"/, // Quoted text
|
|
490
|
+
/\n\s*([A-Z].{20,})\s*\n/, // Paragraph pattern
|
|
491
|
+
];
|
|
492
|
+
|
|
493
|
+
for (const pattern of patterns) {
|
|
494
|
+
const match = text.match(pattern);
|
|
495
|
+
if (match && match[1]) {
|
|
496
|
+
const extracted = match[1].trim();
|
|
497
|
+
// Clean up control characters
|
|
498
|
+
const cleanEnd = extracted.search(/[\x00-\x08\x0B\x0C\x0E-\x1F]/);
|
|
499
|
+
if (cleanEnd > 20) {
|
|
500
|
+
return extracted.substring(0, cleanEnd).trim();
|
|
501
|
+
} else if (cleanEnd === -1 && extracted.length > 20) {
|
|
502
|
+
return extracted;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Multi-strategy attributedBody text extraction
|
|
512
|
+
* Tries multiple approaches to extract text from binary NSAttributedString format
|
|
513
|
+
*/
|
|
514
|
+
function extractTextFromAttributedBody(buffer) {
|
|
515
|
+
if (!buffer || buffer.length === 0) return null;
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
519
|
+
|
|
520
|
+
// Try multiple strategies in order
|
|
521
|
+
const strategies = [
|
|
522
|
+
extractStrategy1_CurrentPattern, // Keep for backward compatibility
|
|
523
|
+
extractStrategy2_DirectUTF8Scan, // Fast, works on most formats
|
|
524
|
+
extractStrategy3_NSKeyedArchiver, // Modern macOS NSAttributedString
|
|
525
|
+
extractStrategy4_RegexBased // Pattern-based extraction
|
|
526
|
+
];
|
|
527
|
+
|
|
528
|
+
for (const strategy of strategies) {
|
|
529
|
+
try {
|
|
530
|
+
const text = strategy(buf);
|
|
531
|
+
if (text && validateExtractedText(text)) {
|
|
532
|
+
return text;
|
|
533
|
+
}
|
|
534
|
+
} catch (e) {
|
|
535
|
+
// Try next strategy
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return null;
|
|
541
|
+
} catch (e) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function getMessages(sinceTimestamp = null) {
|
|
547
|
+
try {
|
|
548
|
+
// Enhanced query to get chat info, group chat detection, and attachments
|
|
549
|
+
// Include attributedBody for messages where text is NULL (newer macOS format)
|
|
550
|
+
// sinceTimestamp is Unix ms - convert to Mac Absolute Time nanoseconds
|
|
551
|
+
let dateFilter = '';
|
|
552
|
+
|
|
553
|
+
// NOTE: Index ALL messages without any date filtering
|
|
554
|
+
// Only emails use the DAYS_BACK filter - messages and calendar are comprehensive
|
|
555
|
+
|
|
556
|
+
if (sinceTimestamp) {
|
|
557
|
+
const macAbsoluteNs = (sinceTimestamp / 1000 - MAC_ABSOLUTE_EPOCH) * 1000000000;
|
|
558
|
+
dateFilter = `AND m.date >= ${macAbsoluteNs}`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const query = `
|
|
562
|
+
SELECT
|
|
563
|
+
m.ROWID as id,
|
|
564
|
+
datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') as date,
|
|
565
|
+
m.date/1000000000 + 978307200 as dateTimestamp,
|
|
566
|
+
CASE WHEN m.is_from_me = 1 THEN 'Me' ELSE coalesce(h.id, 'Unknown') END as sender,
|
|
567
|
+
m.text,
|
|
568
|
+
CASE WHEN m.text IS NULL OR m.text = '' THEN hex(m.attributedBody) ELSE NULL END as attributedBodyHex,
|
|
569
|
+
c.ROWID as chatId,
|
|
570
|
+
c.chat_identifier as chatIdentifier,
|
|
571
|
+
c.display_name as chatName,
|
|
572
|
+
(SELECT COUNT(*) FROM chat_handle_join WHERE chat_id = c.ROWID) as participantCount,
|
|
573
|
+
(SELECT COUNT(*) FROM message_attachment_join WHERE message_id = m.ROWID) as attachmentCount
|
|
574
|
+
FROM message m
|
|
575
|
+
LEFT JOIN handle h ON m.handle_id = h.ROWID
|
|
576
|
+
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
|
|
577
|
+
LEFT JOIN chat c ON cmj.chat_id = c.ROWID
|
|
578
|
+
WHERE ((m.text IS NOT NULL AND m.text <> '') OR m.attributedBody IS NOT NULL)
|
|
579
|
+
${dateFilter}
|
|
580
|
+
ORDER BY m.date DESC
|
|
581
|
+
`;
|
|
582
|
+
const results = safeSqlite3Json(MESSAGES_DB, query, { timeout: 60000 });
|
|
583
|
+
|
|
584
|
+
// Post-process: extract text from attributedBody where text is NULL
|
|
585
|
+
let extractedCount = 0;
|
|
586
|
+
for (const msg of results) {
|
|
587
|
+
if ((!msg.text || msg.text === '') && msg.attributedBodyHex) {
|
|
588
|
+
try {
|
|
589
|
+
const buffer = Buffer.from(msg.attributedBodyHex, 'hex');
|
|
590
|
+
const extracted = extractTextFromAttributedBody(buffer);
|
|
591
|
+
if (extracted) {
|
|
592
|
+
msg.text = extracted;
|
|
593
|
+
extractedCount++;
|
|
594
|
+
}
|
|
595
|
+
} catch (e) {
|
|
596
|
+
// Skip this message if extraction fails
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// Clean up - don't keep the hex blob in memory
|
|
600
|
+
delete msg.attributedBodyHex;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (extractedCount > 0) {
|
|
604
|
+
console.error(`Extracted text from attributedBody for ${extractedCount} messages`);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Filter out messages that still have no text
|
|
608
|
+
return results.filter(msg => msg.text && msg.text.trim() !== '');
|
|
609
|
+
} catch (e) {
|
|
610
|
+
console.error("Error reading messages:", e.message);
|
|
611
|
+
return [];
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ============ CALENDAR INDEXING ============
|
|
616
|
+
|
|
617
|
+
// Convert Mac Absolute Time to Unix timestamp (ms)
|
|
618
|
+
function macAbsoluteToUnixMs(macTime) {
|
|
619
|
+
if (!macTime) return 0;
|
|
620
|
+
return (macTime + MAC_ABSOLUTE_EPOCH) * 1000;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Convert Unix timestamp (ms) to Mac Absolute Time
|
|
624
|
+
function unixMsToMacAbsolute(unixMs) {
|
|
625
|
+
return (unixMs / 1000) - MAC_ABSOLUTE_EPOCH;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Format date from Mac Absolute Time
|
|
629
|
+
function formatMacAbsoluteDate(macTime) {
|
|
630
|
+
if (!macTime) return "";
|
|
631
|
+
const date = new Date(macAbsoluteToUnixMs(macTime));
|
|
632
|
+
return date.toLocaleString();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Map participant status codes to human-readable strings
|
|
636
|
+
function getParticipantStatus(status) {
|
|
637
|
+
const statusMap = {
|
|
638
|
+
0: "unknown",
|
|
639
|
+
1: "accepted",
|
|
640
|
+
2: "declined",
|
|
641
|
+
3: "tentative",
|
|
642
|
+
4: "pending",
|
|
643
|
+
7: "needs-action"
|
|
644
|
+
};
|
|
645
|
+
return statusMap[status] || "unknown";
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function getCalendarEvents() {
|
|
649
|
+
try {
|
|
650
|
+
// NOTE: We index ALL calendar events, not filtered by date
|
|
651
|
+
// Calendar events don't have a "file modification time" like emails do,
|
|
652
|
+
// so we can't use the mdfind + DAYS_BACK approach.
|
|
653
|
+
// Calendar indexing is always comprehensive - filtering by date would lose historical context.
|
|
654
|
+
const now = Date.now();
|
|
655
|
+
const pastDate = unixMsToMacAbsolute(now - 10 * 365 * 24 * 60 * 60 * 1000); // 10 years back
|
|
656
|
+
const futureDate = unixMsToMacAbsolute(now + 10 * 365 * 24 * 60 * 60 * 1000); // 10 years ahead
|
|
657
|
+
|
|
658
|
+
// Query OccurrenceCache for recurring events and their calculated occurrences
|
|
659
|
+
// This includes both recurring and non-recurring events
|
|
660
|
+
// GROUP BY to avoid duplicate entries for recurring events
|
|
661
|
+
const query = `
|
|
662
|
+
SELECT
|
|
663
|
+
ci.ROWID as id,
|
|
664
|
+
ci.summary,
|
|
665
|
+
MIN(COALESCE(oc.occurrence_end_date - (ci.end_date - ci.start_date), ci.start_date)) as start_date,
|
|
666
|
+
MAX(COALESCE(oc.occurrence_end_date, ci.end_date)) as end_date,
|
|
667
|
+
ci.all_day,
|
|
668
|
+
ci.description,
|
|
669
|
+
c.title as calendar_name,
|
|
670
|
+
l.title as location,
|
|
671
|
+
COUNT(DISTINCT oc.day) as occurrenceCount
|
|
672
|
+
FROM OccurrenceCache oc
|
|
673
|
+
INNER JOIN CalendarItem ci ON oc.event_id = ci.ROWID
|
|
674
|
+
LEFT JOIN Calendar c ON ci.calendar_id = c.ROWID
|
|
675
|
+
LEFT JOIN Location l ON ci.location_id = l.ROWID
|
|
676
|
+
WHERE oc.day IS NOT NULL
|
|
677
|
+
AND oc.day >= ${pastDate}
|
|
678
|
+
AND oc.day <= ${futureDate}
|
|
679
|
+
GROUP BY ci.ROWID
|
|
680
|
+
ORDER BY MIN(oc.day) ASC
|
|
681
|
+
`;
|
|
682
|
+
|
|
683
|
+
const rows = safeSqlite3Json(CALENDAR_DB, query, { timeout: 30000 });
|
|
684
|
+
|
|
685
|
+
// Get attendees for events that have them (separate query for efficiency)
|
|
686
|
+
const attendeesQuery = `
|
|
687
|
+
SELECT
|
|
688
|
+
p.owner_id,
|
|
689
|
+
COALESCE(i.display_name, p.email, 'Unknown') as name,
|
|
690
|
+
p.status
|
|
691
|
+
FROM Participant p
|
|
692
|
+
LEFT JOIN Identity i ON p.identity_id = i.ROWID
|
|
693
|
+
WHERE p.entity_type = 0
|
|
694
|
+
`;
|
|
695
|
+
|
|
696
|
+
let attendeesMap = new Map();
|
|
697
|
+
try {
|
|
698
|
+
const attendeesRows = safeSqlite3Json(CALENDAR_DB, attendeesQuery, { timeout: 10000 });
|
|
699
|
+
|
|
700
|
+
// Group attendees by owner_id (event id)
|
|
701
|
+
for (const att of attendeesRows) {
|
|
702
|
+
if (!attendeesMap.has(att.owner_id)) {
|
|
703
|
+
attendeesMap.set(att.owner_id, []);
|
|
704
|
+
}
|
|
705
|
+
attendeesMap.get(att.owner_id).push({
|
|
706
|
+
name: att.name,
|
|
707
|
+
status: getParticipantStatus(att.status)
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
} catch (e) {
|
|
711
|
+
console.error("Warning: Could not fetch attendees:", e.message);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const events = [];
|
|
715
|
+
for (const row of rows) {
|
|
716
|
+
if (!row.summary) continue;
|
|
717
|
+
|
|
718
|
+
const startTimestamp = macAbsoluteToUnixMs(row.start_date);
|
|
719
|
+
const attendees = attendeesMap.get(row.id) || [];
|
|
720
|
+
|
|
721
|
+
if (row.all_day === 1) {
|
|
722
|
+
console.error(`[Indexing] All-day event: "${row.summary}" at ${new Date(startTimestamp).toISOString()}`);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
events.push({
|
|
726
|
+
dbId: row.id, // Stable database ID for deduplication
|
|
727
|
+
title: row.summary,
|
|
728
|
+
start: formatMacAbsoluteDate(row.start_date),
|
|
729
|
+
end: formatMacAbsoluteDate(row.end_date),
|
|
730
|
+
calendar: row.calendar_name || "Unknown",
|
|
731
|
+
location: row.location || "",
|
|
732
|
+
notes: row.description || "",
|
|
733
|
+
isAllDay: row.all_day === 1,
|
|
734
|
+
startTimestamp,
|
|
735
|
+
attendees: JSON.stringify(attendees),
|
|
736
|
+
attendeeCount: attendees.length
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
console.error(`Calendar: Retrieved ${events.length} events via SQLite (~${Math.round((Date.now() - now))}ms)`);
|
|
741
|
+
return events;
|
|
742
|
+
} catch (e) {
|
|
743
|
+
console.error("Error reading calendar:", e.message);
|
|
744
|
+
return [];
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// ============ DATABASE FUNCTIONS ============
|
|
749
|
+
|
|
750
|
+
export async function initDB() {
|
|
751
|
+
if (db) return { db, tables };
|
|
752
|
+
|
|
753
|
+
fs.mkdirSync(INDEX_DIR, { recursive: true });
|
|
754
|
+
db = await lancedb.connect(INDEX_DIR);
|
|
755
|
+
|
|
756
|
+
const tableNames = await db.tableNames();
|
|
757
|
+
if (tableNames.includes("emails")) {
|
|
758
|
+
tables.emails = await db.openTable("emails");
|
|
759
|
+
}
|
|
760
|
+
if (tableNames.includes("messages")) {
|
|
761
|
+
tables.messages = await db.openTable("messages");
|
|
762
|
+
}
|
|
763
|
+
if (tableNames.includes("calendar")) {
|
|
764
|
+
tables.calendar = await db.openTable("calendar");
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
return { db, tables };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export async function clearEmailsTable() {
|
|
771
|
+
await initDB();
|
|
772
|
+
if (tables.emails) {
|
|
773
|
+
await db.dropTable("emails");
|
|
774
|
+
tables.emails = null;
|
|
775
|
+
console.error("Emails table dropped. Re-run indexing to rebuild.");
|
|
776
|
+
return { cleared: true };
|
|
777
|
+
}
|
|
778
|
+
return { cleared: false, message: "No emails table exists" };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
export async function clearMessagesTable() {
|
|
782
|
+
await initDB();
|
|
783
|
+
if (tables.messages) {
|
|
784
|
+
await db.dropTable("messages");
|
|
785
|
+
tables.messages = null;
|
|
786
|
+
console.error("Messages table dropped. Re-run indexing to rebuild.");
|
|
787
|
+
return { cleared: true };
|
|
788
|
+
}
|
|
789
|
+
return { cleared: false, message: "No messages table exists" };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export async function clearCalendarTable() {
|
|
793
|
+
await initDB();
|
|
794
|
+
if (tables.calendar) {
|
|
795
|
+
await db.dropTable("calendar");
|
|
796
|
+
tables.calendar = null;
|
|
797
|
+
console.error("Calendar table dropped. Re-run indexing to rebuild.");
|
|
798
|
+
return { cleared: true };
|
|
799
|
+
}
|
|
800
|
+
return { cleared: false, message: "No calendar table exists" };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export async function rebuildIndex(sources = ["emails", "messages", "calendar"], progressCallback = null) {
|
|
804
|
+
const results = {
|
|
805
|
+
cleared: {},
|
|
806
|
+
indexed: {},
|
|
807
|
+
errors: []
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
// Clear requested sources
|
|
811
|
+
for (const source of sources) {
|
|
812
|
+
try {
|
|
813
|
+
if (source === "emails") {
|
|
814
|
+
const clearResult = await clearEmailsTable();
|
|
815
|
+
results.cleared.emails = clearResult.cleared;
|
|
816
|
+
} else if (source === "messages") {
|
|
817
|
+
const clearResult = await clearMessagesTable();
|
|
818
|
+
results.cleared.messages = clearResult.cleared;
|
|
819
|
+
} else if (source === "calendar") {
|
|
820
|
+
const clearResult = await clearCalendarTable();
|
|
821
|
+
results.cleared.calendar = clearResult.cleared;
|
|
822
|
+
}
|
|
823
|
+
} catch (e) {
|
|
824
|
+
results.errors.push({ source, phase: "clear", error: e.message });
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Reset module-level cache after dropping tables
|
|
829
|
+
// This ensures that initDB() will re-initialize and pick up the newly created tables
|
|
830
|
+
db = null;
|
|
831
|
+
tables = {};
|
|
832
|
+
|
|
833
|
+
// Re-index requested sources
|
|
834
|
+
for (const source of sources) {
|
|
835
|
+
try {
|
|
836
|
+
if (source === "emails") {
|
|
837
|
+
if (progressCallback) progressCallback("Indexing emails...");
|
|
838
|
+
// Force full scan for rebuild (forceFullScan = true)
|
|
839
|
+
const indexResult = await indexEmails(progressCallback, true);
|
|
840
|
+
results.indexed.emails = indexResult;
|
|
841
|
+
} else if (source === "messages") {
|
|
842
|
+
if (progressCallback) progressCallback("Indexing messages...");
|
|
843
|
+
// Force full scan for rebuild (forceFullScan = true)
|
|
844
|
+
const indexResult = await indexMessages(true);
|
|
845
|
+
results.indexed.messages = indexResult;
|
|
846
|
+
} else if (source === "calendar") {
|
|
847
|
+
if (progressCallback) progressCallback("Indexing calendar...");
|
|
848
|
+
const indexResult = await indexCalendar();
|
|
849
|
+
results.indexed.calendar = indexResult;
|
|
850
|
+
}
|
|
851
|
+
} catch (e) {
|
|
852
|
+
results.errors.push({ source, phase: "index", error: e.message });
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
return results;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function getIndexedIds(tableName, idField) {
|
|
860
|
+
await initDB();
|
|
861
|
+
if (!tables[tableName]) return new Set();
|
|
862
|
+
|
|
863
|
+
try {
|
|
864
|
+
const results = await tables[tableName].query().select([idField]).toArray();
|
|
865
|
+
return new Set(results.map(r => r[idField]));
|
|
866
|
+
} catch (e) {
|
|
867
|
+
// THROW instead of returning empty Set to prevent duplicate entries
|
|
868
|
+
console.error(`Error getting indexed IDs from ${tableName}: ${e.message}`);
|
|
869
|
+
throw new Error(`Failed to get indexed IDs from ${tableName}: ${e.message}`);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Get indexed IDs with retry logic to prevent race conditions
|
|
875
|
+
* @param {string} tableName - Table name
|
|
876
|
+
* @param {string} idField - ID field name
|
|
877
|
+
* @param {number} maxRetries - Maximum retry attempts (default: 3)
|
|
878
|
+
* @returns {Promise<Set>} Set of indexed IDs
|
|
879
|
+
*/
|
|
880
|
+
async function getIndexedIdsWithRetry(tableName, idField, maxRetries = 3) {
|
|
881
|
+
let lastError;
|
|
882
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
883
|
+
try {
|
|
884
|
+
return await getIndexedIds(tableName, idField);
|
|
885
|
+
} catch (e) {
|
|
886
|
+
lastError = e;
|
|
887
|
+
if (attempt < maxRetries) {
|
|
888
|
+
const backoff = Math.min(1000 * Math.pow(2, attempt - 1), 5000); // Exponential backoff, max 5s
|
|
889
|
+
console.error(`Retry ${attempt}/${maxRetries} after ${backoff}ms...`);
|
|
890
|
+
await new Promise(resolve => setTimeout(resolve, backoff));
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
throw lastError;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// ============ INDEX ALL CONTENT ============
|
|
898
|
+
|
|
899
|
+
export async function indexEmails(progressCallback = null, forceFullScan = false) {
|
|
900
|
+
await initDB();
|
|
901
|
+
|
|
902
|
+
// Load last index timestamp for incremental scanning
|
|
903
|
+
const meta = loadIndexMeta();
|
|
904
|
+
// Use a 1-day buffer to catch any edge cases (files modified during previous scan, etc.)
|
|
905
|
+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
906
|
+
|
|
907
|
+
// Determine the timestamp for mdfind filtering
|
|
908
|
+
let lastEmailIndexTime;
|
|
909
|
+
if (DAYS_BACK) {
|
|
910
|
+
// DAYS_BACK is set - use it for mdfind filter (e.g., for testing or rebuild with constraints)
|
|
911
|
+
lastEmailIndexTime = Date.now() - (DAYS_BACK * 24 * 60 * 60 * 1000);
|
|
912
|
+
console.error(`Using DAYS_BACK=${DAYS_BACK} for mdfind filter`);
|
|
913
|
+
} else if (forceFullScan) {
|
|
914
|
+
// Force full scan only when no DAYS_BACK constraint
|
|
915
|
+
lastEmailIndexTime = null;
|
|
916
|
+
console.error("Force full scan requested - finding all emails");
|
|
917
|
+
} else if (meta.lastEmailIndexTime) {
|
|
918
|
+
lastEmailIndexTime = meta.lastEmailIndexTime - ONE_DAY_MS;
|
|
919
|
+
} else {
|
|
920
|
+
lastEmailIndexTime = null;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Save the current time BEFORE we start - any emails arriving during indexing
|
|
924
|
+
// will be picked up on the next incremental scan
|
|
925
|
+
const indexStartTime = Date.now();
|
|
926
|
+
|
|
927
|
+
// Use fast incremental scan if we have a previous timestamp
|
|
928
|
+
const startTime = Date.now();
|
|
929
|
+
const newFiles = await findNewEmlxFiles(lastEmailIndexTime);
|
|
930
|
+
console.error(`Found ${newFiles.length} new/modified email files (${Date.now() - startTime}ms)`);
|
|
931
|
+
|
|
932
|
+
const indexedPaths = await getIndexedIdsWithRetry("emails", "filePath");
|
|
933
|
+
const indexedMessageIds = await getIndexedIdsWithRetry("emails", "messageId");
|
|
934
|
+
console.error(`Already indexed: ${indexedPaths.size} emails`);
|
|
935
|
+
|
|
936
|
+
// Filter out already-indexed files
|
|
937
|
+
let toIndex = newFiles.filter(f => !indexedPaths.has(f));
|
|
938
|
+
console.error(`Need to index: ${toIndex.length} emails`);
|
|
939
|
+
|
|
940
|
+
// Sort by mailbox priority so higher-priority folders are indexed first
|
|
941
|
+
// This ensures INBOX copies are kept over Junk/Trash copies during deduplication
|
|
942
|
+
toIndex.sort((a, b) => getMailboxPriority(a) - getMailboxPriority(b));
|
|
943
|
+
|
|
944
|
+
if (toIndex.length === 0) {
|
|
945
|
+
// Save timestamp for next incremental scan
|
|
946
|
+
saveIndexMeta({ ...meta, lastEmailIndexTime: indexStartTime });
|
|
947
|
+
return { indexed: indexedPaths.size, added: 0 };
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
let processed = 0;
|
|
951
|
+
let skippedCount = { parseNull: 0, shortSearchText: 0, duplicateMessageId: 0 };
|
|
952
|
+
|
|
953
|
+
for (let i = 0; i < toIndex.length; i += BATCH_SIZE) {
|
|
954
|
+
const batch = toIndex.slice(i, i + BATCH_SIZE);
|
|
955
|
+
|
|
956
|
+
// Parse all emails in batch first
|
|
957
|
+
const parsedEmails = [];
|
|
958
|
+
const searchTexts = [];
|
|
959
|
+
|
|
960
|
+
for (const filePath of batch) {
|
|
961
|
+
const parsed = parseEmlx(filePath);
|
|
962
|
+
if (!parsed) {
|
|
963
|
+
skippedCount.parseNull++;
|
|
964
|
+
const fileName = filePath.split("/").pop();
|
|
965
|
+
console.error(`[SKIPPED] Parse failed: ${fileName}`);
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
if (parsed.searchText.length <= 20) {
|
|
969
|
+
skippedCount.shortSearchText++;
|
|
970
|
+
const fileName = filePath.split("/").pop();
|
|
971
|
+
console.error(`[SKIPPED] Short searchText (${parsed.searchText.length} chars): ${fileName} - Subject: "${parsed.subject}"`);
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
// Skip duplicate messageIds - same email in multiple folders (IMAP behavior)
|
|
975
|
+
if (parsed.messageId && indexedMessageIds.has(parsed.messageId)) {
|
|
976
|
+
skippedCount.duplicateMessageId++;
|
|
977
|
+
continue; // Skip this duplicate
|
|
978
|
+
}
|
|
979
|
+
// NOTE: Don't filter by email header date - mdfind already filters by file modification time
|
|
980
|
+
// The DAYS_BACK filter is applied at the mdfind level (finding recently modified files),
|
|
981
|
+
// not at the email content level (email header dates can be old for recently modified messages)
|
|
982
|
+
parsedEmails.push({ filePath, parsed });
|
|
983
|
+
searchTexts.push(parsed.searchText);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Generate all embeddings in a single batch call
|
|
987
|
+
let vectors = [];
|
|
988
|
+
if (searchTexts.length > 0) {
|
|
989
|
+
try {
|
|
990
|
+
vectors = await embedBatch(searchTexts);
|
|
991
|
+
} catch (e) {
|
|
992
|
+
console.error("Batch embedding error:", e.message);
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Build records with vectors
|
|
998
|
+
const records = parsedEmails.map((item, idx) => ({
|
|
999
|
+
filePath: item.filePath,
|
|
1000
|
+
from: item.parsed.from,
|
|
1001
|
+
fromEmail: item.parsed.fromEmail,
|
|
1002
|
+
to: item.parsed.to,
|
|
1003
|
+
toEmails: item.parsed.toEmails,
|
|
1004
|
+
subject: item.parsed.subject,
|
|
1005
|
+
date: item.parsed.date,
|
|
1006
|
+
dateTimestamp: item.parsed.dateTimestamp,
|
|
1007
|
+
hasAttachment: item.parsed.hasAttachment,
|
|
1008
|
+
mailbox: item.parsed.mailbox,
|
|
1009
|
+
isSent: item.parsed.isSent,
|
|
1010
|
+
isFlagged: item.parsed.isFlagged,
|
|
1011
|
+
messageId: item.parsed.messageId,
|
|
1012
|
+
body: item.parsed.body,
|
|
1013
|
+
vector: vectors[idx]
|
|
1014
|
+
})).filter(r => r.vector); // Only records with vectors
|
|
1015
|
+
|
|
1016
|
+
if (records.length > 0) {
|
|
1017
|
+
// Defensive duplicate check: remove any duplicates within the batch itself
|
|
1018
|
+
const uniqueRecords = [];
|
|
1019
|
+
const seenPaths = new Set();
|
|
1020
|
+
for (const record of records) {
|
|
1021
|
+
if (!seenPaths.has(record.filePath)) {
|
|
1022
|
+
seenPaths.add(record.filePath);
|
|
1023
|
+
uniqueRecords.push(record);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
if (uniqueRecords.length !== records.length) {
|
|
1028
|
+
const duplicatePaths = records
|
|
1029
|
+
.map(r => r.filePath)
|
|
1030
|
+
.filter((path, idx, arr) => arr.indexOf(path) !== idx);
|
|
1031
|
+
console.error(`WARNING: Removed ${records.length - uniqueRecords.length} duplicate(s) from batch. Paths: ${duplicatePaths.slice(0, 5).join(', ')}`);
|
|
1032
|
+
console.error(`This suggests findNewEmlxFiles() is returning duplicate paths. Investigate mdfind query.`);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (uniqueRecords.length > 0) {
|
|
1036
|
+
if (!tables.emails) {
|
|
1037
|
+
tables.emails = await db.createTable("emails", uniqueRecords, { mode: "overwrite" });
|
|
1038
|
+
} else {
|
|
1039
|
+
// Double-check: verify these IDs truly aren't in the index
|
|
1040
|
+
const currentIndexed = await getIndexedIdsWithRetry("emails", "filePath");
|
|
1041
|
+
const trulyNew = uniqueRecords.filter(r => !currentIndexed.has(r.filePath));
|
|
1042
|
+
|
|
1043
|
+
if (trulyNew.length < uniqueRecords.length) {
|
|
1044
|
+
console.error(`WARNING: ${uniqueRecords.length - trulyNew.length} records were already indexed. Filtering them out.`);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
if (trulyNew.length > 0) {
|
|
1048
|
+
const beforeCount = await tables.emails.countRows();
|
|
1049
|
+
await tables.emails.add(trulyNew);
|
|
1050
|
+
const afterCount = await tables.emails.countRows();
|
|
1051
|
+
const actualAdded = afterCount - beforeCount;
|
|
1052
|
+
|
|
1053
|
+
if (actualAdded !== trulyNew.length) {
|
|
1054
|
+
console.error(`WARNING: Expected to add ${trulyNew.length} records but index grew by ${actualAdded}. Possible duplicate issue.`);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// FIX: Update the messageId tracking Set with newly added records
|
|
1058
|
+
// This ensures subsequent batches can detect messageIds from previous batches
|
|
1059
|
+
for (const record of trulyNew) {
|
|
1060
|
+
if (record.messageId) {
|
|
1061
|
+
indexedMessageIds.add(record.messageId);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
processed += batch.length;
|
|
1070
|
+
console.error(`Indexed ${processed}/${toIndex.length} emails...`);
|
|
1071
|
+
|
|
1072
|
+
// Report progress after each batch
|
|
1073
|
+
if (progressCallback) {
|
|
1074
|
+
progressCallback(`emails-batch-${processed}/${toIndex.length}`);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// Throttle to prevent thermal crashes
|
|
1078
|
+
if (i + BATCH_SIZE < toIndex.length) {
|
|
1079
|
+
await new Promise(r => setTimeout(r, BATCH_DELAY_MS));
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Save timestamp for next incremental scan
|
|
1084
|
+
saveIndexMeta({ ...meta, lastEmailIndexTime: indexStartTime });
|
|
1085
|
+
|
|
1086
|
+
// Log skip summary
|
|
1087
|
+
const totalSkipped = skippedCount.parseNull + skippedCount.shortSearchText + skippedCount.duplicateMessageId;
|
|
1088
|
+
console.error(`\nEmail indexing summary:`);
|
|
1089
|
+
console.error(` Files to index: ${toIndex.length}`);
|
|
1090
|
+
console.error(` Successfully indexed: ${processed}`);
|
|
1091
|
+
console.error(` Skipped - parse errors: ${skippedCount.parseNull}`);
|
|
1092
|
+
console.error(` Skipped - short searchText: ${skippedCount.shortSearchText}`);
|
|
1093
|
+
console.error(` Skipped - duplicate messageId: ${skippedCount.duplicateMessageId}`);
|
|
1094
|
+
console.error(` Total skipped: ${totalSkipped}`);
|
|
1095
|
+
console.error(` Discrepancy: ${toIndex.length - processed - totalSkipped}\n`);
|
|
1096
|
+
|
|
1097
|
+
// Return actual indexed count from database, not stale cache
|
|
1098
|
+
const finalIndexedPaths = await getIndexedIdsWithRetry("emails", "filePath");
|
|
1099
|
+
return { indexed: finalIndexedPaths.size, added: processed };
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
export async function indexMessages(forceFullScan = false) {
|
|
1103
|
+
await initDB();
|
|
1104
|
+
|
|
1105
|
+
// Load last index timestamp for incremental scanning
|
|
1106
|
+
const meta = loadIndexMeta();
|
|
1107
|
+
// Use a 1-hour buffer to catch any edge cases
|
|
1108
|
+
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
1109
|
+
const lastMessageIndexTime = forceFullScan ? null :
|
|
1110
|
+
(meta.lastMessageIndexTime ? meta.lastMessageIndexTime - ONE_HOUR_MS : null);
|
|
1111
|
+
|
|
1112
|
+
// Save the current time BEFORE we start
|
|
1113
|
+
const indexStartTime = Date.now();
|
|
1114
|
+
|
|
1115
|
+
// Use incremental scan if we have a previous timestamp
|
|
1116
|
+
const messages = getMessages(lastMessageIndexTime);
|
|
1117
|
+
console.error(`Found ${messages.length} messages${lastMessageIndexTime ? ' (incremental)' : ' (full scan)'}`);
|
|
1118
|
+
|
|
1119
|
+
const indexed = await getIndexedIdsWithRetry("messages", "id");
|
|
1120
|
+
console.error(`Already indexed: ${indexed.size} messages`);
|
|
1121
|
+
|
|
1122
|
+
const toIndex = messages.filter(m => !indexed.has(String(m.id)));
|
|
1123
|
+
console.error(`Need to index: ${toIndex.length} messages`);
|
|
1124
|
+
|
|
1125
|
+
if (toIndex.length === 0) {
|
|
1126
|
+
// Save timestamp for next incremental scan
|
|
1127
|
+
saveIndexMeta({ ...meta, lastMessageIndexTime: indexStartTime });
|
|
1128
|
+
return { indexed: indexed.size, added: 0 };
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
let processed = 0;
|
|
1132
|
+
for (let i = 0; i < toIndex.length; i += BATCH_SIZE) {
|
|
1133
|
+
const batch = toIndex.slice(i, i + BATCH_SIZE);
|
|
1134
|
+
|
|
1135
|
+
// Prepare all messages and search texts
|
|
1136
|
+
const preparedMsgs = [];
|
|
1137
|
+
const searchTexts = [];
|
|
1138
|
+
|
|
1139
|
+
for (const msg of batch) {
|
|
1140
|
+
const searchText = `From: ${msg.sender}\nMessage: ${msg.text}`.substring(0, 500);
|
|
1141
|
+
if (searchText.length > 10) {
|
|
1142
|
+
preparedMsgs.push(msg);
|
|
1143
|
+
searchTexts.push(searchText);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Generate all embeddings in a single batch call
|
|
1148
|
+
let vectors = [];
|
|
1149
|
+
if (searchTexts.length > 0) {
|
|
1150
|
+
try {
|
|
1151
|
+
vectors = await embedBatch(searchTexts);
|
|
1152
|
+
} catch (e) {
|
|
1153
|
+
console.error("Batch embedding error:", e.message);
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Build records with vectors
|
|
1159
|
+
const records = preparedMsgs.map((msg, idx) => {
|
|
1160
|
+
// Explicitly cast to boolean to avoid LanceDB schema inference issues
|
|
1161
|
+
const isGroupChat = Boolean((parseInt(msg.participantCount) || 0) > 2 || (msg.chatName && msg.chatName.length > 0));
|
|
1162
|
+
const hasAttachment = Boolean((parseInt(msg.attachmentCount) || 0) > 0);
|
|
1163
|
+
return {
|
|
1164
|
+
id: String(msg.id),
|
|
1165
|
+
date: msg.date,
|
|
1166
|
+
dateTimestamp: msg.dateTimestamp || 0,
|
|
1167
|
+
sender: msg.sender,
|
|
1168
|
+
text: msg.text?.substring(0, 500) || "",
|
|
1169
|
+
chatId: String(msg.chatId || ""),
|
|
1170
|
+
chatIdentifier: msg.chatIdentifier || "",
|
|
1171
|
+
chatName: msg.chatName || "",
|
|
1172
|
+
isGroupChat,
|
|
1173
|
+
hasAttachment,
|
|
1174
|
+
vector: vectors[idx]
|
|
1175
|
+
};
|
|
1176
|
+
}).filter(r => r.vector); // Only records with vectors
|
|
1177
|
+
|
|
1178
|
+
if (records.length > 0) {
|
|
1179
|
+
// Defensive duplicate check: remove any duplicates within the batch itself
|
|
1180
|
+
const uniqueRecords = [];
|
|
1181
|
+
const seenIds = new Set();
|
|
1182
|
+
for (const record of records) {
|
|
1183
|
+
if (!seenIds.has(record.id)) {
|
|
1184
|
+
seenIds.add(record.id);
|
|
1185
|
+
uniqueRecords.push(record);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
if (uniqueRecords.length !== records.length) {
|
|
1190
|
+
const duplicateIds = records
|
|
1191
|
+
.map(r => r.id)
|
|
1192
|
+
.filter((id, idx, arr) => arr.indexOf(id) !== idx);
|
|
1193
|
+
console.error(`WARNING: Removed ${records.length - uniqueRecords.length} duplicate(s) from batch. IDs: ${duplicateIds.join(', ')}`);
|
|
1194
|
+
console.error(`This suggests getMessages() is returning duplicate rows. Investigate SQL query.`);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
if (uniqueRecords.length > 0) {
|
|
1198
|
+
if (!tables.messages) {
|
|
1199
|
+
tables.messages = await db.createTable("messages", uniqueRecords, { mode: "overwrite" });
|
|
1200
|
+
} else {
|
|
1201
|
+
// Double-check: verify these IDs truly aren't in the index
|
|
1202
|
+
const currentIndexed = await getIndexedIdsWithRetry("messages", "id");
|
|
1203
|
+
const trulyNew = uniqueRecords.filter(r => !currentIndexed.has(r.id));
|
|
1204
|
+
|
|
1205
|
+
if (trulyNew.length < uniqueRecords.length) {
|
|
1206
|
+
console.error(`WARNING: ${uniqueRecords.length - trulyNew.length} records were already indexed. Filtering them out.`);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if (trulyNew.length > 0) {
|
|
1210
|
+
const beforeCount = await tables.messages.countRows();
|
|
1211
|
+
await tables.messages.add(trulyNew);
|
|
1212
|
+
const afterCount = await tables.messages.countRows();
|
|
1213
|
+
const actualAdded = afterCount - beforeCount;
|
|
1214
|
+
|
|
1215
|
+
if (actualAdded !== trulyNew.length) {
|
|
1216
|
+
console.error(`WARNING: Expected to add ${trulyNew.length} records but index grew by ${actualAdded}. Possible duplicate issue.`);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
processed += batch.length;
|
|
1224
|
+
console.error(`Indexed ${processed}/${toIndex.length} messages...`);
|
|
1225
|
+
|
|
1226
|
+
// Throttle to prevent thermal crashes
|
|
1227
|
+
if (i + BATCH_SIZE < toIndex.length) {
|
|
1228
|
+
await new Promise(r => setTimeout(r, BATCH_DELAY_MS));
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Save timestamp for next incremental scan
|
|
1233
|
+
saveIndexMeta({ ...meta, lastMessageIndexTime: indexStartTime });
|
|
1234
|
+
|
|
1235
|
+
return { indexed: indexed.size, added: processed };
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
export async function indexCalendar() {
|
|
1239
|
+
await initDB();
|
|
1240
|
+
|
|
1241
|
+
const events = getCalendarEvents();
|
|
1242
|
+
console.error(`Found ${events.length} calendar events`);
|
|
1243
|
+
|
|
1244
|
+
// Get already indexed event IDs for incremental indexing
|
|
1245
|
+
const indexed = await getIndexedIdsWithRetry("calendar", "id");
|
|
1246
|
+
console.error(`Already indexed: ${indexed.size} calendar events`);
|
|
1247
|
+
|
|
1248
|
+
// Build set of current event IDs for stale detection
|
|
1249
|
+
// Use dbId only - must match the ID format used when storing events (line 1139)
|
|
1250
|
+
const currentIds = new Set(events.map(evt => `${evt.dbId}`));
|
|
1251
|
+
|
|
1252
|
+
// Find and remove stale entries (indexed but no longer in calendar)
|
|
1253
|
+
const staleIds = [...indexed].filter(id => !currentIds.has(id));
|
|
1254
|
+
if (staleIds.length > 0 && tables.calendar) {
|
|
1255
|
+
console.error(`Removing ${staleIds.length} stale calendar entries...`);
|
|
1256
|
+
|
|
1257
|
+
// Validate all IDs first
|
|
1258
|
+
const validIds = [];
|
|
1259
|
+
for (const staleId of staleIds) {
|
|
1260
|
+
const validatedId = validateLanceDBId(staleId);
|
|
1261
|
+
if (!validatedId) {
|
|
1262
|
+
console.error(`Skipping invalid stale ID: ${staleId.substring(0, 50)}...`);
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
validIds.push(escapeSQL(validatedId));
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// Batch delete in chunks of 100 to avoid query size limits
|
|
1269
|
+
const BATCH_DELETE_SIZE = 100;
|
|
1270
|
+
for (let i = 0; i < validIds.length; i += BATCH_DELETE_SIZE) {
|
|
1271
|
+
const batch = validIds.slice(i, i + BATCH_DELETE_SIZE);
|
|
1272
|
+
if (batch.length > 0) {
|
|
1273
|
+
try {
|
|
1274
|
+
// Use OR conditions for batch delete (LanceDB compatible)
|
|
1275
|
+
const conditions = batch.map(id => `id = '${id}'`).join(' OR ');
|
|
1276
|
+
await tables.calendar.delete(conditions);
|
|
1277
|
+
} catch (e) {
|
|
1278
|
+
console.error(`Failed to delete batch: ${e.message}`);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// Prepare only NEW events and search texts
|
|
1285
|
+
const validEvents = [];
|
|
1286
|
+
const searchTexts = [];
|
|
1287
|
+
|
|
1288
|
+
for (const evt of events) {
|
|
1289
|
+
const eventId = `${evt.dbId}`; // Remove timestamp from ID to avoid duplicates
|
|
1290
|
+
// Skip if already indexed
|
|
1291
|
+
if (indexed.has(eventId)) continue;
|
|
1292
|
+
|
|
1293
|
+
const searchText = `Event: ${evt.title}\nCalendar: ${evt.calendar}\nLocation: ${evt.location}\nNotes: ${evt.notes}`.substring(0, 500);
|
|
1294
|
+
if (searchText.length > 10) {
|
|
1295
|
+
validEvents.push({ ...evt, id: eventId });
|
|
1296
|
+
searchTexts.push(searchText);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
if (validEvents.length === 0) {
|
|
1301
|
+
console.error("No new calendar events to index");
|
|
1302
|
+
return { indexed: indexed.size, added: 0, removed: staleIds.length };
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
console.error(`Indexing ${validEvents.length} new calendar events...`);
|
|
1306
|
+
|
|
1307
|
+
// Generate embeddings in batches
|
|
1308
|
+
const allVectors = [];
|
|
1309
|
+
for (let i = 0; i < searchTexts.length; i += BATCH_SIZE) {
|
|
1310
|
+
const batchTexts = searchTexts.slice(i, i + BATCH_SIZE);
|
|
1311
|
+
try {
|
|
1312
|
+
const batchVectors = await embedBatch(batchTexts);
|
|
1313
|
+
allVectors.push(...batchVectors);
|
|
1314
|
+
} catch (e) {
|
|
1315
|
+
console.error("Batch embedding error:", e.message);
|
|
1316
|
+
}
|
|
1317
|
+
console.error(`Embedded ${Math.min(i + BATCH_SIZE, searchTexts.length)}/${validEvents.length} new calendar events...`);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// Build records with vectors
|
|
1321
|
+
const records = validEvents.map((evt, idx) => ({
|
|
1322
|
+
id: evt.id,
|
|
1323
|
+
title: evt.title,
|
|
1324
|
+
start: evt.start,
|
|
1325
|
+
end: evt.end,
|
|
1326
|
+
startTimestamp: evt.startTimestamp,
|
|
1327
|
+
calendar: evt.calendar,
|
|
1328
|
+
location: evt.location,
|
|
1329
|
+
notes: evt.notes?.substring(0, 200) || "",
|
|
1330
|
+
isAllDay: evt.isAllDay,
|
|
1331
|
+
attendees: evt.attendees || "[]",
|
|
1332
|
+
attendeeCount: evt.attendeeCount || 0,
|
|
1333
|
+
vector: allVectors[idx]
|
|
1334
|
+
})).filter(r => r.vector); // Only include records with valid vectors
|
|
1335
|
+
|
|
1336
|
+
if (records.length > 0) {
|
|
1337
|
+
// Defensive duplicate check: remove any duplicates within the batch itself
|
|
1338
|
+
const uniqueRecords = [];
|
|
1339
|
+
const seenIds = new Set();
|
|
1340
|
+
for (const record of records) {
|
|
1341
|
+
if (!seenIds.has(record.id)) {
|
|
1342
|
+
seenIds.add(record.id);
|
|
1343
|
+
uniqueRecords.push(record);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
if (uniqueRecords.length !== records.length) {
|
|
1348
|
+
console.error(`Removed ${records.length - uniqueRecords.length} duplicate(s) from batch`);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
if (uniqueRecords.length > 0) {
|
|
1352
|
+
if (tables.calendar) {
|
|
1353
|
+
// Add to existing table
|
|
1354
|
+
await tables.calendar.add(uniqueRecords);
|
|
1355
|
+
} else {
|
|
1356
|
+
// Create new table
|
|
1357
|
+
tables.calendar = await db.createTable("calendar", uniqueRecords);
|
|
1358
|
+
}
|
|
1359
|
+
console.error(`Indexed ${uniqueRecords.length} new calendar events`);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
return { indexed: indexed.size, added: records.length, removed: staleIds.length };
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
export async function indexAll(progressCallback = null) {
|
|
1367
|
+
console.error("Starting full index...");
|
|
1368
|
+
|
|
1369
|
+
if (progressCallback) progressCallback('emails-start');
|
|
1370
|
+
const emailResult = await indexEmails(progressCallback);
|
|
1371
|
+
if (progressCallback) progressCallback('emails-complete');
|
|
1372
|
+
|
|
1373
|
+
if (progressCallback) progressCallback('messages-start');
|
|
1374
|
+
const messageResult = await indexMessages();
|
|
1375
|
+
if (progressCallback) progressCallback('messages-complete');
|
|
1376
|
+
|
|
1377
|
+
if (progressCallback) progressCallback('calendar-start');
|
|
1378
|
+
const calendarResult = await indexCalendar();
|
|
1379
|
+
if (progressCallback) progressCallback('calendar-complete');
|
|
1380
|
+
|
|
1381
|
+
console.error("Full index complete.");
|
|
1382
|
+
return { emails: emailResult, messages: messageResult, calendar: calendarResult };
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
export async function isIndexReady(type = "emails") {
|
|
1386
|
+
await initDB();
|
|
1387
|
+
return tables[type] !== null && tables[type] !== undefined;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// ============ DIRECT QUERIES (for recent items) ============
|
|
1391
|
+
|
|
1392
|
+
export async function getRecentEmails(limit = 10, daysBack = 7) {
|
|
1393
|
+
await initDB();
|
|
1394
|
+
if (!tables.emails) return [];
|
|
1395
|
+
|
|
1396
|
+
const cutoff = Date.now() - (daysBack * 24 * 60 * 60 * 1000);
|
|
1397
|
+
|
|
1398
|
+
try {
|
|
1399
|
+
// Fetch all and filter in JavaScript (LanceDB where clause with quoted columns is unreliable)
|
|
1400
|
+
const results = await tables.emails.query()
|
|
1401
|
+
.select(["filePath", "from", "fromEmail", "to", "subject", "date", "dateTimestamp", "hasAttachment", "mailbox", "isSent", "isFlagged", "messageId", "body"])
|
|
1402
|
+
.toArray();
|
|
1403
|
+
|
|
1404
|
+
// Filter by date, sort by timestamp descending, and limit
|
|
1405
|
+
return results
|
|
1406
|
+
.filter(r => r.dateTimestamp >= cutoff)
|
|
1407
|
+
.sort((a, b) => b.dateTimestamp - a.dateTimestamp)
|
|
1408
|
+
.slice(0, limit);
|
|
1409
|
+
} catch (e) {
|
|
1410
|
+
// Fallback with minimal columns for older indexes
|
|
1411
|
+
console.error("Using fallback query:", e.message);
|
|
1412
|
+
try {
|
|
1413
|
+
const results = await tables.emails.query()
|
|
1414
|
+
.select(["filePath", "from", "fromEmail", "to", "subject", "date", "dateTimestamp", "hasAttachment", "body"])
|
|
1415
|
+
.toArray();
|
|
1416
|
+
return results
|
|
1417
|
+
.filter(r => r.dateTimestamp >= cutoff)
|
|
1418
|
+
.sort((a, b) => b.dateTimestamp - a.dateTimestamp)
|
|
1419
|
+
.slice(0, limit);
|
|
1420
|
+
} catch (e2) {
|
|
1421
|
+
console.error("Error getting recent emails:", e2.message);
|
|
1422
|
+
return [];
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
export async function getEmailsByDateRange(startTs, endTs) {
|
|
1428
|
+
await initDB();
|
|
1429
|
+
if (!tables.emails) return [];
|
|
1430
|
+
|
|
1431
|
+
try {
|
|
1432
|
+
// LanceDB where clause with quoted column names doesn't work reliably
|
|
1433
|
+
// So we fetch all and filter in JavaScript
|
|
1434
|
+
const results = await tables.emails.query()
|
|
1435
|
+
.select(["filePath", "from", "fromEmail", "to", "subject", "date", "dateTimestamp", "hasAttachment", "mailbox", "isSent", "isFlagged", "messageId", "body"])
|
|
1436
|
+
.toArray();
|
|
1437
|
+
|
|
1438
|
+
// Filter by date range and sort by timestamp descending
|
|
1439
|
+
return results
|
|
1440
|
+
.filter(r => r.dateTimestamp >= startTs && r.dateTimestamp < endTs)
|
|
1441
|
+
.sort((a, b) => b.dateTimestamp - a.dateTimestamp);
|
|
1442
|
+
} catch (e) {
|
|
1443
|
+
// Fallback with minimal columns for older indexes
|
|
1444
|
+
console.error("Using fallback query for date range:", e.message);
|
|
1445
|
+
try {
|
|
1446
|
+
const results = await tables.emails.query()
|
|
1447
|
+
.select(["filePath", "from", "fromEmail", "to", "subject", "date", "dateTimestamp", "hasAttachment", "body"])
|
|
1448
|
+
.toArray();
|
|
1449
|
+
return results
|
|
1450
|
+
.filter(r => r.dateTimestamp >= startTs && r.dateTimestamp < endTs)
|
|
1451
|
+
.sort((a, b) => b.dateTimestamp - a.dateTimestamp);
|
|
1452
|
+
} catch (e2) {
|
|
1453
|
+
console.error("Error getting emails by date range:", e2.message);
|
|
1454
|
+
return [];
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
export async function getRecentMessages(limit = 10, daysBack = 1) {
|
|
1460
|
+
await initDB();
|
|
1461
|
+
if (!tables.messages) return { messages: [], hasMore: false };
|
|
1462
|
+
|
|
1463
|
+
try {
|
|
1464
|
+
const cutoff = Date.now() / 1000 - (daysBack * 24 * 60 * 60); // Messages use Unix timestamp
|
|
1465
|
+
const results = await tables.messages.query()
|
|
1466
|
+
.select(["id", "date", "dateTimestamp", "sender", "text", "chatId", "isGroupChat"])
|
|
1467
|
+
.toArray();
|
|
1468
|
+
|
|
1469
|
+
const filtered = results
|
|
1470
|
+
.filter(r => r.dateTimestamp >= cutoff)
|
|
1471
|
+
.sort((a, b) => b.dateTimestamp - a.dateTimestamp);
|
|
1472
|
+
|
|
1473
|
+
const hasMore = filtered.length > limit;
|
|
1474
|
+
const messages = filtered.slice(0, limit);
|
|
1475
|
+
return { messages, hasMore };
|
|
1476
|
+
} catch (e) {
|
|
1477
|
+
console.error("Error getting recent messages:", e.message);
|
|
1478
|
+
return { messages: [], hasMore: false };
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
export async function getConversation(contact, limit = 50) {
|
|
1483
|
+
await initDB();
|
|
1484
|
+
if (!tables.messages) return [];
|
|
1485
|
+
|
|
1486
|
+
try {
|
|
1487
|
+
const contactLower = contact.toLowerCase();
|
|
1488
|
+
const results = await tables.messages.query()
|
|
1489
|
+
.select(["id", "date", "dateTimestamp", "sender", "text", "chatId", "chatIdentifier"])
|
|
1490
|
+
.toArray();
|
|
1491
|
+
|
|
1492
|
+
// Find messages where sender or chatIdentifier contains the contact
|
|
1493
|
+
const filtered = results.filter(r => {
|
|
1494
|
+
const sender = (r.sender || "").toLowerCase();
|
|
1495
|
+
const chatId = (r.chatIdentifier || "").toLowerCase();
|
|
1496
|
+
return sender.includes(contactLower) || chatId.includes(contactLower);
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
// Sort chronologically (oldest first for conversation view)
|
|
1500
|
+
return filtered
|
|
1501
|
+
.sort((a, b) => a.dateTimestamp - b.dateTimestamp)
|
|
1502
|
+
.slice(-limit); // Take last N messages
|
|
1503
|
+
} catch (e) {
|
|
1504
|
+
console.error("Error getting conversation:", e.message);
|
|
1505
|
+
return [];
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
export async function getCalendarByDate(startTimestamp, endTimestamp) {
|
|
1510
|
+
await initDB();
|
|
1511
|
+
if (!tables.calendar) return [];
|
|
1512
|
+
|
|
1513
|
+
try {
|
|
1514
|
+
const results = await tables.calendar.query()
|
|
1515
|
+
.select(["id", "title", "start", "end", "startTimestamp", "calendar", "location", "notes", "isAllDay"])
|
|
1516
|
+
.toArray();
|
|
1517
|
+
|
|
1518
|
+
console.error(`[Calendar Query] Searching between ${new Date(startTimestamp).toISOString()} and ${new Date(endTimestamp).toISOString()}`);
|
|
1519
|
+
console.error(`[Calendar Query] Total events in index: ${results.length}`);
|
|
1520
|
+
|
|
1521
|
+
const filtered = results.filter(r => r.startTimestamp >= startTimestamp && r.startTimestamp < endTimestamp);
|
|
1522
|
+
|
|
1523
|
+
console.error(`[Calendar Query] Matched events: ${filtered.length}`);
|
|
1524
|
+
if (filtered.length === 0 && results.length > 0) {
|
|
1525
|
+
// Show events near the boundary for debugging
|
|
1526
|
+
const nearby = results.filter(r =>
|
|
1527
|
+
r.startTimestamp >= startTimestamp - 86400000 &&
|
|
1528
|
+
r.startTimestamp <= endTimestamp + 86400000
|
|
1529
|
+
);
|
|
1530
|
+
console.error(`[Calendar Query] Events within ±1 day: ${nearby.length}`);
|
|
1531
|
+
nearby.forEach(e => {
|
|
1532
|
+
console.error(` - "${e.title}" at ${new Date(e.startTimestamp).toISOString()} (all-day: ${e.isAllDay})`);
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
return filtered.sort((a, b) => a.startTimestamp - b.startTimestamp);
|
|
1537
|
+
} catch (e) {
|
|
1538
|
+
console.error("Error getting calendar by date:", e.message);
|
|
1539
|
+
return [];
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
export async function getAllCalendarEvents() {
|
|
1544
|
+
await initDB();
|
|
1545
|
+
if (!tables.calendar) return [];
|
|
1546
|
+
|
|
1547
|
+
try {
|
|
1548
|
+
const results = await tables.calendar.query()
|
|
1549
|
+
.select(["id", "title", "start", "end", "startTimestamp", "calendar", "location", "isAllDay"])
|
|
1550
|
+
.toArray();
|
|
1551
|
+
|
|
1552
|
+
return results.sort((a, b) => a.startTimestamp - b.startTimestamp);
|
|
1553
|
+
} catch (e) {
|
|
1554
|
+
console.error("Error getting all calendar events:", e.message);
|
|
1555
|
+
return [];
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// ============ NEW TOOLS - PHASE 1 ============
|
|
1560
|
+
|
|
1561
|
+
// Mailboxes to exclude by default
|
|
1562
|
+
const EXCLUDED_MAILBOXES = ['junk', 'trash', 'deleted messages', 'spam'];
|
|
1563
|
+
|
|
1564
|
+
// mail_senders: List most frequent email senders
|
|
1565
|
+
export async function getFrequentSenders(limit = 30, daysBack = 0, includeJunk = false) {
|
|
1566
|
+
await initDB();
|
|
1567
|
+
if (!tables.emails) return [];
|
|
1568
|
+
|
|
1569
|
+
try {
|
|
1570
|
+
let emails = await tables.emails.query().select(["fromEmail", "from", "dateTimestamp", "mailbox"]).toArray();
|
|
1571
|
+
if (daysBack > 0) {
|
|
1572
|
+
const cutoff = Date.now() - daysBack * 24 * 60 * 60 * 1000;
|
|
1573
|
+
emails = emails.filter(e => e.dateTimestamp >= cutoff);
|
|
1574
|
+
}
|
|
1575
|
+
// Exclude junk/trash by default
|
|
1576
|
+
if (!includeJunk) {
|
|
1577
|
+
emails = emails.filter(e =>
|
|
1578
|
+
!EXCLUDED_MAILBOXES.some(mb =>
|
|
1579
|
+
(e.mailbox || "").toLowerCase().includes(mb)
|
|
1580
|
+
)
|
|
1581
|
+
);
|
|
1582
|
+
}
|
|
1583
|
+
const senderCounts = {};
|
|
1584
|
+
emails.forEach(e => {
|
|
1585
|
+
const key = e.fromEmail || e.from || "Unknown";
|
|
1586
|
+
senderCounts[key] = (senderCounts[key] || 0) + 1;
|
|
1587
|
+
});
|
|
1588
|
+
return Object.entries(senderCounts)
|
|
1589
|
+
.sort((a, b) => b[1] - a[1])
|
|
1590
|
+
.slice(0, limit)
|
|
1591
|
+
.map(([email, count]) => ({ email, messageCount: count }));
|
|
1592
|
+
} catch (e) {
|
|
1593
|
+
console.error("Error getting frequent senders:", e.message);
|
|
1594
|
+
return [];
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// messages_contacts: List all contacts you've messaged
|
|
1599
|
+
export function getMessageContacts(limit = 50) {
|
|
1600
|
+
try {
|
|
1601
|
+
// Validate limit to prevent SQL issues
|
|
1602
|
+
const safeLimit = validateLimit(limit, 50, 500);
|
|
1603
|
+
|
|
1604
|
+
const query = `
|
|
1605
|
+
SELECT
|
|
1606
|
+
h.id as contact,
|
|
1607
|
+
COUNT(m.ROWID) as messageCount,
|
|
1608
|
+
datetime(MAX(m.date)/1000000000 + 978307200, 'unixepoch', 'localtime') as lastMessageDate
|
|
1609
|
+
FROM message m
|
|
1610
|
+
LEFT JOIN handle h ON m.handle_id = h.ROWID
|
|
1611
|
+
WHERE h.id IS NOT NULL
|
|
1612
|
+
GROUP BY h.id
|
|
1613
|
+
ORDER BY MAX(m.date) DESC
|
|
1614
|
+
LIMIT ${safeLimit}
|
|
1615
|
+
`;
|
|
1616
|
+
return safeSqlite3Json(MESSAGES_DB, query, { timeout: 30000 });
|
|
1617
|
+
} catch (e) {
|
|
1618
|
+
console.error("Error getting message contacts:", e.message);
|
|
1619
|
+
return [];
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// calendar_upcoming: Get next N upcoming events
|
|
1624
|
+
export function getUpcomingEvents(limit = 10) {
|
|
1625
|
+
try {
|
|
1626
|
+
// Validate limit to prevent SQL issues
|
|
1627
|
+
const safeLimit = validateLimit(limit, 10, 100);
|
|
1628
|
+
const nowMac = Math.floor(Date.now() / 1000) - 978307200;
|
|
1629
|
+
const fetchLimit = safeLimit + 1; // Fetch one extra to detect if more exist
|
|
1630
|
+
|
|
1631
|
+
// Query OccurrenceCache to include recurring event occurrences
|
|
1632
|
+
const query = `
|
|
1633
|
+
SELECT
|
|
1634
|
+
ci.summary as title,
|
|
1635
|
+
datetime(COALESCE(oc.occurrence_end_date - (ci.end_date - ci.start_date), ci.start_date) + 978307200, 'unixepoch', 'localtime') as start,
|
|
1636
|
+
datetime(COALESCE(oc.occurrence_end_date, ci.end_date) + 978307200, 'unixepoch', 'localtime') as end,
|
|
1637
|
+
ci.all_day as isAllDay,
|
|
1638
|
+
c.title as calendar,
|
|
1639
|
+
l.title as location,
|
|
1640
|
+
oc.day as sort_day
|
|
1641
|
+
FROM OccurrenceCache oc
|
|
1642
|
+
INNER JOIN CalendarItem ci ON oc.event_id = ci.ROWID
|
|
1643
|
+
LEFT JOIN Calendar c ON ci.calendar_id = c.ROWID
|
|
1644
|
+
LEFT JOIN Location l ON ci.location_id = l.ROWID
|
|
1645
|
+
WHERE oc.day >= ${nowMac} AND ci.summary IS NOT NULL AND ci.summary <> ''
|
|
1646
|
+
ORDER BY sort_day ASC
|
|
1647
|
+
LIMIT ${fetchLimit}
|
|
1648
|
+
`;
|
|
1649
|
+
const events = safeSqlite3Json(CALENDAR_DB, query, { timeout: 10000 });
|
|
1650
|
+
const hasMore = events.length > safeLimit;
|
|
1651
|
+
const limitedEvents = events.slice(0, safeLimit);
|
|
1652
|
+
return { events: limitedEvents, showing: limitedEvents.length, hasMore };
|
|
1653
|
+
} catch (e) {
|
|
1654
|
+
console.error("Error getting upcoming events:", e.message);
|
|
1655
|
+
return { events: [], showing: 0, hasMore: false };
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
// ============ NEW TOOLS - PHASE 2 ============
|
|
1660
|
+
|
|
1661
|
+
// mail_unread_count: Get count of unread emails via AppleScript
|
|
1662
|
+
export function getUnreadCount(mailbox = null) {
|
|
1663
|
+
try {
|
|
1664
|
+
let script;
|
|
1665
|
+
|
|
1666
|
+
if (mailbox) {
|
|
1667
|
+
// Validate mailbox name to prevent AppleScript injection
|
|
1668
|
+
const validatedMailbox = validateMailboxName(mailbox);
|
|
1669
|
+
if (!validatedMailbox) {
|
|
1670
|
+
return { unreadCount: 0, mailbox, error: "Invalid mailbox name. Only alphanumeric characters, spaces, hyphens, underscores, and periods are allowed." };
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// Escape for AppleScript double-quoted string (belt and suspenders)
|
|
1674
|
+
const escapedMailbox = escapeAppleScript(validatedMailbox);
|
|
1675
|
+
|
|
1676
|
+
script = `tell application "Mail"
|
|
1677
|
+
set unreadCount to 0
|
|
1678
|
+
repeat with acc in accounts
|
|
1679
|
+
try
|
|
1680
|
+
set mb to mailbox "${escapedMailbox}" of acc
|
|
1681
|
+
set unreadCount to unreadCount + (count of (messages of mb whose read status is false))
|
|
1682
|
+
end try
|
|
1683
|
+
end repeat
|
|
1684
|
+
return unreadCount
|
|
1685
|
+
end tell`;
|
|
1686
|
+
} else {
|
|
1687
|
+
script = `tell application "Mail"
|
|
1688
|
+
return unread count of inbox
|
|
1689
|
+
end tell`;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
const result = safeOsascript(script, { timeout: 15000 });
|
|
1693
|
+
return { unreadCount: parseInt(result.trim()) || 0, mailbox: mailbox || "INBOX" };
|
|
1694
|
+
} catch (e) {
|
|
1695
|
+
console.error("Error getting unread count:", e.message);
|
|
1696
|
+
return { unreadCount: 0, mailbox: mailbox || "INBOX", error: e.message };
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// calendar_week: Get events for current or next week
|
|
1701
|
+
export function getWeekEvents(weekOffset = 0) {
|
|
1702
|
+
try {
|
|
1703
|
+
const now = new Date();
|
|
1704
|
+
// Get Monday of the current week
|
|
1705
|
+
const monday = new Date(now);
|
|
1706
|
+
const day = now.getDay();
|
|
1707
|
+
const diff = day === 0 ? -6 : 1 - day; // If Sunday, go back 6 days; otherwise go to Monday
|
|
1708
|
+
monday.setDate(now.getDate() + diff + (weekOffset * 7));
|
|
1709
|
+
monday.setHours(0, 0, 0, 0);
|
|
1710
|
+
|
|
1711
|
+
// Get Sunday end of week
|
|
1712
|
+
const sunday = new Date(monday);
|
|
1713
|
+
sunday.setDate(monday.getDate() + 6);
|
|
1714
|
+
sunday.setHours(23, 59, 59, 999);
|
|
1715
|
+
|
|
1716
|
+
const startMac = Math.floor(monday.getTime() / 1000) - 978307200;
|
|
1717
|
+
const endMac = Math.floor(sunday.getTime() / 1000) - 978307200;
|
|
1718
|
+
|
|
1719
|
+
// Query OccurrenceCache to include recurring event occurrences
|
|
1720
|
+
const query = `
|
|
1721
|
+
SELECT
|
|
1722
|
+
ci.summary as title,
|
|
1723
|
+
datetime(COALESCE(oc.occurrence_end_date - (ci.end_date - ci.start_date), ci.start_date) + 978307200, 'unixepoch', 'localtime') as start,
|
|
1724
|
+
datetime(COALESCE(oc.occurrence_end_date, ci.end_date) + 978307200, 'unixepoch', 'localtime') as end,
|
|
1725
|
+
ci.all_day as isAllDay,
|
|
1726
|
+
c.title as calendar,
|
|
1727
|
+
l.title as location
|
|
1728
|
+
FROM OccurrenceCache oc
|
|
1729
|
+
INNER JOIN CalendarItem ci ON oc.event_id = ci.ROWID
|
|
1730
|
+
LEFT JOIN Calendar c ON ci.calendar_id = c.ROWID
|
|
1731
|
+
LEFT JOIN Location l ON ci.location_id = l.ROWID
|
|
1732
|
+
WHERE oc.day >= ${startMac} AND oc.day <= ${endMac}
|
|
1733
|
+
AND ci.summary IS NOT NULL AND ci.summary <> ''
|
|
1734
|
+
ORDER BY oc.day ASC
|
|
1735
|
+
`;
|
|
1736
|
+
const events = safeSqlite3Json(CALENDAR_DB, query, { timeout: 15000 });
|
|
1737
|
+
|
|
1738
|
+
const weekStart = monday.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
|
|
1739
|
+
const weekEnd = sunday.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
|
|
1740
|
+
|
|
1741
|
+
return {
|
|
1742
|
+
events,
|
|
1743
|
+
weekLabel: weekOffset === 0 ? "This Week" : weekOffset === 1 ? "Next Week" : `Week of ${weekStart}`,
|
|
1744
|
+
dateRange: `${weekStart} - ${weekEnd}`
|
|
1745
|
+
};
|
|
1746
|
+
} catch (e) {
|
|
1747
|
+
console.error("Error getting week events:", e.message);
|
|
1748
|
+
return { events: [], weekLabel: "Unknown", dateRange: "", error: e.message };
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// ============ NEW TOOLS - PHASE 3 ============
|
|
1753
|
+
|
|
1754
|
+
// mail_thread: Get email thread by searching for related emails
|
|
1755
|
+
// Uses subject-based matching since Message-ID isn't indexed
|
|
1756
|
+
export async function getEmailThread(filePath, limit = 20) {
|
|
1757
|
+
await initDB();
|
|
1758
|
+
if (!tables.emails) return { error: "Email index not ready", emails: [] };
|
|
1759
|
+
|
|
1760
|
+
try {
|
|
1761
|
+
// Validate file path to prevent path traversal attacks
|
|
1762
|
+
let validatedPath;
|
|
1763
|
+
try {
|
|
1764
|
+
validatedPath = validateEmailPath(filePath, MAIL_DIR);
|
|
1765
|
+
} catch (e) {
|
|
1766
|
+
return { error: `Invalid file path: ${e.message}`, emails: [] };
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// Verify file exists
|
|
1770
|
+
if (!fs.existsSync(validatedPath)) {
|
|
1771
|
+
return { error: "Email file not found", emails: [] };
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// Read the email to get subject
|
|
1775
|
+
const content = fs.readFileSync(validatedPath, "utf-8");
|
|
1776
|
+
const subjectMatch = content.match(/^Subject:\s*(.+)$/m);
|
|
1777
|
+
if (!subjectMatch) {
|
|
1778
|
+
return { error: "Could not extract subject from email", emails: [] };
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
// Clean subject - remove Re:, Fwd:, etc.
|
|
1782
|
+
let subject = subjectMatch[1].trim();
|
|
1783
|
+
const baseSubject = subject.replace(/^(Re|Fwd|Fw):\s*/gi, "").trim();
|
|
1784
|
+
|
|
1785
|
+
if (baseSubject.length < 5) {
|
|
1786
|
+
return { error: "Subject too short to find thread", emails: [] };
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
// Search for emails with similar subjects
|
|
1790
|
+
const allEmails = await tables.emails.query()
|
|
1791
|
+
.select(["filePath", "from", "to", "subject", "date", "dateTimestamp"])
|
|
1792
|
+
.toArray();
|
|
1793
|
+
|
|
1794
|
+
// Filter to emails with matching base subject
|
|
1795
|
+
const threadEmails = allEmails
|
|
1796
|
+
.filter(e => {
|
|
1797
|
+
const eBaseSubject = (e.subject || "").replace(/^(Re|Fwd|Fw):\s*/gi, "").trim();
|
|
1798
|
+
return eBaseSubject.toLowerCase() === baseSubject.toLowerCase();
|
|
1799
|
+
})
|
|
1800
|
+
.sort((a, b) => a.dateTimestamp - b.dateTimestamp)
|
|
1801
|
+
.slice(0, limit);
|
|
1802
|
+
|
|
1803
|
+
return {
|
|
1804
|
+
emails: threadEmails,
|
|
1805
|
+
baseSubject,
|
|
1806
|
+
threadCount: threadEmails.length
|
|
1807
|
+
};
|
|
1808
|
+
} catch (e) {
|
|
1809
|
+
console.error("Error getting email thread:", e.message);
|
|
1810
|
+
return { error: e.message, emails: [] };
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// calendar_recurring: Get recurring events (shows events with recurrence rules and their upcoming occurrences)
|
|
1815
|
+
export function getRecurringEvents(limit = 20) {
|
|
1816
|
+
try {
|
|
1817
|
+
// Validate limit to prevent SQL issues
|
|
1818
|
+
const safeLimit = validateLimit(limit, 20, 100);
|
|
1819
|
+
const nowMac = Math.floor(Date.now() / 1000) - 978307200;
|
|
1820
|
+
const fetchLimit = safeLimit + 1; // Fetch one extra to detect if more exist
|
|
1821
|
+
|
|
1822
|
+
// Query events that have recurrence rules defined in the Recurrence table
|
|
1823
|
+
// Show the next upcoming occurrence of each recurring event
|
|
1824
|
+
const query = `
|
|
1825
|
+
SELECT DISTINCT
|
|
1826
|
+
ci.summary as title,
|
|
1827
|
+
datetime(COALESCE(MIN(oc.occurrence_end_date) - (ci.end_date - ci.start_date), MIN(oc.day)) + 978307200, 'unixepoch', 'localtime') as start,
|
|
1828
|
+
c.title as calendar,
|
|
1829
|
+
ci.all_day as isAllDay,
|
|
1830
|
+
COUNT(DISTINCT oc.day) as occurrenceCount
|
|
1831
|
+
FROM Recurrence r
|
|
1832
|
+
INNER JOIN CalendarItem ci ON r.owner_id = ci.ROWID
|
|
1833
|
+
INNER JOIN OccurrenceCache oc ON oc.event_id = ci.ROWID
|
|
1834
|
+
LEFT JOIN Calendar c ON ci.calendar_id = c.ROWID
|
|
1835
|
+
WHERE oc.day >= ${nowMac}
|
|
1836
|
+
AND ci.summary IS NOT NULL AND ci.summary <> ''
|
|
1837
|
+
GROUP BY ci.ROWID, ci.summary, c.title, ci.all_day
|
|
1838
|
+
ORDER BY occurrenceCount DESC, MIN(oc.day) ASC
|
|
1839
|
+
LIMIT ${fetchLimit}
|
|
1840
|
+
`;
|
|
1841
|
+
const events = safeSqlite3Json(CALENDAR_DB, query, { timeout: 30000 });
|
|
1842
|
+
const hasMore = events.length > safeLimit;
|
|
1843
|
+
const limitedEvents = events.slice(0, safeLimit);
|
|
1844
|
+
return { events: limitedEvents, showing: limitedEvents.length, hasMore };
|
|
1845
|
+
} catch (e) {
|
|
1846
|
+
console.error("Error getting recurring events:", e.message);
|
|
1847
|
+
return { events: [], showing: 0, hasMore: false };
|
|
1848
|
+
}
|
|
1849
|
+
}
|