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/lib/audit.js
ADDED
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Index Audit Module for apple-tools-mcp
|
|
3
|
+
*
|
|
4
|
+
* Provides comprehensive auditing of the vector index against source data
|
|
5
|
+
* with 0% tolerance. Identifies missing items, orphaned entries, and duplicates.
|
|
6
|
+
*
|
|
7
|
+
* Core Features:
|
|
8
|
+
* - 100% source data validation (no filtering by date)
|
|
9
|
+
* - Detailed verbose reporting with file paths and metadata
|
|
10
|
+
* - Performance optimized for 100k+ items
|
|
11
|
+
* - Report-only (no auto-fix)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from "fs";
|
|
15
|
+
import path from "path";
|
|
16
|
+
import { execSync } from "child_process";
|
|
17
|
+
import { safeSqlite3Json } from "./shell.js";
|
|
18
|
+
import * as lancedb from "@lancedb/lancedb";
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// CONSTANTS AND PATHS
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
const HOME = process.env.HOME;
|
|
25
|
+
const INDEX_DIR = process.env.APPLE_TOOLS_INDEX_DIR ||
|
|
26
|
+
path.join(HOME, ".apple-tools-mcp", "vector-index");
|
|
27
|
+
const MAIL_DIR = path.join(HOME, "Library", "Mail");
|
|
28
|
+
const MESSAGES_DB = path.join(HOME, "Library", "Messages", "chat.db");
|
|
29
|
+
const CALENDAR_DB = path.join(HOME, "Library", "Group Containers", "group.com.apple.calendar", "Calendar.sqlitedb");
|
|
30
|
+
|
|
31
|
+
// Mac Absolute Time epoch: Jan 1, 2001 00:00:00 UTC
|
|
32
|
+
const MAC_ABSOLUTE_EPOCH = 978307200;
|
|
33
|
+
|
|
34
|
+
// Email indexing time window (matches indexer.js behavior)
|
|
35
|
+
const DAYS_BACK = process.env.APPLE_TOOLS_INDEX_DAYS_BACK ?
|
|
36
|
+
parseInt(process.env.APPLE_TOOLS_INDEX_DAYS_BACK, 10) : null;
|
|
37
|
+
|
|
38
|
+
// Exclude these folders from email indexing (matches indexer behavior)
|
|
39
|
+
const EXCLUDED_FOLDERS = ["Junk.mbox", "Saved Junk.mbox", "Trash.mbox", "Deleted Messages.mbox"];
|
|
40
|
+
|
|
41
|
+
let db = null;
|
|
42
|
+
let tables = {};
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// DATABASE CONNECTION
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
async function initDB() {
|
|
49
|
+
if (db) return { db, tables };
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
db = await lancedb.connect(INDEX_DIR);
|
|
53
|
+
const tableNames = await db.tableNames();
|
|
54
|
+
|
|
55
|
+
if (tableNames.includes("emails")) {
|
|
56
|
+
tables.emails = await db.openTable("emails");
|
|
57
|
+
}
|
|
58
|
+
if (tableNames.includes("messages")) {
|
|
59
|
+
tables.messages = await db.openTable("messages");
|
|
60
|
+
}
|
|
61
|
+
if (tableNames.includes("calendar")) {
|
|
62
|
+
tables.calendar = await db.openTable("calendar");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { db, tables };
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.error("Error initializing database:", e.message);
|
|
68
|
+
return { db: null, tables: {} };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ============================================================================
|
|
73
|
+
// SOURCE COUNTING FUNCTIONS
|
|
74
|
+
// ============================================================================
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Count all .emlx files (excluding Junk/Trash)
|
|
78
|
+
* Respects APPLE_TOOLS_INDEX_DAYS_BACK environment variable
|
|
79
|
+
* @returns {number} Total count of email files
|
|
80
|
+
*/
|
|
81
|
+
export function countRawEmails() {
|
|
82
|
+
if (!fs.existsSync(MAIL_DIR)) return 0;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
// Build exclusion pattern for find command
|
|
86
|
+
const exclusions = EXCLUDED_FOLDERS.map(folder => `-path "*/${folder}/*"`).join(" -o ");
|
|
87
|
+
|
|
88
|
+
// Add time filter if DAYS_BACK is set (matches indexer behavior)
|
|
89
|
+
const timeFilter = DAYS_BACK ? `-mtime -${DAYS_BACK}` : "";
|
|
90
|
+
const cmd = `find "${MAIL_DIR}" \\( -name "*.emlx" -o -name "*.partial.emlx" \\) ! \\( ${exclusions} \\) ${timeFilter} -type f 2>/dev/null | wc -l`;
|
|
91
|
+
|
|
92
|
+
const result = execSync(cmd, { encoding: "utf-8", timeout: 120000 }).trim();
|
|
93
|
+
return parseInt(result) || 0;
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.error("Error counting emails:", e.message);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Count all messages with text or attributedBody
|
|
102
|
+
* @returns {number} Total count of indexable messages
|
|
103
|
+
*/
|
|
104
|
+
export function countRawMessages() {
|
|
105
|
+
if (!fs.existsSync(MESSAGES_DB)) return 0;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const query = `SELECT COUNT(*) as count FROM message
|
|
109
|
+
WHERE (text IS NOT NULL AND text != '')
|
|
110
|
+
OR attributedBody IS NOT NULL`;
|
|
111
|
+
const results = safeSqlite3Json(MESSAGES_DB, query);
|
|
112
|
+
return results[0]?.count || 0;
|
|
113
|
+
} catch (e) {
|
|
114
|
+
console.error("Error counting messages:", e.message);
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Count calendar events in the configured time window
|
|
121
|
+
* (90 days back, 365 days forward)
|
|
122
|
+
* @returns {number} Total count of calendar events
|
|
123
|
+
*/
|
|
124
|
+
export function countRawCalendarEvents() {
|
|
125
|
+
if (!fs.existsSync(CALENDAR_DB)) return 0;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
// Match indexer's 10-year window for comprehensive calendar indexing
|
|
130
|
+
const pastDate = (now / 1000) - MAC_ABSOLUTE_EPOCH - (10 * 365 * 24 * 60 * 60);
|
|
131
|
+
const futureDate = (now / 1000) - MAC_ABSOLUTE_EPOCH + (10 * 365 * 24 * 60 * 60);
|
|
132
|
+
|
|
133
|
+
// Count only calendar items that have occurrences (real scheduled events)
|
|
134
|
+
// Don't count database junk like far-future placeholders or deleted events
|
|
135
|
+
const query = `
|
|
136
|
+
SELECT COUNT(DISTINCT ci.ROWID) as count
|
|
137
|
+
FROM OccurrenceCache oc
|
|
138
|
+
INNER JOIN CalendarItem ci ON oc.event_id = ci.ROWID
|
|
139
|
+
WHERE oc.day IS NOT NULL
|
|
140
|
+
AND oc.day >= ${pastDate}
|
|
141
|
+
AND oc.day <= ${futureDate}
|
|
142
|
+
AND ci.summary IS NOT NULL
|
|
143
|
+
`;
|
|
144
|
+
const results = safeSqlite3Json(CALENDAR_DB, query);
|
|
145
|
+
return results[0]?.count || 0;
|
|
146
|
+
} catch (e) {
|
|
147
|
+
console.error("Error counting calendar events:", e.message);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ============================================================================
|
|
153
|
+
// ID EXTRACTION FUNCTIONS
|
|
154
|
+
// ============================================================================
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Get all email file paths (excluding Junk/Trash)
|
|
158
|
+
* Respects APPLE_TOOLS_INDEX_DAYS_BACK environment variable
|
|
159
|
+
* @returns {Set<string>} Set of absolute file paths
|
|
160
|
+
*/
|
|
161
|
+
export function getRawEmailIds() {
|
|
162
|
+
if (!fs.existsSync(MAIL_DIR)) return new Set();
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const exclusions = EXCLUDED_FOLDERS.map(folder => `-path "*/${folder}/*"`).join(" -o ");
|
|
166
|
+
|
|
167
|
+
// Add time filter if DAYS_BACK is set (matches indexer behavior)
|
|
168
|
+
const timeFilter = DAYS_BACK ? `-mtime -${DAYS_BACK}` : "";
|
|
169
|
+
const cmd = `find "${MAIL_DIR}" \\( -name "*.emlx" -o -name "*.partial.emlx" \\) ! \\( ${exclusions} \\) ${timeFilter} -type f 2>/dev/null`;
|
|
170
|
+
|
|
171
|
+
const result = execSync(cmd, { encoding: "utf-8", timeout: 120000, maxBuffer: 50 * 1024 * 1024 }).trim();
|
|
172
|
+
const paths = result.split("\n").filter(p => p);
|
|
173
|
+
return new Set(paths);
|
|
174
|
+
} catch (e) {
|
|
175
|
+
console.error("Error getting email IDs:", e.message);
|
|
176
|
+
return new Set();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get all message ROWIDs with text or attributedBody
|
|
182
|
+
* @returns {Set<string>} Set of message IDs (as strings)
|
|
183
|
+
*/
|
|
184
|
+
export function getRawMessageIds() {
|
|
185
|
+
if (!fs.existsSync(MESSAGES_DB)) return new Set();
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const query = `SELECT ROWID as id FROM message
|
|
189
|
+
WHERE (text IS NOT NULL AND text != '')
|
|
190
|
+
OR attributedBody IS NOT NULL`;
|
|
191
|
+
const results = safeSqlite3Json(MESSAGES_DB, query);
|
|
192
|
+
return new Set(results.map(r => String(r.id)));
|
|
193
|
+
} catch (e) {
|
|
194
|
+
console.error("Error getting message IDs:", e.message);
|
|
195
|
+
return new Set();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Get all calendar event IDs (just dbId, no timestamp)
|
|
201
|
+
* Uses CalendarItem table with GROUP BY to match indexer's behavior
|
|
202
|
+
* @returns {Set<string>} Set of event IDs
|
|
203
|
+
*/
|
|
204
|
+
export function getRawCalendarIds() {
|
|
205
|
+
if (!fs.existsSync(CALENDAR_DB)) return new Set();
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const now = Date.now();
|
|
209
|
+
// Match indexer's 10-year window for comprehensive calendar indexing
|
|
210
|
+
const pastDate = (now / 1000) - MAC_ABSOLUTE_EPOCH - (10 * 365 * 24 * 60 * 60);
|
|
211
|
+
const futureDate = (now / 1000) - MAC_ABSOLUTE_EPOCH + (10 * 365 * 24 * 60 * 60);
|
|
212
|
+
|
|
213
|
+
const query = `
|
|
214
|
+
SELECT DISTINCT ci.ROWID as dbId
|
|
215
|
+
FROM OccurrenceCache oc
|
|
216
|
+
INNER JOIN CalendarItem ci ON oc.event_id = ci.ROWID
|
|
217
|
+
WHERE oc.day IS NOT NULL
|
|
218
|
+
AND oc.day >= ${pastDate}
|
|
219
|
+
AND oc.day <= ${futureDate}
|
|
220
|
+
AND ci.summary IS NOT NULL
|
|
221
|
+
GROUP BY ci.ROWID
|
|
222
|
+
`;
|
|
223
|
+
|
|
224
|
+
const results = safeSqlite3Json(CALENDAR_DB, query);
|
|
225
|
+
// Return just the dbId (no timestamp) to match indexer's new format
|
|
226
|
+
return new Set(results.map(r => String(r.dbId)));
|
|
227
|
+
} catch (e) {
|
|
228
|
+
console.error("Error getting calendar IDs:", e.message);
|
|
229
|
+
return new Set();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Get all indexed IDs from a LanceDB table
|
|
235
|
+
* @param {string} tableName - Name of the table
|
|
236
|
+
* @param {string} idField - Field name containing the ID
|
|
237
|
+
* @returns {Promise<Set>} Set of indexed IDs
|
|
238
|
+
*/
|
|
239
|
+
export async function getIndexedIds(tableName, idField) {
|
|
240
|
+
await initDB();
|
|
241
|
+
|
|
242
|
+
if (!tables[tableName]) return new Set();
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
// Fetch only the ID field for performance
|
|
246
|
+
const results = await tables[tableName].query().select([idField]).limit(1000000).toArray();
|
|
247
|
+
return new Set(results.map(r => String(r[idField])));
|
|
248
|
+
} catch (e) {
|
|
249
|
+
console.error(`Error getting indexed IDs from ${tableName}:`, e.message);
|
|
250
|
+
return new Set();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Get all indexed items with metadata for detailed reporting
|
|
256
|
+
* @param {string} tableName - Name of the table
|
|
257
|
+
* @param {Array<string>} fields - Fields to retrieve
|
|
258
|
+
* @returns {Promise<Array>} Array of indexed items
|
|
259
|
+
*/
|
|
260
|
+
async function getIndexedItems(tableName, fields) {
|
|
261
|
+
await initDB();
|
|
262
|
+
|
|
263
|
+
if (!tables[tableName]) return [];
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const results = await tables[tableName].query().select(fields).limit(1000000).toArray();
|
|
267
|
+
return results;
|
|
268
|
+
} catch (e) {
|
|
269
|
+
console.error(`Error getting indexed items from ${tableName}:`, e.message);
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ============================================================================
|
|
275
|
+
// DISCREPANCY DETECTION
|
|
276
|
+
// ============================================================================
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Find items in source but not in index
|
|
280
|
+
* @param {Set} sourceIds - IDs from source data
|
|
281
|
+
* @param {Set} indexedIds - IDs from index
|
|
282
|
+
* @returns {Array<string>} Array of missing IDs
|
|
283
|
+
*/
|
|
284
|
+
export function findMissing(sourceIds, indexedIds) {
|
|
285
|
+
const missing = [];
|
|
286
|
+
for (const id of sourceIds) {
|
|
287
|
+
if (!indexedIds.has(id)) {
|
|
288
|
+
missing.push(id);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return missing;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Find items in index but deleted from source (orphaned)
|
|
296
|
+
* @param {Set} indexedIds - IDs from index
|
|
297
|
+
* @param {Function} sourceValidator - Function to check if source exists
|
|
298
|
+
* @returns {Promise<Array<string>>} Array of orphaned IDs
|
|
299
|
+
*/
|
|
300
|
+
export async function findOrphaned(indexedIds, sourceValidator) {
|
|
301
|
+
const orphaned = [];
|
|
302
|
+
for (const id of indexedIds) {
|
|
303
|
+
if (!await sourceValidator(id)) {
|
|
304
|
+
orphaned.push(id);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return orphaned;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Find duplicate entries in index (same ID indexed multiple times)
|
|
312
|
+
* @param {Array} indexedItems - All items from index
|
|
313
|
+
* @param {string} keyField - Field to check for duplicates
|
|
314
|
+
* @returns {Array<{id: string, count: number}>} Duplicates with counts
|
|
315
|
+
*/
|
|
316
|
+
export function findDuplicates(indexedItems, keyField) {
|
|
317
|
+
const counts = new Map();
|
|
318
|
+
|
|
319
|
+
for (const item of indexedItems) {
|
|
320
|
+
const key = String(item[keyField]);
|
|
321
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const duplicates = [];
|
|
325
|
+
for (const [id, count] of counts.entries()) {
|
|
326
|
+
if (count > 1) {
|
|
327
|
+
duplicates.push({ id, count });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return duplicates;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ============================================================================
|
|
335
|
+
// METADATA EXTRACTION
|
|
336
|
+
// ============================================================================
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Get email metadata for detailed reporting
|
|
340
|
+
* @param {string} filePath - Path to .emlx file
|
|
341
|
+
* @returns {object} Email metadata
|
|
342
|
+
*/
|
|
343
|
+
function getEmailMetadata(filePath) {
|
|
344
|
+
try {
|
|
345
|
+
if (!fs.existsSync(filePath)) {
|
|
346
|
+
return { subject: "Unknown", from: "Unknown", date: "Unknown", messageId: null, exists: false };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const rawContent = fs.readFileSync(filePath, "utf-8");
|
|
350
|
+
|
|
351
|
+
// Handle Apple Mail envelope format: first line is byte count
|
|
352
|
+
// Strip the preamble to get the actual RFC822 email content
|
|
353
|
+
let content = rawContent;
|
|
354
|
+
const lines = rawContent.split("\n");
|
|
355
|
+
if (lines[0] && /^\d+\s*$/.test(lines[0])) {
|
|
356
|
+
content = lines.slice(1).join("\n");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Use regex-based extraction (same approach as indexer.js)
|
|
360
|
+
// This handles folded headers, case-insensitivity, and optional whitespace
|
|
361
|
+
const subjectMatch = content.match(/^Subject:\s*(.+)$/im);
|
|
362
|
+
const fromMatch = content.match(/^From:\s*(.+)$/im);
|
|
363
|
+
const dateMatch = content.match(/^Date:\s*(.+)$/im);
|
|
364
|
+
const messageIdMatch = content.match(/^Message-ID:\s*(.+)$/im);
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
subject: subjectMatch?.[1]?.trim() || "Unknown",
|
|
368
|
+
from: fromMatch?.[1]?.trim() || "Unknown",
|
|
369
|
+
date: dateMatch?.[1]?.trim() || "Unknown",
|
|
370
|
+
messageId: messageIdMatch?.[1]?.trim() || null,
|
|
371
|
+
exists: true
|
|
372
|
+
};
|
|
373
|
+
} catch (e) {
|
|
374
|
+
return { subject: "Error", from: "Error", date: "Error", messageId: null, exists: false };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Get message metadata for detailed reporting
|
|
380
|
+
* @param {string} messageId - Message ROWID
|
|
381
|
+
* @returns {object} Message metadata
|
|
382
|
+
*/
|
|
383
|
+
function getMessageMetadata(messageId) {
|
|
384
|
+
try {
|
|
385
|
+
const query = `
|
|
386
|
+
SELECT
|
|
387
|
+
m.text,
|
|
388
|
+
datetime(m.date/1000000000 + ${MAC_ABSOLUTE_EPOCH}, 'unixepoch', 'localtime') as date,
|
|
389
|
+
CASE WHEN m.is_from_me = 1 THEN 'Me' ELSE coalesce(h.id, 'Unknown') END as sender
|
|
390
|
+
FROM message m
|
|
391
|
+
LEFT JOIN handle h ON m.handle_id = h.ROWID
|
|
392
|
+
WHERE m.ROWID = ${messageId}
|
|
393
|
+
`;
|
|
394
|
+
|
|
395
|
+
const results = safeSqlite3Json(MESSAGES_DB, query);
|
|
396
|
+
if (results.length > 0) {
|
|
397
|
+
const msg = results[0];
|
|
398
|
+
return {
|
|
399
|
+
text: (msg.text || "").substring(0, 100),
|
|
400
|
+
date: msg.date,
|
|
401
|
+
sender: msg.sender
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
} catch (e) {
|
|
405
|
+
// Silent error
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return { text: "Unknown", date: "Unknown", sender: "Unknown" };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Get calendar event metadata for detailed reporting
|
|
413
|
+
* @param {string} dbId - Database ID (just the ROWID, no timestamp)
|
|
414
|
+
* @returns {object} Calendar event metadata
|
|
415
|
+
*/
|
|
416
|
+
function getCalendarMetadata(dbId) {
|
|
417
|
+
try {
|
|
418
|
+
const query = `
|
|
419
|
+
SELECT
|
|
420
|
+
summary as title,
|
|
421
|
+
datetime(start_date + ${MAC_ABSOLUTE_EPOCH}, 'unixepoch', 'localtime') as start
|
|
422
|
+
FROM CalendarItem
|
|
423
|
+
WHERE ROWID = ${dbId}
|
|
424
|
+
`;
|
|
425
|
+
|
|
426
|
+
const results = safeSqlite3Json(CALENDAR_DB, query);
|
|
427
|
+
if (results.length > 0) {
|
|
428
|
+
return {
|
|
429
|
+
title: results[0].title,
|
|
430
|
+
start: results[0].start
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
} catch (e) {
|
|
434
|
+
// Silent error
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return { title: "Unknown", start: "Unknown" };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ============================================================================
|
|
441
|
+
// MAIN AUDIT FUNCTIONS
|
|
442
|
+
// ============================================================================
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Audit emails
|
|
446
|
+
* @param {object} options - Audit options
|
|
447
|
+
* @returns {Promise<object>} Audit results
|
|
448
|
+
*/
|
|
449
|
+
export async function auditEmails(options = {}) {
|
|
450
|
+
const { maxItems = 100 } = options;
|
|
451
|
+
|
|
452
|
+
console.error("Auditing emails...");
|
|
453
|
+
|
|
454
|
+
// Phase 1: COUNT
|
|
455
|
+
const sourceCount = countRawEmails();
|
|
456
|
+
const sourceIds = getRawEmailIds();
|
|
457
|
+
const indexedIds = await getIndexedIds("emails", "filePath");
|
|
458
|
+
const indexedCount = indexedIds.size;
|
|
459
|
+
|
|
460
|
+
// Phase 2: IDENTIFY
|
|
461
|
+
const missing = findMissing(sourceIds, indexedIds);
|
|
462
|
+
const orphaned = await findOrphaned(indexedIds, (id) => fs.existsSync(id));
|
|
463
|
+
|
|
464
|
+
// Get all indexed items for duplicate detection and messageId mapping
|
|
465
|
+
const indexedItems = await getIndexedItems("emails", ["filePath", "subject", "messageId"]);
|
|
466
|
+
const duplicates = findDuplicates(indexedItems, "filePath");
|
|
467
|
+
|
|
468
|
+
// Create messageId -> indexed items map for deduplication detection
|
|
469
|
+
const indexedMessageIds = new Map();
|
|
470
|
+
for (const item of indexedItems) {
|
|
471
|
+
if (item.messageId) {
|
|
472
|
+
if (!indexedMessageIds.has(item.messageId)) {
|
|
473
|
+
indexedMessageIds.set(item.messageId, []);
|
|
474
|
+
}
|
|
475
|
+
indexedMessageIds.get(item.messageId).push(item);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Phase 3: PREPARE DETAILED ITEMS - Categorize missing items
|
|
480
|
+
const missingDetailed = [];
|
|
481
|
+
let deduplicatedCount = 0;
|
|
482
|
+
|
|
483
|
+
for (const filePath of missing.slice(0, maxItems > 0 ? maxItems : missing.length)) {
|
|
484
|
+
const metadata = getEmailMetadata(filePath);
|
|
485
|
+
|
|
486
|
+
// Check if this missing file is a duplicate by messageId
|
|
487
|
+
let reason = "Not indexed";
|
|
488
|
+
let isDuplicate = false;
|
|
489
|
+
|
|
490
|
+
if (metadata.messageId && indexedMessageIds.has(metadata.messageId)) {
|
|
491
|
+
// This file has the same messageId as an indexed email
|
|
492
|
+
const indexedDuplicates = indexedMessageIds.get(metadata.messageId);
|
|
493
|
+
if (indexedDuplicates.length > 0) {
|
|
494
|
+
reason = `Deduplicated (duplicate messageId - same as: ${indexedDuplicates[0].subject || "Unknown"})`;
|
|
495
|
+
isDuplicate = true;
|
|
496
|
+
deduplicatedCount++;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
missingDetailed.push({
|
|
501
|
+
filePath,
|
|
502
|
+
...metadata,
|
|
503
|
+
reason,
|
|
504
|
+
isDuplicate
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Count total deduplicates (for items not shown in detail)
|
|
509
|
+
let totalDeduplicates = 0;
|
|
510
|
+
for (const filePath of missing) {
|
|
511
|
+
const metadata = getEmailMetadata(filePath);
|
|
512
|
+
if (metadata.messageId && indexedMessageIds.has(metadata.messageId)) {
|
|
513
|
+
totalDeduplicates++;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const trulyMissingCount = missing.length - totalDeduplicates;
|
|
517
|
+
|
|
518
|
+
const orphanedDetailed = orphaned.slice(0, maxItems > 0 ? maxItems : orphaned.length).map(filePath => {
|
|
519
|
+
const indexedItem = indexedItems.find(item => item.filePath === filePath);
|
|
520
|
+
return {
|
|
521
|
+
filePath,
|
|
522
|
+
subject: indexedItem?.subject || "Unknown",
|
|
523
|
+
reason: "File no longer exists (deleted from Mail.app)"
|
|
524
|
+
};
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
const duplicatesDetailed = duplicates.slice(0, maxItems > 0 ? maxItems : duplicates.length).map(dup => {
|
|
528
|
+
const items = indexedItems.filter(item => item.filePath === dup.id);
|
|
529
|
+
return {
|
|
530
|
+
filePath: dup.id,
|
|
531
|
+
count: dup.count,
|
|
532
|
+
subject: items[0]?.subject || "Unknown"
|
|
533
|
+
};
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
dataType: "emails",
|
|
538
|
+
counts: {
|
|
539
|
+
source: sourceCount,
|
|
540
|
+
indexed: indexedCount,
|
|
541
|
+
unique: indexedCount,
|
|
542
|
+
coverage: sourceCount > 0 ? indexedCount / sourceCount : 0,
|
|
543
|
+
notes: {
|
|
544
|
+
totalSourceFiles: sourceCount,
|
|
545
|
+
indexedUniqueEmails: indexedCount,
|
|
546
|
+
deduplicatedFiles: totalDeduplicates,
|
|
547
|
+
trulyMissingCount: trulyMissingCount,
|
|
548
|
+
explanation: `${indexedCount} unique emails indexed from ${sourceCount} source files (${totalDeduplicates} duplicate messageIds correctly deduplicated, ${trulyMissingCount} truly missing)`
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
discrepancies: {
|
|
552
|
+
missing: missingDetailed,
|
|
553
|
+
orphaned: orphanedDetailed,
|
|
554
|
+
duplicates: duplicatesDetailed,
|
|
555
|
+
missingCount: trulyMissingCount,
|
|
556
|
+
deduplicatedCount: totalDeduplicates,
|
|
557
|
+
orphanedCount: orphaned.length,
|
|
558
|
+
duplicateCount: duplicates.length
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Audit messages
|
|
565
|
+
* @param {object} options - Audit options
|
|
566
|
+
* @returns {Promise<object>} Audit results
|
|
567
|
+
*/
|
|
568
|
+
export async function auditMessages(options = {}) {
|
|
569
|
+
const { maxItems = 100 } = options;
|
|
570
|
+
|
|
571
|
+
console.error("Auditing messages...");
|
|
572
|
+
|
|
573
|
+
// Phase 1: COUNT
|
|
574
|
+
const sourceCount = countRawMessages();
|
|
575
|
+
const sourceIds = getRawMessageIds();
|
|
576
|
+
const indexedIds = await getIndexedIds("messages", "id");
|
|
577
|
+
const indexedCount = indexedIds.size;
|
|
578
|
+
|
|
579
|
+
// Phase 2: IDENTIFY
|
|
580
|
+
const missing = findMissing(sourceIds, indexedIds);
|
|
581
|
+
|
|
582
|
+
// Messages don't have orphaned entries (database persists)
|
|
583
|
+
const orphaned = [];
|
|
584
|
+
|
|
585
|
+
// Get all indexed items for duplicate detection
|
|
586
|
+
const indexedItems = await getIndexedItems("messages", ["id", "text", "sender"]);
|
|
587
|
+
const duplicates = findDuplicates(indexedItems, "id");
|
|
588
|
+
|
|
589
|
+
// Phase 3: PREPARE DETAILED ITEMS
|
|
590
|
+
const missingDetailed = missing.slice(0, maxItems > 0 ? maxItems : missing.length).map(id => ({
|
|
591
|
+
id,
|
|
592
|
+
...getMessageMetadata(id),
|
|
593
|
+
reason: "Not indexed"
|
|
594
|
+
}));
|
|
595
|
+
|
|
596
|
+
const duplicatesDetailed = duplicates.slice(0, maxItems > 0 ? maxItems : duplicates.length).map(dup => {
|
|
597
|
+
const items = indexedItems.filter(item => String(item.id) === dup.id);
|
|
598
|
+
return {
|
|
599
|
+
id: dup.id,
|
|
600
|
+
count: dup.count,
|
|
601
|
+
text: items[0]?.text?.substring(0, 100) || "Unknown",
|
|
602
|
+
sender: items[0]?.sender || "Unknown"
|
|
603
|
+
};
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
dataType: "messages",
|
|
608
|
+
counts: {
|
|
609
|
+
source: sourceCount,
|
|
610
|
+
indexed: indexedCount,
|
|
611
|
+
coverage: sourceCount > 0 ? indexedCount / sourceCount : 0
|
|
612
|
+
},
|
|
613
|
+
discrepancies: {
|
|
614
|
+
missing: missingDetailed,
|
|
615
|
+
orphaned: [],
|
|
616
|
+
duplicates: duplicatesDetailed,
|
|
617
|
+
missingCount: missing.length,
|
|
618
|
+
orphanedCount: 0,
|
|
619
|
+
duplicateCount: duplicates.length
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Audit calendar events
|
|
626
|
+
* @param {object} options - Audit options
|
|
627
|
+
* @returns {Promise<object>} Audit results
|
|
628
|
+
*/
|
|
629
|
+
export async function auditCalendar(options = {}) {
|
|
630
|
+
const { maxItems = 100 } = options;
|
|
631
|
+
|
|
632
|
+
console.error("Auditing calendar...");
|
|
633
|
+
|
|
634
|
+
// Phase 1: COUNT
|
|
635
|
+
const sourceCount = countRawCalendarEvents();
|
|
636
|
+
const sourceIds = getRawCalendarIds();
|
|
637
|
+
const indexedIds = await getIndexedIds("calendar", "id");
|
|
638
|
+
const indexedCount = indexedIds.size;
|
|
639
|
+
|
|
640
|
+
// Phase 2: IDENTIFY
|
|
641
|
+
const missing = findMissing(sourceIds, indexedIds);
|
|
642
|
+
const orphaned = findMissing(indexedIds, sourceIds); // Reverse check for stale entries
|
|
643
|
+
|
|
644
|
+
// Get all indexed items for duplicate detection
|
|
645
|
+
const indexedItems = await getIndexedItems("calendar", ["id", "title"]);
|
|
646
|
+
const duplicates = findDuplicates(indexedItems, "id");
|
|
647
|
+
|
|
648
|
+
// Phase 3: PREPARE DETAILED ITEMS
|
|
649
|
+
const missingDetailed = missing.slice(0, maxItems > 0 ? maxItems : missing.length).map(id => ({
|
|
650
|
+
id,
|
|
651
|
+
...getCalendarMetadata(id),
|
|
652
|
+
reason: "Not indexed"
|
|
653
|
+
}));
|
|
654
|
+
|
|
655
|
+
const orphanedDetailed = orphaned.slice(0, maxItems > 0 ? maxItems : orphaned.length).map(id => {
|
|
656
|
+
const indexedItem = indexedItems.find(item => item.id === id);
|
|
657
|
+
return {
|
|
658
|
+
id,
|
|
659
|
+
title: indexedItem?.title || "Unknown",
|
|
660
|
+
reason: "Event no longer exists in calendar"
|
|
661
|
+
};
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
const duplicatesDetailed = duplicates.slice(0, maxItems > 0 ? maxItems : duplicates.length).map(dup => {
|
|
665
|
+
const items = indexedItems.filter(item => item.id === dup.id);
|
|
666
|
+
return {
|
|
667
|
+
id: dup.id,
|
|
668
|
+
count: dup.count,
|
|
669
|
+
title: items[0]?.title || "Unknown"
|
|
670
|
+
};
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
return {
|
|
674
|
+
dataType: "calendar",
|
|
675
|
+
counts: {
|
|
676
|
+
source: sourceCount,
|
|
677
|
+
indexed: indexedCount,
|
|
678
|
+
coverage: sourceCount > 0 ? indexedCount / sourceCount : 0
|
|
679
|
+
},
|
|
680
|
+
discrepancies: {
|
|
681
|
+
missing: missingDetailed,
|
|
682
|
+
orphaned: orphanedDetailed,
|
|
683
|
+
duplicates: duplicatesDetailed,
|
|
684
|
+
missingCount: missing.length,
|
|
685
|
+
orphanedCount: orphaned.length,
|
|
686
|
+
duplicateCount: duplicates.length
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Audit all data sources
|
|
693
|
+
* @param {object} options - Audit options
|
|
694
|
+
* @returns {Promise<object>} Combined audit results
|
|
695
|
+
*/
|
|
696
|
+
export async function auditAll(options = {}) {
|
|
697
|
+
const { sources = ["emails", "messages", "calendar"], maxItems = 100 } = options;
|
|
698
|
+
|
|
699
|
+
const results = {};
|
|
700
|
+
|
|
701
|
+
// Run audits in parallel for performance
|
|
702
|
+
const promises = [];
|
|
703
|
+
if (sources.includes("emails")) {
|
|
704
|
+
promises.push(auditEmails({ maxItems }).then(r => ({ type: "emails", result: r })));
|
|
705
|
+
}
|
|
706
|
+
if (sources.includes("messages")) {
|
|
707
|
+
promises.push(auditMessages({ maxItems }).then(r => ({ type: "messages", result: r })));
|
|
708
|
+
}
|
|
709
|
+
if (sources.includes("calendar")) {
|
|
710
|
+
promises.push(auditCalendar({ maxItems }).then(r => ({ type: "calendar", result: r })));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const allResults = await Promise.all(promises);
|
|
714
|
+
for (const { type, result } of allResults) {
|
|
715
|
+
results[type] = result;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return results;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ============================================================================
|
|
722
|
+
// REPORT FORMATTING
|
|
723
|
+
// ============================================================================
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Format audit results as verbose text report
|
|
727
|
+
* @param {object} results - Audit results from auditAll()
|
|
728
|
+
* @returns {string} Formatted report
|
|
729
|
+
*/
|
|
730
|
+
export function formatAuditReport(results) {
|
|
731
|
+
const timestamp = new Date().toISOString().replace("T", " ").substring(0, 19);
|
|
732
|
+
|
|
733
|
+
let report = "=== INDEX AUDIT REPORT ===\n";
|
|
734
|
+
report += `Generated: ${timestamp}\n\n`;
|
|
735
|
+
|
|
736
|
+
for (const [dataType, result] of Object.entries(results)) {
|
|
737
|
+
const { counts, discrepancies } = result;
|
|
738
|
+
const { source, indexed, coverage, notes } = counts;
|
|
739
|
+
const { missing, orphaned, duplicates, missingCount, orphanedCount, duplicateCount, deduplicatedCount } = discrepancies;
|
|
740
|
+
|
|
741
|
+
const isPerfect = missingCount === 0 && orphanedCount === 0 && duplicateCount === 0;
|
|
742
|
+
const statusIcon = isPerfect ? "✓" : "✗";
|
|
743
|
+
|
|
744
|
+
report += "━".repeat(60) + "\n";
|
|
745
|
+
report += `${dataType.toUpperCase()}\n`;
|
|
746
|
+
report += "━".repeat(60) + "\n\n";
|
|
747
|
+
|
|
748
|
+
// For emails, show adjusted coverage that accounts for deduplication
|
|
749
|
+
if (dataType === "emails" && notes && notes.deduplicatedFiles) {
|
|
750
|
+
const uniqueExpected = source - notes.deduplicatedFiles;
|
|
751
|
+
const uniqueCoverage = uniqueExpected > 0 ? Math.min(indexed / uniqueExpected, 1.0) : 1.0;
|
|
752
|
+
const trulyMissingCount = Math.max(0, uniqueExpected - indexed);
|
|
753
|
+
|
|
754
|
+
report += `${statusIcon} Files on disk: ${source.toLocaleString()}\n`;
|
|
755
|
+
report += ` └─ Unique emails: ${uniqueExpected.toLocaleString()}\n`;
|
|
756
|
+
report += ` └─ Duplicate files (same email, multiple folders): ${notes.deduplicatedFiles.toLocaleString()}\n`;
|
|
757
|
+
report += `${statusIcon} Indexed: ${indexed.toLocaleString()} unique emails\n`;
|
|
758
|
+
report += `${statusIcon} Unique Email Coverage: ${(uniqueCoverage * 100).toFixed(1)}%`;
|
|
759
|
+
|
|
760
|
+
if (trulyMissingCount === 0 && orphanedCount === 0 && duplicateCount === 0) {
|
|
761
|
+
report += " (Perfect!)";
|
|
762
|
+
} else if (trulyMissingCount > 0 || orphanedCount > 0 || duplicateCount > 0) {
|
|
763
|
+
const issues = [];
|
|
764
|
+
if (trulyMissingCount > 0) issues.push(`${trulyMissingCount} missing`);
|
|
765
|
+
if (orphanedCount > 0) issues.push(`${orphanedCount} orphaned`);
|
|
766
|
+
if (duplicateCount > 0) issues.push(`${duplicateCount} duplicates`);
|
|
767
|
+
report += ` (${issues.join(", ")})`;
|
|
768
|
+
}
|
|
769
|
+
report += "\n";
|
|
770
|
+
} else {
|
|
771
|
+
report += `${statusIcon} Source: ${source.toLocaleString()} ${dataType}\n`;
|
|
772
|
+
report += `${statusIcon} Indexed: ${indexed.toLocaleString()} ${dataType}\n`;
|
|
773
|
+
report += `${statusIcon} Coverage: ${(coverage * 100).toFixed(1)}%`;
|
|
774
|
+
|
|
775
|
+
if (!isPerfect) {
|
|
776
|
+
report += ` (${missingCount} missing, ${orphanedCount} orphaned, ${duplicateCount} duplicates)`;
|
|
777
|
+
} else {
|
|
778
|
+
report += " (Perfect!)";
|
|
779
|
+
}
|
|
780
|
+
report += "\n";
|
|
781
|
+
}
|
|
782
|
+
report += "\n";
|
|
783
|
+
|
|
784
|
+
// Missing items
|
|
785
|
+
if (missingCount > 0 || (dataType === "emails" && deduplicatedCount > 0)) {
|
|
786
|
+
// Separate truly missing from deduplicated items
|
|
787
|
+
const trulyMissing = missing.filter(item => !item.isDuplicate);
|
|
788
|
+
const deduplicated = missing.filter(item => item.isDuplicate);
|
|
789
|
+
|
|
790
|
+
if (trulyMissing.length > 0) {
|
|
791
|
+
report += "─".repeat(60) + "\n";
|
|
792
|
+
report += `MISSING ITEMS (${trulyMissing.length} truly missing)\n`;
|
|
793
|
+
report += "─".repeat(60) + "\n\n";
|
|
794
|
+
|
|
795
|
+
trulyMissing.forEach((item, index) => {
|
|
796
|
+
report += `${index + 1}. `;
|
|
797
|
+
if (dataType === "emails") {
|
|
798
|
+
report += `${item.filePath}\n`;
|
|
799
|
+
report += ` Subject: ${item.subject}\n`;
|
|
800
|
+
report += ` From: ${item.from}\n`;
|
|
801
|
+
report += ` Date: ${item.date}\n`;
|
|
802
|
+
} else if (dataType === "messages") {
|
|
803
|
+
report += `Message ID: ${item.id}\n`;
|
|
804
|
+
report += ` Text: ${item.text}\n`;
|
|
805
|
+
report += ` Sender: ${item.sender}\n`;
|
|
806
|
+
report += ` Date: ${item.date}\n`;
|
|
807
|
+
} else if (dataType === "calendar") {
|
|
808
|
+
report += `Event ID: ${item.id}\n`;
|
|
809
|
+
report += ` Title: ${item.title}\n`;
|
|
810
|
+
report += ` Start: ${item.start}\n`;
|
|
811
|
+
}
|
|
812
|
+
report += ` Reason: ${item.reason}\n\n`;
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Deduplicated items are not listed individually - count is shown in summary
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Orphaned items
|
|
820
|
+
if (orphanedCount > 0) {
|
|
821
|
+
report += "─".repeat(60) + "\n";
|
|
822
|
+
report += `ORPHANED ITEMS (${orphanedCount} total)\n`;
|
|
823
|
+
report += "─".repeat(60) + "\n\n";
|
|
824
|
+
|
|
825
|
+
orphaned.forEach((item, index) => {
|
|
826
|
+
report += `${index + 1}. `;
|
|
827
|
+
if (dataType === "emails") {
|
|
828
|
+
report += `${item.filePath}\n`;
|
|
829
|
+
report += ` Subject: ${item.subject}\n`;
|
|
830
|
+
} else if (dataType === "calendar") {
|
|
831
|
+
report += `Event ID: ${item.id}\n`;
|
|
832
|
+
report += ` Title: ${item.title}\n`;
|
|
833
|
+
}
|
|
834
|
+
report += ` Reason: ${item.reason}\n\n`;
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Duplicates
|
|
839
|
+
if (duplicateCount > 0) {
|
|
840
|
+
report += "─".repeat(60) + "\n";
|
|
841
|
+
report += `DUPLICATE ITEMS (${duplicateCount} total)\n`;
|
|
842
|
+
report += "─".repeat(60) + "\n\n";
|
|
843
|
+
|
|
844
|
+
duplicates.forEach((item, index) => {
|
|
845
|
+
report += `${index + 1}. `;
|
|
846
|
+
if (dataType === "emails") {
|
|
847
|
+
report += `FilePath indexed ${item.count} times:\n`;
|
|
848
|
+
report += ` ${item.filePath}\n`;
|
|
849
|
+
report += ` Subject: ${item.subject}\n\n`;
|
|
850
|
+
} else if (dataType === "messages") {
|
|
851
|
+
report += `Message ID ${item.id} indexed ${item.count} times:\n`;
|
|
852
|
+
report += ` Text: ${item.text}\n`;
|
|
853
|
+
report += ` Sender: ${item.sender}\n\n`;
|
|
854
|
+
} else if (dataType === "calendar") {
|
|
855
|
+
report += `Event ID ${item.id} indexed ${item.count} times:\n`;
|
|
856
|
+
report += ` Title: ${item.title}\n\n`;
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Remediation suggestions
|
|
863
|
+
report += "━".repeat(60) + "\n";
|
|
864
|
+
report += "REMEDIATION SUGGESTIONS\n";
|
|
865
|
+
report += "━".repeat(60) + "\n\n";
|
|
866
|
+
|
|
867
|
+
const sourcesWithIssues = [];
|
|
868
|
+
let totalDiscrepancies = 0;
|
|
869
|
+
|
|
870
|
+
for (const [dataType, result] of Object.entries(results)) {
|
|
871
|
+
const { discrepancies } = result;
|
|
872
|
+
const count = discrepancies.missingCount + discrepancies.orphanedCount + discrepancies.duplicateCount;
|
|
873
|
+
if (count > 0) {
|
|
874
|
+
sourcesWithIssues.push(dataType);
|
|
875
|
+
totalDiscrepancies += count;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (sourcesWithIssues.length > 0) {
|
|
880
|
+
report += `1. Run rebuild_index with sources: ${JSON.stringify(sourcesWithIssues)}\n`;
|
|
881
|
+
report += `2. Total items affected: ${totalDiscrepancies}\n`;
|
|
882
|
+
report += `3. Estimated rebuild time: 3-10 minutes\n`;
|
|
883
|
+
report += `4. Orphaned entries will be removed during rebuild\n`;
|
|
884
|
+
report += `5. Duplicates indicate index corruption - rebuild recommended\n`;
|
|
885
|
+
} else {
|
|
886
|
+
report += "✓ No issues found! Index is in perfect sync with source data.\n";
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Summary Report
|
|
890
|
+
report += "\n" + "━".repeat(60) + "\n";
|
|
891
|
+
report += "SUMMARY REPORT\n";
|
|
892
|
+
report += "━".repeat(60) + "\n\n";
|
|
893
|
+
|
|
894
|
+
let totalSource = 0;
|
|
895
|
+
let totalIndexed = 0;
|
|
896
|
+
let totalMissing = 0;
|
|
897
|
+
let totalOrphaned = 0;
|
|
898
|
+
let totalDuplicates = 0;
|
|
899
|
+
let totalDeduplicates = 0;
|
|
900
|
+
|
|
901
|
+
for (const [dataType, result] of Object.entries(results)) {
|
|
902
|
+
totalSource += result.counts.source;
|
|
903
|
+
totalIndexed += result.counts.indexed;
|
|
904
|
+
totalMissing += result.discrepancies.missingCount;
|
|
905
|
+
totalOrphaned += result.discrepancies.orphanedCount;
|
|
906
|
+
totalDuplicates += result.discrepancies.duplicateCount;
|
|
907
|
+
totalDeduplicates += result.discrepancies.deduplicatedCount || 0;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Calculate adjusted coverage (accounting for deduplication)
|
|
911
|
+
const uniqueSource = totalSource - totalDeduplicates;
|
|
912
|
+
const adjustedCoverage = uniqueSource > 0 ? Math.min(totalIndexed / uniqueSource, 1.0) : 1.0;
|
|
913
|
+
const rawCoverage = totalSource > 0 ? (totalIndexed / totalSource) : 0;
|
|
914
|
+
const totalIssues = totalMissing + totalOrphaned + totalDuplicates;
|
|
915
|
+
const healthStatus = totalIssues === 0 ? "HEALTHY ✓" : totalIssues <= 10 ? "MINOR ISSUES ⚠" : "NEEDS ATTENTION ✗";
|
|
916
|
+
|
|
917
|
+
report += `Data Sources Audited: ${Object.keys(results).length}\n`;
|
|
918
|
+
report += `Total Files: ${totalSource.toLocaleString()}\n`;
|
|
919
|
+
if (totalDeduplicates > 0) {
|
|
920
|
+
report += ` └─ Unique items: ${uniqueSource.toLocaleString()}\n`;
|
|
921
|
+
report += ` └─ Duplicate files: ${totalDeduplicates.toLocaleString()} (same email in multiple folders)\n`;
|
|
922
|
+
}
|
|
923
|
+
report += `Total Indexed: ${totalIndexed.toLocaleString()}\n`;
|
|
924
|
+
report += `Unique Item Coverage: ${(adjustedCoverage * 100).toFixed(1)}%\n`;
|
|
925
|
+
report += `Health Status: ${healthStatus}\n\n`;
|
|
926
|
+
|
|
927
|
+
// Calculate truly missing (excluding deduplicated)
|
|
928
|
+
let trulyMissingCount = 0;
|
|
929
|
+
for (const result of Object.values(results)) {
|
|
930
|
+
const trulyMissing = result.discrepancies.missing.filter(item => !item.isDuplicate);
|
|
931
|
+
trulyMissingCount += trulyMissing.length;
|
|
932
|
+
}
|
|
933
|
+
const realIssues = trulyMissingCount + totalOrphaned + totalDuplicates;
|
|
934
|
+
|
|
935
|
+
if (realIssues > 0 || totalDeduplicates > 0) {
|
|
936
|
+
report += "Issue Breakdown:\n";
|
|
937
|
+
if (trulyMissingCount > 0) {
|
|
938
|
+
report += ` Truly Missing: ${trulyMissingCount.toLocaleString()} (${((trulyMissingCount/uniqueSource)*100).toFixed(2)}% of unique items)\n`;
|
|
939
|
+
}
|
|
940
|
+
if (totalDeduplicates > 0) {
|
|
941
|
+
report += ` Deduplicated Files: ${totalDeduplicates.toLocaleString()} (same email in multiple folders - NORMAL)\n`;
|
|
942
|
+
}
|
|
943
|
+
if (totalOrphaned > 0) {
|
|
944
|
+
report += ` Orphaned Items: ${totalOrphaned.toLocaleString()} (${((totalOrphaned/totalIndexed)*100).toFixed(2)}% of index)\n`;
|
|
945
|
+
}
|
|
946
|
+
if (totalDuplicates > 0) {
|
|
947
|
+
report += ` Duplicate Items: ${totalDuplicates.toLocaleString()}\n`;
|
|
948
|
+
}
|
|
949
|
+
if (realIssues > 0) {
|
|
950
|
+
report += ` Total Issues: ${realIssues.toLocaleString()}\n\n`;
|
|
951
|
+
} else if (totalDeduplicates > 0) {
|
|
952
|
+
report += ` (No issues - deduplication is expected behavior)\n\n`;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Per-source breakdown
|
|
956
|
+
report += "Per-Source Status:\n";
|
|
957
|
+
for (const [dataType, result] of Object.entries(results)) {
|
|
958
|
+
const { counts, discrepancies } = result;
|
|
959
|
+
const sourceIssues = discrepancies.missingCount + discrepancies.orphanedCount + discrepancies.duplicateCount;
|
|
960
|
+
const status = sourceIssues === 0 ? "✓" : "✗";
|
|
961
|
+
|
|
962
|
+
// For emails, show adjusted coverage
|
|
963
|
+
let coverage;
|
|
964
|
+
if (dataType === "emails" && counts.notes && counts.notes.deduplicatedFiles) {
|
|
965
|
+
const uniqueExpected = counts.source - counts.notes.deduplicatedFiles;
|
|
966
|
+
coverage = uniqueExpected > 0 ? Math.min((counts.indexed / uniqueExpected) * 100, 100).toFixed(1) : "100.0";
|
|
967
|
+
} else {
|
|
968
|
+
coverage = (counts.coverage * 100).toFixed(1);
|
|
969
|
+
}
|
|
970
|
+
report += ` ${status} ${dataType}: ${coverage}% coverage (${sourceIssues} issues)\n`;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Detailed discrepancy list
|
|
974
|
+
report += "\n" + "─".repeat(60) + "\n";
|
|
975
|
+
report += "ALL DISCREPANCIES (Detailed List)\n";
|
|
976
|
+
report += "─".repeat(60) + "\n\n";
|
|
977
|
+
|
|
978
|
+
// Collect all discrepancies from all sources
|
|
979
|
+
let itemNumber = 1;
|
|
980
|
+
|
|
981
|
+
// Missing items (excluding deduplicated emails which are shown separately)
|
|
982
|
+
// Calculate truly missing count (excluding deduplicated emails)
|
|
983
|
+
let trulyMissingTotal = 0;
|
|
984
|
+
for (const [dataType, result] of Object.entries(results)) {
|
|
985
|
+
const trulyMissing = result.discrepancies.missing.filter(item => !item.isDuplicate);
|
|
986
|
+
trulyMissingTotal += trulyMissing.length;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (trulyMissingTotal > 0) {
|
|
990
|
+
report += `MISSING ITEMS (${trulyMissingTotal} truly missing):\n\n`;
|
|
991
|
+
for (const [dataType, result] of Object.entries(results)) {
|
|
992
|
+
const trulyMissing = result.discrepancies.missing.filter(item => !item.isDuplicate);
|
|
993
|
+
if (trulyMissing.length > 0) {
|
|
994
|
+
report += ` From ${dataType}:\n`;
|
|
995
|
+
trulyMissing.forEach((item) => {
|
|
996
|
+
report += ` ${itemNumber}. `;
|
|
997
|
+
if (dataType === "emails") {
|
|
998
|
+
report += `${item.filePath}\n`;
|
|
999
|
+
report += ` Subject: ${item.subject}\n`;
|
|
1000
|
+
report += ` From: ${item.from}\n`;
|
|
1001
|
+
report += ` Date: ${item.date}\n`;
|
|
1002
|
+
} else if (dataType === "messages") {
|
|
1003
|
+
report += `Message ID: ${item.id}\n`;
|
|
1004
|
+
report += ` Text: ${item.text}\n`;
|
|
1005
|
+
report += ` Sender: ${item.sender}\n`;
|
|
1006
|
+
report += ` Date: ${item.date}\n`;
|
|
1007
|
+
} else if (dataType === "calendar") {
|
|
1008
|
+
report += `Event ID: ${item.id}\n`;
|
|
1009
|
+
report += ` Title: ${item.title}\n`;
|
|
1010
|
+
report += ` Start: ${item.start}\n`;
|
|
1011
|
+
}
|
|
1012
|
+
report += ` Reason: ${item.reason}\n\n`;
|
|
1013
|
+
itemNumber++;
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Orphaned items
|
|
1020
|
+
if (totalOrphaned > 0) {
|
|
1021
|
+
report += `ORPHANED ITEMS (${totalOrphaned} total):\n\n`;
|
|
1022
|
+
itemNumber = 1;
|
|
1023
|
+
for (const [dataType, result] of Object.entries(results)) {
|
|
1024
|
+
if (result.discrepancies.orphaned.length > 0) {
|
|
1025
|
+
report += ` From ${dataType}:\n`;
|
|
1026
|
+
result.discrepancies.orphaned.forEach((item) => {
|
|
1027
|
+
report += ` ${itemNumber}. `;
|
|
1028
|
+
if (dataType === "emails") {
|
|
1029
|
+
report += `${item.filePath}\n`;
|
|
1030
|
+
report += ` Subject: ${item.subject}\n`;
|
|
1031
|
+
} else if (dataType === "messages") {
|
|
1032
|
+
report += `Message ID: ${item.id}\n`;
|
|
1033
|
+
report += ` Text: ${item.text}\n`;
|
|
1034
|
+
} else if (dataType === "calendar") {
|
|
1035
|
+
report += `Event ID: ${item.id}\n`;
|
|
1036
|
+
report += ` Title: ${item.title}\n`;
|
|
1037
|
+
}
|
|
1038
|
+
report += ` Reason: ${item.reason}\n\n`;
|
|
1039
|
+
itemNumber++;
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Duplicate items
|
|
1046
|
+
if (totalDuplicates > 0) {
|
|
1047
|
+
report += `DUPLICATE ITEMS (${totalDuplicates} total):\n\n`;
|
|
1048
|
+
itemNumber = 1;
|
|
1049
|
+
for (const [dataType, result] of Object.entries(results)) {
|
|
1050
|
+
if (result.discrepancies.duplicates.length > 0) {
|
|
1051
|
+
report += ` From ${dataType}:\n`;
|
|
1052
|
+
result.discrepancies.duplicates.forEach((item) => {
|
|
1053
|
+
report += ` ${itemNumber}. `;
|
|
1054
|
+
if (dataType === "emails") {
|
|
1055
|
+
report += `FilePath indexed ${item.count} times:\n`;
|
|
1056
|
+
report += ` ${item.filePath}\n`;
|
|
1057
|
+
report += ` Subject: ${item.subject}\n\n`;
|
|
1058
|
+
} else if (dataType === "messages") {
|
|
1059
|
+
report += `Message ID ${item.id} indexed ${item.count} times:\n`;
|
|
1060
|
+
report += ` Text: ${item.text}\n`;
|
|
1061
|
+
report += ` Sender: ${item.sender}\n\n`;
|
|
1062
|
+
} else if (dataType === "calendar") {
|
|
1063
|
+
report += `Event ID ${item.id} indexed ${item.count} times:\n`;
|
|
1064
|
+
report += ` Title: ${item.title}\n\n`;
|
|
1065
|
+
}
|
|
1066
|
+
itemNumber++;
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
} else {
|
|
1072
|
+
report += "✓ Perfect index health - all source data is correctly indexed\n";
|
|
1073
|
+
report += "✓ No missing items\n";
|
|
1074
|
+
report += "✓ No orphaned entries\n";
|
|
1075
|
+
report += "✓ No duplicate entries\n";
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
report += "\n" + "=".repeat(60) + "\n";
|
|
1079
|
+
report += "END OF AUDIT REPORT\n";
|
|
1080
|
+
report += "=".repeat(60) + "\n";
|
|
1081
|
+
|
|
1082
|
+
return report;
|
|
1083
|
+
}
|