imsg-mcp 1.2.0 → 1.3.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/CHANGELOG.md +18 -0
- package/README.md +4 -1
- package/dist/cli.js +47 -5
- package/dist/cli.js.map +1 -1
- package/dist/{types-Bbi1xtl-.js → exportFormats-CWWiy5uz.js} +1 -10
- package/dist/exportFormats-CWWiy5uz.js.map +1 -0
- package/dist/{exportStream-Bu5recJ4.js → exportStream-B86sO7Mg.js} +19 -23
- package/dist/exportStream-B86sO7Mg.js.map +1 -0
- package/dist/{imessage-db-BYU5mxM-.js → imessage-db-C7w6mu1Q.js} +824 -411
- package/dist/imessage-db-C7w6mu1Q.js.map +1 -0
- package/dist/index.js +70 -34
- package/dist/index.js.map +1 -1
- package/dist/{setup-C8Ys73o9.js → setup-B2HejfMT.js} +2 -2
- package/dist/{setup-C8Ys73o9.js.map → setup-B2HejfMT.js.map} +1 -1
- package/dist/{shutdown-BJifUGdo.js → shutdown-CikCSZru.js} +4 -4
- package/dist/{shutdown-BJifUGdo.js.map → shutdown-CikCSZru.js.map} +1 -1
- package/dist/tui.js +20 -6
- package/dist/tui.js.map +1 -1
- package/dist/{watchdog-0RGOHpj4.js → watchdog-CnbBZnfg.js} +11 -2
- package/dist/watchdog-CnbBZnfg.js.map +1 -0
- package/native/imsg-native.darwin-arm64.node +0 -0
- package/native/index.d.ts +2 -74
- package/native/index.js +0 -2
- package/package.json +1 -1
- package/skills/imsg-mcp/SKILL.md +5 -0
- package/dist/exportStream-Bu5recJ4.js.map +0 -1
- package/dist/imessage-db-BYU5mxM-.js.map +0 -1
- package/dist/types-Bbi1xtl-.js.map +0 -1
- package/dist/watchdog-0RGOHpj4.js.map +0 -1
|
@@ -1,11 +1,378 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import {
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
3
|
import Database from "better-sqlite3";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { existsSync, readdirSync, mkdirSync } from "node:fs";
|
|
7
|
-
import { O as OBJECT_REPLACEMENT_CHAR, p as perf, J as isGroupGuid, K as isGroupChatIdentifier, L as generateThreadSlug, T as Tables, M as AssociatedMessageType, N as macAutoTimestampToDate, P as isReactionType, Q as MAC_EPOCH_OFFSET, R as NANOS_PER_SECOND, S as
|
|
7
|
+
import { O as OBJECT_REPLACEMENT_CHAR, p as perf, J as isGroupGuid, K as isGroupChatIdentifier, L as generateThreadSlug, T as Tables, M as AssociatedMessageType, N as macAutoTimestampToDate, P as isReactionType, Q as MAC_EPOCH_OFFSET, R as NANOS_PER_SECOND, S as parseAssociatedMessageGuid$1, U as macTimestampToDate$1 } from "./shutdown-CikCSZru.js";
|
|
8
8
|
import { parseBuffer } from "bplist-parser";
|
|
9
|
+
function normalizePhoneNumber(phone) {
|
|
10
|
+
const digits = phone.replace(/\D/g, "");
|
|
11
|
+
if (digits.length === 11 && digits.startsWith("1")) {
|
|
12
|
+
return digits.slice(1);
|
|
13
|
+
}
|
|
14
|
+
return digits;
|
|
15
|
+
}
|
|
16
|
+
function normalizedPhoneVariants(phone) {
|
|
17
|
+
const normalized = normalizePhoneNumber(phone);
|
|
18
|
+
const variants = /* @__PURE__ */ new Set([normalized]);
|
|
19
|
+
if (normalized.length === 11 && normalized.startsWith("61")) {
|
|
20
|
+
const localMobile = normalized.slice(2);
|
|
21
|
+
variants.add(localMobile);
|
|
22
|
+
if (localMobile.length === 9 && localMobile.startsWith("4")) {
|
|
23
|
+
variants.add(`0${localMobile}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (normalized.length === 10 && normalized.startsWith("04")) {
|
|
27
|
+
const mobileDigits = normalized.slice(1);
|
|
28
|
+
variants.add(mobileDigits);
|
|
29
|
+
variants.add(`61${mobileDigits}`);
|
|
30
|
+
}
|
|
31
|
+
if (normalized.length === 10) {
|
|
32
|
+
variants.add(`1${normalized}`);
|
|
33
|
+
}
|
|
34
|
+
if (normalized.length === 9 && normalized.startsWith("4")) {
|
|
35
|
+
variants.add(`61${normalized}`);
|
|
36
|
+
variants.add(`0${normalized}`);
|
|
37
|
+
}
|
|
38
|
+
return [...variants];
|
|
39
|
+
}
|
|
40
|
+
function normalizeEmail(email) {
|
|
41
|
+
return email.toLowerCase().trim();
|
|
42
|
+
}
|
|
43
|
+
function normalizeNameForMatch(name) {
|
|
44
|
+
if (!name) return "";
|
|
45
|
+
return name.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
46
|
+
}
|
|
47
|
+
function nameCandidates(fields) {
|
|
48
|
+
const out = /* @__PURE__ */ new Set();
|
|
49
|
+
const add = (n) => {
|
|
50
|
+
const norm = normalizeNameForMatch(n);
|
|
51
|
+
if (norm) out.add(norm);
|
|
52
|
+
};
|
|
53
|
+
add(fields.nickname);
|
|
54
|
+
const full = [fields.firstName, fields.lastName].filter(Boolean).join(" ");
|
|
55
|
+
add(full);
|
|
56
|
+
add(fields.organization);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
const ADDRESS_BOOK_DIR = join(homedir(), "Library", "Application Support", "AddressBook");
|
|
60
|
+
const MAIN_DB_NAME = "AddressBook-v22.abcddb";
|
|
61
|
+
function discoverContactDbPaths(customPaths) {
|
|
62
|
+
if (customPaths) {
|
|
63
|
+
const list = Array.isArray(customPaths) ? customPaths : [customPaths];
|
|
64
|
+
return list.filter((p) => existsSync(p));
|
|
65
|
+
}
|
|
66
|
+
const paths = [];
|
|
67
|
+
const mainDb = join(ADDRESS_BOOK_DIR, MAIN_DB_NAME);
|
|
68
|
+
if (existsSync(mainDb)) paths.push(mainDb);
|
|
69
|
+
const sourcesDir = join(ADDRESS_BOOK_DIR, "Sources");
|
|
70
|
+
if (existsSync(sourcesDir)) {
|
|
71
|
+
try {
|
|
72
|
+
const subdirs = readdirSync(sourcesDir, { withFileTypes: true });
|
|
73
|
+
for (const d of subdirs) {
|
|
74
|
+
if (!d.isDirectory()) continue;
|
|
75
|
+
const sourceDb = join(sourcesDir, d.name, MAIN_DB_NAME);
|
|
76
|
+
if (existsSync(sourceDb)) paths.push(sourceDb);
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return paths;
|
|
82
|
+
}
|
|
83
|
+
class ContactsDB {
|
|
84
|
+
dbPaths = [];
|
|
85
|
+
databases = [];
|
|
86
|
+
phoneMap = /* @__PURE__ */ new Map();
|
|
87
|
+
emailMap = /* @__PURE__ */ new Map();
|
|
88
|
+
contactCache = /* @__PURE__ */ new Map();
|
|
89
|
+
initialized = false;
|
|
90
|
+
/** Next id when loading from multiple DBs (avoids Z_PK collisions across sources). */
|
|
91
|
+
nextContactId = 1;
|
|
92
|
+
constructor(dbPaths) {
|
|
93
|
+
this.dbPaths = discoverContactDbPaths(dbPaths);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Initialize the contact lookup maps from all discovered DBs (local + iCloud/sources).
|
|
97
|
+
*/
|
|
98
|
+
initialize() {
|
|
99
|
+
if (this.initialized) return;
|
|
100
|
+
for (const path of this.dbPaths) {
|
|
101
|
+
try {
|
|
102
|
+
const db = new Database(path, { readonly: true });
|
|
103
|
+
this.databases.push(db);
|
|
104
|
+
this.loadContactsFromDb(db);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.warn(`ContactsDB: could not load ${path}:`, err);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
this.initialized = true;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Load contacts from a single Address Book SQLite DB into the shared maps.
|
|
113
|
+
*/
|
|
114
|
+
loadContactsFromDb(db) {
|
|
115
|
+
const contacts = db.prepare(`
|
|
116
|
+
SELECT
|
|
117
|
+
Z_PK as localId,
|
|
118
|
+
ZFIRSTNAME as firstName,
|
|
119
|
+
ZLASTNAME as lastName,
|
|
120
|
+
ZMIDDLENAME as middleName,
|
|
121
|
+
ZNICKNAME as nickname,
|
|
122
|
+
ZORGANIZATION as organization
|
|
123
|
+
FROM ZABCDRECORD
|
|
124
|
+
WHERE ZFIRSTNAME IS NOT NULL OR ZLASTNAME IS NOT NULL OR ZORGANIZATION IS NOT NULL
|
|
125
|
+
`).all();
|
|
126
|
+
for (const row of contacts) {
|
|
127
|
+
const localId = row.localId;
|
|
128
|
+
const phones = db.prepare(`
|
|
129
|
+
SELECT ZFULLNUMBER as number, ZLABEL as label
|
|
130
|
+
FROM ZABCDPHONENUMBER
|
|
131
|
+
WHERE ZOWNER = ? OR Z22_OWNER = ?
|
|
132
|
+
`).all(localId, localId).filter((p) => p.number);
|
|
133
|
+
const emails = db.prepare(`
|
|
134
|
+
SELECT ZADDRESS as email, ZLABEL as label
|
|
135
|
+
FROM ZABCDEMAILADDRESS
|
|
136
|
+
WHERE ZOWNER = ? OR Z22_OWNER = ?
|
|
137
|
+
`).all(localId, localId).filter((e) => e.email);
|
|
138
|
+
const existingIds = this.findExistingContactIds(
|
|
139
|
+
phones.map((p) => p.number),
|
|
140
|
+
emails.map((e) => e.email)
|
|
141
|
+
);
|
|
142
|
+
const cardNames = nameCandidates(row);
|
|
143
|
+
const sameEntityIds = existingIds.filter((id) => {
|
|
144
|
+
const existing = this.contactCache.get(id);
|
|
145
|
+
if (!existing) return false;
|
|
146
|
+
const existingNames = nameCandidates(existing);
|
|
147
|
+
if (cardNames.size === 0 || existingNames.size === 0) return true;
|
|
148
|
+
for (const n of cardNames) if (existingNames.has(n)) return true;
|
|
149
|
+
return false;
|
|
150
|
+
});
|
|
151
|
+
let contact;
|
|
152
|
+
if (sameEntityIds.length === 0) {
|
|
153
|
+
contact = {
|
|
154
|
+
id: this.nextContactId++,
|
|
155
|
+
firstName: row.firstName,
|
|
156
|
+
lastName: row.lastName,
|
|
157
|
+
middleName: row.middleName,
|
|
158
|
+
nickname: row.nickname,
|
|
159
|
+
organization: row.organization,
|
|
160
|
+
displayName: this.buildDisplayName(row),
|
|
161
|
+
phoneNumbers: [],
|
|
162
|
+
emails: []
|
|
163
|
+
};
|
|
164
|
+
} else {
|
|
165
|
+
const survivorId = sameEntityIds[0];
|
|
166
|
+
for (let i = 1; i < sameEntityIds.length; i++) {
|
|
167
|
+
this.mergeContacts(survivorId, sameEntityIds[i]);
|
|
168
|
+
}
|
|
169
|
+
contact = this.contactCache.get(survivorId);
|
|
170
|
+
}
|
|
171
|
+
const handleName = this.buildDisplayName(row);
|
|
172
|
+
for (const phone of phones) {
|
|
173
|
+
if (!contact.phoneNumbers.includes(phone.number)) contact.phoneNumbers.push(phone.number);
|
|
174
|
+
for (const variant of normalizedPhoneVariants(phone.number)) {
|
|
175
|
+
if (!this.phoneMap.has(variant)) {
|
|
176
|
+
this.phoneMap.set(variant, {
|
|
177
|
+
contactId: contact.id,
|
|
178
|
+
displayName: handleName,
|
|
179
|
+
label: phone.label
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (const email of emails) {
|
|
185
|
+
if (!contact.emails.includes(email.email)) contact.emails.push(email.email);
|
|
186
|
+
const key = normalizeEmail(email.email);
|
|
187
|
+
if (!this.emailMap.has(key)) {
|
|
188
|
+
this.emailMap.set(key, {
|
|
189
|
+
contactId: contact.id,
|
|
190
|
+
displayName: handleName,
|
|
191
|
+
label: email.label
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
this.contactCache.set(contact.id, contact);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Probe the already-loaded lookup maps for every existing contactId that
|
|
200
|
+
* shares any of these handles (loaded from earlier source DBs). Returns the
|
|
201
|
+
* distinct ids in first-seen order; the caller treats the first as the
|
|
202
|
+
* survivor and unions the rest. Empty when none match.
|
|
203
|
+
*/
|
|
204
|
+
findExistingContactIds(phones, emails) {
|
|
205
|
+
const ids = [];
|
|
206
|
+
const seen = /* @__PURE__ */ new Set();
|
|
207
|
+
const add = (id) => {
|
|
208
|
+
if (!seen.has(id)) {
|
|
209
|
+
seen.add(id);
|
|
210
|
+
ids.push(id);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
for (const number of phones) {
|
|
214
|
+
for (const variant of normalizedPhoneVariants(number)) {
|
|
215
|
+
const hit = this.phoneMap.get(variant);
|
|
216
|
+
if (hit) add(hit.contactId);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const email of emails) {
|
|
220
|
+
const hit = this.emailMap.get(normalizeEmail(email));
|
|
221
|
+
if (hit) add(hit.contactId);
|
|
222
|
+
}
|
|
223
|
+
return ids;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Fold contact `dropId` into `keepId`: union their phone/email lists and
|
|
227
|
+
* re-point every lookup-map entry (preserving labels) from the dropped id to
|
|
228
|
+
* the survivor, then forget the dropped id. Used when one card bridges two
|
|
229
|
+
* previously-separate identities.
|
|
230
|
+
*/
|
|
231
|
+
mergeContacts(keepId, dropId) {
|
|
232
|
+
if (keepId === dropId) return;
|
|
233
|
+
const keep = this.contactCache.get(keepId);
|
|
234
|
+
const drop = this.contactCache.get(dropId);
|
|
235
|
+
if (!keep || !drop) return;
|
|
236
|
+
for (const p of drop.phoneNumbers)
|
|
237
|
+
if (!keep.phoneNumbers.includes(p)) keep.phoneNumbers.push(p);
|
|
238
|
+
for (const e of drop.emails) if (!keep.emails.includes(e)) keep.emails.push(e);
|
|
239
|
+
for (const [key, lookup] of this.phoneMap) {
|
|
240
|
+
if (lookup.contactId === dropId) {
|
|
241
|
+
this.phoneMap.set(key, { ...lookup, contactId: keepId });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
for (const [key, lookup] of this.emailMap) {
|
|
245
|
+
if (lookup.contactId === dropId) {
|
|
246
|
+
this.emailMap.set(key, { ...lookup, contactId: keepId });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
this.contactCache.delete(dropId);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Look up a contact by phone number or email.
|
|
253
|
+
*/
|
|
254
|
+
lookupContact(handle) {
|
|
255
|
+
if (!this.initialized) {
|
|
256
|
+
this.initialize();
|
|
257
|
+
}
|
|
258
|
+
if (/[\d+\-()\s]/.test(handle)) {
|
|
259
|
+
for (const variant of normalizedPhoneVariants(handle)) {
|
|
260
|
+
const contact = this.phoneMap.get(variant);
|
|
261
|
+
if (contact) {
|
|
262
|
+
return contact;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (handle.includes("@")) {
|
|
267
|
+
const normalized = normalizeEmail(handle);
|
|
268
|
+
const contact = this.emailMap.get(normalized);
|
|
269
|
+
if (contact) {
|
|
270
|
+
return contact;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Look up a contact by phone number or email
|
|
277
|
+
* Returns display name if found, or the original handle if not
|
|
278
|
+
*/
|
|
279
|
+
lookupHandle(handle) {
|
|
280
|
+
return this.lookupContact(handle)?.displayName ?? handle;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Get full contact details by ID
|
|
284
|
+
*/
|
|
285
|
+
getContact(id) {
|
|
286
|
+
if (!this.initialized) {
|
|
287
|
+
this.initialize();
|
|
288
|
+
}
|
|
289
|
+
return this.contactCache.get(id) || null;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* A load-order-independent identity anchor for a contact: the
|
|
293
|
+
* lexicographically smallest normalized handle. Contact ids are assigned in
|
|
294
|
+
* Address Book load order, so they renumber whenever any card is added or
|
|
295
|
+
* removed — anything persisted across sessions (thread-slug hashes) must key
|
|
296
|
+
* off this anchor instead, which only changes if the contact's own handles
|
|
297
|
+
* change.
|
|
298
|
+
*/
|
|
299
|
+
stableAnchor(contactId) {
|
|
300
|
+
const contact = this.getContact(contactId);
|
|
301
|
+
if (!contact) return null;
|
|
302
|
+
const handles = [
|
|
303
|
+
...contact.phoneNumbers.map((p) => normalizePhoneNumber(p)),
|
|
304
|
+
...contact.emails.map((e) => normalizeEmail(e))
|
|
305
|
+
].filter((h) => h.length > 0);
|
|
306
|
+
if (handles.length === 0) return null;
|
|
307
|
+
handles.sort();
|
|
308
|
+
return handles[0];
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Search contacts by name
|
|
312
|
+
*/
|
|
313
|
+
searchContacts(query) {
|
|
314
|
+
if (!this.initialized) {
|
|
315
|
+
this.initialize();
|
|
316
|
+
}
|
|
317
|
+
const lowerQuery = query.toLowerCase();
|
|
318
|
+
return Array.from(this.contactCache.values()).filter(
|
|
319
|
+
(c) => c.displayName.toLowerCase().includes(lowerQuery) || c.phoneNumbers.some((p) => p.includes(query)) || c.emails.some((e) => e.toLowerCase().includes(lowerQuery))
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Build display name from contact fields
|
|
324
|
+
*/
|
|
325
|
+
buildDisplayName(row) {
|
|
326
|
+
if (row.nickname) return row.nickname;
|
|
327
|
+
const parts = [];
|
|
328
|
+
if (row.firstName) parts.push(row.firstName);
|
|
329
|
+
if (row.lastName) parts.push(row.lastName);
|
|
330
|
+
if (parts.length > 0) {
|
|
331
|
+
return parts.join(" ");
|
|
332
|
+
}
|
|
333
|
+
if (row.organization) return row.organization;
|
|
334
|
+
return "Unknown Contact";
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Close all database connections
|
|
338
|
+
*/
|
|
339
|
+
close() {
|
|
340
|
+
for (const db of this.databases) {
|
|
341
|
+
try {
|
|
342
|
+
db.close();
|
|
343
|
+
} catch {
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
this.databases = [];
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Paginated list of all loaded contacts, sorted by displayName.
|
|
350
|
+
* Used by the MCP `list_contacts` tool.
|
|
351
|
+
*/
|
|
352
|
+
listContacts(offset = 0, limit = 20) {
|
|
353
|
+
if (!this.initialized) {
|
|
354
|
+
this.initialize();
|
|
355
|
+
}
|
|
356
|
+
const all = Array.from(this.contactCache.values()).sort(
|
|
357
|
+
(a, b) => a.displayName.localeCompare(b.displayName)
|
|
358
|
+
);
|
|
359
|
+
const slice = limit === 0 ? all.slice(offset) : all.slice(offset, offset + limit);
|
|
360
|
+
return { contacts: slice, total: all.length };
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Get statistics about loaded contacts
|
|
364
|
+
*/
|
|
365
|
+
getStats() {
|
|
366
|
+
if (!this.initialized) {
|
|
367
|
+
this.initialize();
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
totalContacts: this.contactCache.size,
|
|
371
|
+
phoneNumbers: this.phoneMap.size,
|
|
372
|
+
emails: this.emailMap.size
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
9
376
|
const __dirname$1 = dirname(fileURLToPath(import.meta.url));
|
|
10
377
|
let _native;
|
|
11
378
|
function tryLoadNative() {
|
|
@@ -306,318 +673,60 @@ function normalizeCandidate(text) {
|
|
|
306
673
|
}
|
|
307
674
|
function scoreCandidate(text) {
|
|
308
675
|
let score = 0;
|
|
309
|
-
if (/[A-Za-z]/.test(text)) score += 50;
|
|
310
|
-
if (/\d/.test(text)) score += 10;
|
|
311
|
-
if (/\s/.test(text)) score += 30;
|
|
312
|
-
if (/[.!?]/.test(text)) score += 15;
|
|
313
|
-
if (/[\u{1F300}-\u{1FAFF}]/u.test(text)) score += 100;
|
|
314
|
-
if (/^[A-Za-z0-9]{1,3}$/.test(text)) score -= 100;
|
|
315
|
-
score += Math.min(text.length, 200);
|
|
316
|
-
return score;
|
|
317
|
-
}
|
|
318
|
-
const STRUCTURED_BOOST = 500;
|
|
319
|
-
function extractAttributedBodyText(blob) {
|
|
320
|
-
if (!blob) return void 0;
|
|
321
|
-
const native = getNativeParser();
|
|
322
|
-
if (native) {
|
|
323
|
-
try {
|
|
324
|
-
const result = native(blob);
|
|
325
|
-
if (result && result.length > 1 && nativeResultLooksTrustworthy(result)) {
|
|
326
|
-
return result;
|
|
327
|
-
}
|
|
328
|
-
} catch {
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
const parser = new TypedStreamParser(blob);
|
|
332
|
-
const candidates = /* @__PURE__ */ new Map();
|
|
333
|
-
const deadline = Date.now() + PARSE_TIMEOUT_MS;
|
|
334
|
-
const remember = (values, boost = 0) => {
|
|
335
|
-
for (const value of values) {
|
|
336
|
-
const normalized = normalizeCandidate(value);
|
|
337
|
-
if (!normalized) continue;
|
|
338
|
-
const score = scoreCandidate(normalized) + boost;
|
|
339
|
-
const prev = candidates.get(normalized) ?? -Infinity;
|
|
340
|
-
if (score > prev) candidates.set(normalized, score);
|
|
341
|
-
}
|
|
342
|
-
};
|
|
343
|
-
try {
|
|
344
|
-
if (Date.now() < deadline) {
|
|
345
|
-
remember(
|
|
346
|
-
parser.parseAllNSStrings().map((entry) => entry.content),
|
|
347
|
-
STRUCTURED_BOOST
|
|
348
|
-
);
|
|
349
|
-
}
|
|
350
|
-
} catch {
|
|
351
|
-
}
|
|
352
|
-
try {
|
|
353
|
-
if (Date.now() < deadline) {
|
|
354
|
-
remember(parser.extractReadableText());
|
|
355
|
-
}
|
|
356
|
-
} catch {
|
|
357
|
-
}
|
|
358
|
-
if (Date.now() < deadline) {
|
|
359
|
-
remember(blob.toString("utf8").split(CONTROL_SPLIT_RE));
|
|
360
|
-
}
|
|
361
|
-
const best = [...candidates.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
362
|
-
return best?.[0];
|
|
363
|
-
}
|
|
364
|
-
function normalizePhoneNumber(phone) {
|
|
365
|
-
const digits = phone.replace(/\D/g, "");
|
|
366
|
-
if (digits.length === 11 && digits.startsWith("1")) {
|
|
367
|
-
return digits.slice(1);
|
|
368
|
-
}
|
|
369
|
-
return digits;
|
|
370
|
-
}
|
|
371
|
-
function normalizedPhoneVariants(phone) {
|
|
372
|
-
const normalized = normalizePhoneNumber(phone);
|
|
373
|
-
const variants = /* @__PURE__ */ new Set([normalized]);
|
|
374
|
-
if (normalized.length === 11 && normalized.startsWith("61")) {
|
|
375
|
-
const localMobile = normalized.slice(2);
|
|
376
|
-
variants.add(localMobile);
|
|
377
|
-
if (localMobile.length === 9 && localMobile.startsWith("4")) {
|
|
378
|
-
variants.add(`0${localMobile}`);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
if (normalized.length === 10 && normalized.startsWith("04")) {
|
|
382
|
-
const mobileDigits = normalized.slice(1);
|
|
383
|
-
variants.add(mobileDigits);
|
|
384
|
-
variants.add(`61${mobileDigits}`);
|
|
385
|
-
}
|
|
386
|
-
if (normalized.length === 10) {
|
|
387
|
-
variants.add(`1${normalized}`);
|
|
388
|
-
}
|
|
389
|
-
if (normalized.length === 9 && normalized.startsWith("4")) {
|
|
390
|
-
variants.add(`61${normalized}`);
|
|
391
|
-
variants.add(`0${normalized}`);
|
|
392
|
-
}
|
|
393
|
-
return [...variants];
|
|
394
|
-
}
|
|
395
|
-
function normalizeEmail(email) {
|
|
396
|
-
return email.toLowerCase().trim();
|
|
397
|
-
}
|
|
398
|
-
const ADDRESS_BOOK_DIR = join(homedir(), "Library", "Application Support", "AddressBook");
|
|
399
|
-
const MAIN_DB_NAME = "AddressBook-v22.abcddb";
|
|
400
|
-
function discoverContactDbPaths(customPaths) {
|
|
401
|
-
if (customPaths) {
|
|
402
|
-
const list = Array.isArray(customPaths) ? customPaths : [customPaths];
|
|
403
|
-
return list.filter((p) => existsSync(p));
|
|
404
|
-
}
|
|
405
|
-
const paths = [];
|
|
406
|
-
const mainDb = join(ADDRESS_BOOK_DIR, MAIN_DB_NAME);
|
|
407
|
-
if (existsSync(mainDb)) paths.push(mainDb);
|
|
408
|
-
const sourcesDir = join(ADDRESS_BOOK_DIR, "Sources");
|
|
409
|
-
if (existsSync(sourcesDir)) {
|
|
410
|
-
try {
|
|
411
|
-
const subdirs = readdirSync(sourcesDir, { withFileTypes: true });
|
|
412
|
-
for (const d of subdirs) {
|
|
413
|
-
if (!d.isDirectory()) continue;
|
|
414
|
-
const sourceDb = join(sourcesDir, d.name, MAIN_DB_NAME);
|
|
415
|
-
if (existsSync(sourceDb)) paths.push(sourceDb);
|
|
416
|
-
}
|
|
417
|
-
} catch {
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
return paths;
|
|
421
|
-
}
|
|
422
|
-
class ContactsDB {
|
|
423
|
-
dbPaths = [];
|
|
424
|
-
databases = [];
|
|
425
|
-
phoneMap = /* @__PURE__ */ new Map();
|
|
426
|
-
emailMap = /* @__PURE__ */ new Map();
|
|
427
|
-
contactCache = /* @__PURE__ */ new Map();
|
|
428
|
-
initialized = false;
|
|
429
|
-
/** Next id when loading from multiple DBs (avoids Z_PK collisions across sources). */
|
|
430
|
-
nextContactId = 1;
|
|
431
|
-
constructor(dbPaths) {
|
|
432
|
-
this.dbPaths = discoverContactDbPaths(dbPaths);
|
|
433
|
-
}
|
|
434
|
-
/**
|
|
435
|
-
* Initialize the contact lookup maps from all discovered DBs (local + iCloud/sources).
|
|
436
|
-
*/
|
|
437
|
-
initialize() {
|
|
438
|
-
if (this.initialized) return;
|
|
439
|
-
for (const path of this.dbPaths) {
|
|
440
|
-
try {
|
|
441
|
-
const db = new Database(path, { readonly: true });
|
|
442
|
-
this.databases.push(db);
|
|
443
|
-
this.loadContactsFromDb(db);
|
|
444
|
-
} catch (err) {
|
|
445
|
-
console.warn(`ContactsDB: could not load ${path}:`, err);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
this.initialized = true;
|
|
449
|
-
}
|
|
450
|
-
/**
|
|
451
|
-
* Load contacts from a single Address Book SQLite DB into the shared maps.
|
|
452
|
-
*/
|
|
453
|
-
loadContactsFromDb(db) {
|
|
454
|
-
const contacts = db.prepare(`
|
|
455
|
-
SELECT
|
|
456
|
-
Z_PK as localId,
|
|
457
|
-
ZFIRSTNAME as firstName,
|
|
458
|
-
ZLASTNAME as lastName,
|
|
459
|
-
ZMIDDLENAME as middleName,
|
|
460
|
-
ZNICKNAME as nickname,
|
|
461
|
-
ZORGANIZATION as organization
|
|
462
|
-
FROM ZABCDRECORD
|
|
463
|
-
WHERE ZFIRSTNAME IS NOT NULL OR ZLASTNAME IS NOT NULL OR ZORGANIZATION IS NOT NULL
|
|
464
|
-
`).all();
|
|
465
|
-
for (const row of contacts) {
|
|
466
|
-
const globalId = this.nextContactId++;
|
|
467
|
-
const contact = {
|
|
468
|
-
id: globalId,
|
|
469
|
-
firstName: row.firstName,
|
|
470
|
-
lastName: row.lastName,
|
|
471
|
-
middleName: row.middleName,
|
|
472
|
-
nickname: row.nickname,
|
|
473
|
-
organization: row.organization,
|
|
474
|
-
displayName: this.buildDisplayName(row),
|
|
475
|
-
phoneNumbers: [],
|
|
476
|
-
emails: []
|
|
477
|
-
};
|
|
478
|
-
const localId = row.localId;
|
|
479
|
-
const phones = db.prepare(`
|
|
480
|
-
SELECT ZFULLNUMBER as number, ZLABEL as label
|
|
481
|
-
FROM ZABCDPHONENUMBER
|
|
482
|
-
WHERE ZOWNER = ? OR Z22_OWNER = ?
|
|
483
|
-
`).all(localId, localId);
|
|
484
|
-
for (const phone of phones) {
|
|
485
|
-
if (phone.number) {
|
|
486
|
-
contact.phoneNumbers.push(phone.number);
|
|
487
|
-
const lookup = {
|
|
488
|
-
contactId: contact.id,
|
|
489
|
-
displayName: contact.displayName,
|
|
490
|
-
label: phone.label
|
|
491
|
-
};
|
|
492
|
-
for (const variant of normalizedPhoneVariants(phone.number)) {
|
|
493
|
-
this.phoneMap.set(variant, lookup);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
const emails = db.prepare(`
|
|
498
|
-
SELECT ZADDRESS as email, ZLABEL as label
|
|
499
|
-
FROM ZABCDEMAILADDRESS
|
|
500
|
-
WHERE ZOWNER = ? OR Z22_OWNER = ?
|
|
501
|
-
`).all(localId, localId);
|
|
502
|
-
for (const email of emails) {
|
|
503
|
-
if (email.email) {
|
|
504
|
-
contact.emails.push(email.email);
|
|
505
|
-
const normalized = normalizeEmail(email.email);
|
|
506
|
-
this.emailMap.set(normalized, {
|
|
507
|
-
contactId: contact.id,
|
|
508
|
-
displayName: contact.displayName,
|
|
509
|
-
label: email.label
|
|
510
|
-
});
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
this.contactCache.set(contact.id, contact);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
/**
|
|
517
|
-
* Look up a contact by phone number or email.
|
|
518
|
-
*/
|
|
519
|
-
lookupContact(handle) {
|
|
520
|
-
if (!this.initialized) {
|
|
521
|
-
this.initialize();
|
|
522
|
-
}
|
|
523
|
-
if (/[\d+\-()\s]/.test(handle)) {
|
|
524
|
-
for (const variant of normalizedPhoneVariants(handle)) {
|
|
525
|
-
const contact = this.phoneMap.get(variant);
|
|
526
|
-
if (contact) {
|
|
527
|
-
return contact;
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
if (handle.includes("@")) {
|
|
532
|
-
const normalized = normalizeEmail(handle);
|
|
533
|
-
const contact = this.emailMap.get(normalized);
|
|
534
|
-
if (contact) {
|
|
535
|
-
return contact;
|
|
676
|
+
if (/[A-Za-z]/.test(text)) score += 50;
|
|
677
|
+
if (/\d/.test(text)) score += 10;
|
|
678
|
+
if (/\s/.test(text)) score += 30;
|
|
679
|
+
if (/[.!?]/.test(text)) score += 15;
|
|
680
|
+
if (/[\u{1F300}-\u{1FAFF}]/u.test(text)) score += 100;
|
|
681
|
+
if (/^[A-Za-z0-9]{1,3}$/.test(text)) score -= 100;
|
|
682
|
+
score += Math.min(text.length, 200);
|
|
683
|
+
return score;
|
|
684
|
+
}
|
|
685
|
+
const STRUCTURED_BOOST = 500;
|
|
686
|
+
function extractAttributedBodyText(blob) {
|
|
687
|
+
if (!blob) return void 0;
|
|
688
|
+
const native = getNativeParser();
|
|
689
|
+
if (native) {
|
|
690
|
+
try {
|
|
691
|
+
const result = native(blob);
|
|
692
|
+
if (result && result.length > 1 && nativeResultLooksTrustworthy(result)) {
|
|
693
|
+
return result;
|
|
536
694
|
}
|
|
695
|
+
} catch {
|
|
537
696
|
}
|
|
538
|
-
return null;
|
|
539
|
-
}
|
|
540
|
-
/**
|
|
541
|
-
* Look up a contact by phone number or email
|
|
542
|
-
* Returns display name if found, or the original handle if not
|
|
543
|
-
*/
|
|
544
|
-
lookupHandle(handle) {
|
|
545
|
-
return this.lookupContact(handle)?.displayName ?? handle;
|
|
546
|
-
}
|
|
547
|
-
/**
|
|
548
|
-
* Get full contact details by ID
|
|
549
|
-
*/
|
|
550
|
-
getContact(id) {
|
|
551
|
-
if (!this.initialized) {
|
|
552
|
-
this.initialize();
|
|
553
|
-
}
|
|
554
|
-
return this.contactCache.get(id) || null;
|
|
555
|
-
}
|
|
556
|
-
/**
|
|
557
|
-
* Search contacts by name
|
|
558
|
-
*/
|
|
559
|
-
searchContacts(query) {
|
|
560
|
-
if (!this.initialized) {
|
|
561
|
-
this.initialize();
|
|
562
|
-
}
|
|
563
|
-
const lowerQuery = query.toLowerCase();
|
|
564
|
-
return Array.from(this.contactCache.values()).filter(
|
|
565
|
-
(c) => c.displayName.toLowerCase().includes(lowerQuery) || c.phoneNumbers.some((p) => p.includes(query)) || c.emails.some((e) => e.toLowerCase().includes(lowerQuery))
|
|
566
|
-
);
|
|
567
697
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
698
|
+
const parser = new TypedStreamParser(blob);
|
|
699
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
700
|
+
const deadline = Date.now() + PARSE_TIMEOUT_MS;
|
|
701
|
+
const remember = (values, boost = 0) => {
|
|
702
|
+
for (const value of values) {
|
|
703
|
+
const normalized = normalizeCandidate(value);
|
|
704
|
+
if (!normalized) continue;
|
|
705
|
+
const score = scoreCandidate(normalized) + boost;
|
|
706
|
+
const prev = candidates.get(normalized) ?? -Infinity;
|
|
707
|
+
if (score > prev) candidates.set(normalized, score);
|
|
578
708
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
for (const db of this.databases) {
|
|
587
|
-
try {
|
|
588
|
-
db.close();
|
|
589
|
-
} catch {
|
|
590
|
-
}
|
|
709
|
+
};
|
|
710
|
+
try {
|
|
711
|
+
if (Date.now() < deadline) {
|
|
712
|
+
remember(
|
|
713
|
+
parser.parseAllNSStrings().map((entry) => entry.content),
|
|
714
|
+
STRUCTURED_BOOST
|
|
715
|
+
);
|
|
591
716
|
}
|
|
592
|
-
|
|
717
|
+
} catch {
|
|
593
718
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
*/
|
|
598
|
-
listContacts(offset = 0, limit = 20) {
|
|
599
|
-
if (!this.initialized) {
|
|
600
|
-
this.initialize();
|
|
719
|
+
try {
|
|
720
|
+
if (Date.now() < deadline) {
|
|
721
|
+
remember(parser.extractReadableText());
|
|
601
722
|
}
|
|
602
|
-
|
|
603
|
-
(a, b) => a.displayName.localeCompare(b.displayName)
|
|
604
|
-
);
|
|
605
|
-
const slice = limit === 0 ? all.slice(offset) : all.slice(offset, offset + limit);
|
|
606
|
-
return { contacts: slice, total: all.length };
|
|
723
|
+
} catch {
|
|
607
724
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
*/
|
|
611
|
-
getStats() {
|
|
612
|
-
if (!this.initialized) {
|
|
613
|
-
this.initialize();
|
|
614
|
-
}
|
|
615
|
-
return {
|
|
616
|
-
totalContacts: this.contactCache.size,
|
|
617
|
-
phoneNumbers: this.phoneMap.size,
|
|
618
|
-
emails: this.emailMap.size
|
|
619
|
-
};
|
|
725
|
+
if (Date.now() < deadline) {
|
|
726
|
+
remember(blob.toString("utf8").split(CONTROL_SPLIT_RE));
|
|
620
727
|
}
|
|
728
|
+
const best = [...candidates.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
729
|
+
return best?.[0];
|
|
621
730
|
}
|
|
622
731
|
function normalizeSnippetText(text) {
|
|
623
732
|
if (!text) return null;
|
|
@@ -723,20 +832,42 @@ class SlugStore {
|
|
|
723
832
|
this.db.pragma("journal_mode = WAL");
|
|
724
833
|
this.migrate();
|
|
725
834
|
}
|
|
835
|
+
/**
|
|
836
|
+
* Schema. v2 splits identity-level slug rows from a many-to-one guid map so a
|
|
837
|
+
* single canonical slug can cover every chat leg of one contact (phone + email,
|
|
838
|
+
* SMS + iMessage). v1 keyed a slug to exactly one guid, so a merged identity
|
|
839
|
+
* produced multiple slugs whose surfaced one flipped with recency.
|
|
840
|
+
*/
|
|
726
841
|
migrate() {
|
|
842
|
+
const version = this.db.pragma("user_version", { simple: true }) ?? 0;
|
|
843
|
+
if (version < 2) {
|
|
844
|
+
this.db.exec("DROP TABLE IF EXISTS thread_slugs");
|
|
845
|
+
this.db.exec("DROP TABLE IF EXISTS slug_chat_guids");
|
|
846
|
+
}
|
|
727
847
|
this.db.exec(`
|
|
728
848
|
CREATE TABLE IF NOT EXISTS thread_slugs (
|
|
729
849
|
slug TEXT PRIMARY KEY,
|
|
730
|
-
chat_guid TEXT UNIQUE NOT NULL,
|
|
731
850
|
chat_identifier TEXT NOT NULL,
|
|
732
851
|
display_name TEXT,
|
|
733
852
|
service TEXT NOT NULL DEFAULT 'iMessage',
|
|
734
853
|
is_group INTEGER NOT NULL DEFAULT 0,
|
|
735
854
|
participants TEXT NOT NULL DEFAULT '',
|
|
736
855
|
updated_at INTEGER NOT NULL DEFAULT 0
|
|
737
|
-
)
|
|
856
|
+
);
|
|
857
|
+
CREATE TABLE IF NOT EXISTS slug_chat_guids (
|
|
858
|
+
chat_guid TEXT PRIMARY KEY,
|
|
859
|
+
slug TEXT NOT NULL
|
|
860
|
+
);
|
|
861
|
+
CREATE INDEX IF NOT EXISTS idx_slug_chat_guids_slug ON slug_chat_guids(slug);
|
|
738
862
|
`);
|
|
863
|
+
this.db.pragma("user_version = 2");
|
|
739
864
|
}
|
|
865
|
+
/**
|
|
866
|
+
* Upsert one chat leg under its identity slug. The identity row is keyed by
|
|
867
|
+
* slug (idempotent across legs); the leg's guid is mapped to that slug. When
|
|
868
|
+
* an identity already has a phone `chat_identifier`, a later email leg does
|
|
869
|
+
* NOT overwrite it — phone is preferred as the canonical handle for stability.
|
|
870
|
+
*/
|
|
740
871
|
upsert(record) {
|
|
741
872
|
const params = {
|
|
742
873
|
slug: record.slug,
|
|
@@ -748,33 +879,28 @@ class SlugStore {
|
|
|
748
879
|
participants: record.participants,
|
|
749
880
|
updatedAt: record.updatedAt
|
|
750
881
|
};
|
|
751
|
-
const
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
is_group = excluded.is_group,
|
|
774
|
-
participants = excluded.participants,
|
|
775
|
-
updated_at = excluded.updated_at
|
|
776
|
-
`);
|
|
777
|
-
insertOrUpdateBySlug.run(params);
|
|
882
|
+
const tx = this.db.transaction(() => {
|
|
883
|
+
this.db.prepare(`
|
|
884
|
+
INSERT INTO thread_slugs (slug, chat_identifier, display_name, service, is_group, participants, updated_at)
|
|
885
|
+
VALUES (@slug, @chatIdentifier, @displayName, @service, @isGroup, @participants, @updatedAt)
|
|
886
|
+
ON CONFLICT(slug) DO UPDATE SET
|
|
887
|
+
chat_identifier = CASE
|
|
888
|
+
WHEN instr(thread_slugs.chat_identifier, '@') = 0 AND instr(excluded.chat_identifier, '@') > 0
|
|
889
|
+
THEN thread_slugs.chat_identifier
|
|
890
|
+
ELSE excluded.chat_identifier
|
|
891
|
+
END,
|
|
892
|
+
display_name = excluded.display_name,
|
|
893
|
+
service = excluded.service,
|
|
894
|
+
is_group = excluded.is_group,
|
|
895
|
+
participants = excluded.participants,
|
|
896
|
+
updated_at = excluded.updated_at
|
|
897
|
+
`).run(params);
|
|
898
|
+
this.db.prepare(`
|
|
899
|
+
INSERT INTO slug_chat_guids (chat_guid, slug) VALUES (@chatGuid, @slug)
|
|
900
|
+
ON CONFLICT(chat_guid) DO UPDATE SET slug = excluded.slug
|
|
901
|
+
`).run(params);
|
|
902
|
+
});
|
|
903
|
+
tx();
|
|
778
904
|
}
|
|
779
905
|
upsertMany(records) {
|
|
780
906
|
const tx = this.db.transaction((recs) => {
|
|
@@ -784,39 +910,56 @@ class SlugStore {
|
|
|
784
910
|
}
|
|
785
911
|
lookupBySlug(slug) {
|
|
786
912
|
const row = this.db.prepare("SELECT * FROM thread_slugs WHERE slug = ?").get(slug);
|
|
787
|
-
return row ? this.rowToRecord(row) : null;
|
|
913
|
+
return row ? this.rowToRecord(row, this.representativeGuid(slug)) : null;
|
|
788
914
|
}
|
|
789
915
|
lookupByGuid(chatGuid) {
|
|
790
|
-
const
|
|
791
|
-
|
|
916
|
+
const link = this.db.prepare("SELECT slug FROM slug_chat_guids WHERE chat_guid = ?").get(chatGuid);
|
|
917
|
+
if (!link) return null;
|
|
918
|
+
const row = this.db.prepare("SELECT * FROM thread_slugs WHERE slug = ?").get(link.slug);
|
|
919
|
+
return row ? this.rowToRecord(row, chatGuid) : null;
|
|
792
920
|
}
|
|
793
921
|
lookupByChatIdentifier(chatIdentifier) {
|
|
794
922
|
const row = this.db.prepare("SELECT * FROM thread_slugs WHERE chat_identifier = ?").get(chatIdentifier);
|
|
795
|
-
return row ? this.rowToRecord(row) : null;
|
|
923
|
+
return row ? this.rowToRecord(row, this.representativeGuid(row.slug)) : null;
|
|
796
924
|
}
|
|
797
925
|
all() {
|
|
798
926
|
const rows = this.db.prepare("SELECT * FROM thread_slugs ORDER BY updated_at DESC").all();
|
|
799
|
-
return rows.map((r) => this.rowToRecord(r));
|
|
927
|
+
return rows.map((r) => this.rowToRecord(r, this.representativeGuid(r.slug)));
|
|
800
928
|
}
|
|
801
|
-
/**
|
|
929
|
+
/** Every guid→slug link (used to rebuild the in-memory guid map on startup). */
|
|
930
|
+
guidLinks() {
|
|
931
|
+
const rows = this.db.prepare("SELECT chat_guid, slug FROM slug_chat_guids").all();
|
|
932
|
+
return rows.map((r) => ({ chatGuid: r.chat_guid, slug: r.slug }));
|
|
933
|
+
}
|
|
934
|
+
/** A stable representative guid for a slug (lowest guid, or "" if none). */
|
|
935
|
+
representativeGuid(slug) {
|
|
936
|
+
const row = this.db.prepare("SELECT MIN(chat_guid) as guid FROM slug_chat_guids WHERE slug = ?").get(slug);
|
|
937
|
+
return row?.guid ?? "";
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Drop guid links whose chat_guid is not in the valid set, then any identity
|
|
941
|
+
* slug left with no guids. Returns the number of guid links removed.
|
|
942
|
+
*/
|
|
802
943
|
prune(validGuids) {
|
|
803
|
-
const
|
|
804
|
-
const toDelete =
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
944
|
+
const links = this.db.prepare("SELECT chat_guid FROM slug_chat_guids").all();
|
|
945
|
+
const toDelete = links.filter((r) => !validGuids.has(r.chat_guid)).map((r) => r.chat_guid);
|
|
946
|
+
const tx = this.db.transaction(() => {
|
|
947
|
+
const delLink = this.db.prepare("DELETE FROM slug_chat_guids WHERE chat_guid = ?");
|
|
948
|
+
for (const guid of toDelete) delLink.run(guid);
|
|
949
|
+
this.db.exec(
|
|
950
|
+
"DELETE FROM thread_slugs WHERE slug NOT IN (SELECT DISTINCT slug FROM slug_chat_guids)"
|
|
951
|
+
);
|
|
809
952
|
});
|
|
810
|
-
tx(
|
|
953
|
+
tx();
|
|
811
954
|
return toDelete.length;
|
|
812
955
|
}
|
|
813
956
|
close() {
|
|
814
957
|
this.db.close();
|
|
815
958
|
}
|
|
816
|
-
rowToRecord(row) {
|
|
959
|
+
rowToRecord(row, chatGuid) {
|
|
817
960
|
return {
|
|
818
961
|
slug: row.slug,
|
|
819
|
-
chatGuid
|
|
962
|
+
chatGuid,
|
|
820
963
|
chatIdentifier: row.chat_identifier,
|
|
821
964
|
displayName: row.display_name,
|
|
822
965
|
service: row.service,
|
|
@@ -880,6 +1023,9 @@ function getRichContentType(balloonBundleId) {
|
|
|
880
1023
|
return "unknown";
|
|
881
1024
|
}
|
|
882
1025
|
const macTimestampToDate = macTimestampToDate$1;
|
|
1026
|
+
function dateToMacTimestamp(date) {
|
|
1027
|
+
return Math.floor((date.getTime() / 1e3 - MAC_EPOCH_OFFSET) * NANOS_PER_SECOND);
|
|
1028
|
+
}
|
|
883
1029
|
class IMessageDB {
|
|
884
1030
|
raw;
|
|
885
1031
|
dbPath;
|
|
@@ -890,6 +1036,8 @@ class IMessageDB {
|
|
|
890
1036
|
slugMap = /* @__PURE__ */ new Map();
|
|
891
1037
|
/** In-memory chat guid -> slug for stable per-chat slug lookups. */
|
|
892
1038
|
guidToSlug = /* @__PURE__ */ new Map();
|
|
1039
|
+
/** Per-sync canonical service per identity key (prefer iMessage) for slugs. */
|
|
1040
|
+
identityServiceMap = null;
|
|
893
1041
|
// ── TTL caches ───────────────────────────────────────────────────────
|
|
894
1042
|
static CACHE_TTL_MS = 3e4;
|
|
895
1043
|
cachedAllChats = null;
|
|
@@ -898,8 +1046,17 @@ class IMessageDB {
|
|
|
898
1046
|
cachedParticipants = /* @__PURE__ */ new Map();
|
|
899
1047
|
cachedMergeKeys = /* @__PURE__ */ new Map();
|
|
900
1048
|
cachedSnippets = /* @__PURE__ */ new Map();
|
|
1049
|
+
/**
|
|
1050
|
+
* Per-chat reaction map (guid → reactions), TTL-cached. A large export pages
|
|
1051
|
+
* the same merged chats dozens of times; without this, every page re-scanned
|
|
1052
|
+
* each chat's full reaction set — O(pages × chats) — which dominated big
|
|
1053
|
+
* exports and spiked event-loop lag.
|
|
1054
|
+
*/
|
|
1055
|
+
cachedReactionsByChat = /* @__PURE__ */ new Map();
|
|
901
1056
|
backgroundSyncNeeded = true;
|
|
902
1057
|
backgroundRefreshScheduled = false;
|
|
1058
|
+
/** Set on close() so background chunked work stops touching a closed DB. */
|
|
1059
|
+
closed = false;
|
|
903
1060
|
constructor(dbPath, contactsDbPaths, slugStorePath) {
|
|
904
1061
|
const span = perf("IMessageDB.constructor");
|
|
905
1062
|
this.dbPath = dbPath || join(homedir(), "Library", "Messages", "chat.db");
|
|
@@ -923,9 +1080,11 @@ class IMessageDB {
|
|
|
923
1080
|
*/
|
|
924
1081
|
loadCachedSlugs() {
|
|
925
1082
|
const span = perf("loadCachedSlugs");
|
|
1083
|
+
for (const link of this.slugStore.guidLinks()) {
|
|
1084
|
+
this.guidToSlug.set(link.chatGuid, link.slug);
|
|
1085
|
+
}
|
|
926
1086
|
const records = this.slugStore.all();
|
|
927
1087
|
for (const r of records) {
|
|
928
|
-
this.guidToSlug.set(r.chatGuid, r.slug);
|
|
929
1088
|
this.slugMap.set(r.slug, {
|
|
930
1089
|
ROWID: 0,
|
|
931
1090
|
// placeholder -- will be filled on full sync
|
|
@@ -938,24 +1097,53 @@ class IMessageDB {
|
|
|
938
1097
|
}
|
|
939
1098
|
span.end({ loaded: records.length });
|
|
940
1099
|
}
|
|
941
|
-
/**
|
|
942
|
-
|
|
1100
|
+
/**
|
|
1101
|
+
* Compute the canonical slug for a chat without persisting anything.
|
|
1102
|
+
*
|
|
1103
|
+
* Identity key: every chat leg of one contact (phone + email, SMS +
|
|
1104
|
+
* iMessage) shares this, so they collapse to ONE stable slug. Groups stay
|
|
1105
|
+
* per-guid. Mirrors getConversationMergeKey so the slug tracks the merge.
|
|
1106
|
+
* The service segment is identity-wide (prefer iMessage) — otherwise the
|
|
1107
|
+
* SMS leg and iMessage leg of one contact would produce `…~sms~h` and
|
|
1108
|
+
* `…~imsg~h`, i.e. two slugs.
|
|
1109
|
+
*/
|
|
1110
|
+
computeSlugForChat(chat) {
|
|
943
1111
|
const isGroup = isGroupGuid(chat.guid) || isGroupChatIdentifier(chat.chat_identifier);
|
|
944
|
-
const
|
|
1112
|
+
const mergeKey = isGroup ? `group:${chat.guid}` : this.getConversationMergeKey(chat.chat_identifier, chat.guid, false);
|
|
1113
|
+
const canonicalService = isGroup ? this.detectServiceForChat(chat) : this.identityServiceMap?.get(mergeKey) ?? this.detectServiceForChat(chat);
|
|
1114
|
+
let identityKey = mergeKey;
|
|
1115
|
+
let resolvedName = null;
|
|
1116
|
+
if (!isGroup && chat.chat_identifier) {
|
|
1117
|
+
const lookup = this.contacts.lookupContact(chat.chat_identifier);
|
|
1118
|
+
if (lookup) {
|
|
1119
|
+
const anchor = this.contacts.stableAnchor(lookup.contactId);
|
|
1120
|
+
if (anchor) identityKey = `contact:${anchor}`;
|
|
1121
|
+
resolvedName = this.contacts.getContact(lookup.contactId)?.displayName ?? lookup.displayName;
|
|
1122
|
+
} else {
|
|
1123
|
+
const fallback = this.contacts.lookupHandle(chat.chat_identifier);
|
|
1124
|
+
resolvedName = fallback !== chat.chat_identifier ? fallback : null;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
945
1127
|
const slug = generateThreadSlug({
|
|
946
1128
|
chatIdentifier: chat.chat_identifier,
|
|
947
1129
|
guid: chat.guid,
|
|
948
1130
|
displayName: chat.display_name,
|
|
949
|
-
serviceName:
|
|
950
|
-
resolvedContactName: resolvedName
|
|
1131
|
+
serviceName: canonicalService,
|
|
1132
|
+
resolvedContactName: resolvedName,
|
|
1133
|
+
identityKey
|
|
951
1134
|
});
|
|
1135
|
+
return { slug, isGroup, resolvedName, canonicalService };
|
|
1136
|
+
}
|
|
1137
|
+
/** Generate and cache a slug for a single chat. */
|
|
1138
|
+
syncSlugForChat(chat) {
|
|
1139
|
+
const { slug, isGroup, resolvedName, canonicalService } = this.computeSlugForChat(chat);
|
|
952
1140
|
const participants = isGroup ? this.fetchChatParticipants(chat.ROWID) : [chat.chat_identifier];
|
|
953
1141
|
this.slugStore.upsert({
|
|
954
1142
|
slug,
|
|
955
1143
|
chatGuid: chat.guid,
|
|
956
1144
|
chatIdentifier: chat.chat_identifier,
|
|
957
|
-
displayName: resolvedName
|
|
958
|
-
service:
|
|
1145
|
+
displayName: resolvedName ?? chat.display_name ?? null,
|
|
1146
|
+
service: canonicalService,
|
|
959
1147
|
isGroup,
|
|
960
1148
|
participants: participants.join(","),
|
|
961
1149
|
updatedAt: Date.now()
|
|
@@ -973,21 +1161,30 @@ class IMessageDB {
|
|
|
973
1161
|
this.backgroundSyncNeeded = false;
|
|
974
1162
|
const span = perf("backgroundSlugSync");
|
|
975
1163
|
const chats = this.getAllChatsWithLastDate();
|
|
1164
|
+
const identityService = /* @__PURE__ */ new Map();
|
|
1165
|
+
for (const c of chats) {
|
|
1166
|
+
if (isGroupGuid(c.guid) || isGroupChatIdentifier(c.chat_identifier)) continue;
|
|
1167
|
+
const key = this.getConversationMergeKey(c.chat_identifier, c.guid, false);
|
|
1168
|
+
const svc = this.detectServiceForChat(c);
|
|
1169
|
+
if (svc === "iMessage" || !identityService.has(key)) identityService.set(key, svc);
|
|
1170
|
+
}
|
|
1171
|
+
this.identityServiceMap = identityService;
|
|
976
1172
|
const validGuids = /* @__PURE__ */ new Set();
|
|
977
1173
|
let index = 0;
|
|
978
1174
|
let synced = 0;
|
|
979
1175
|
const CHUNK = 100;
|
|
980
1176
|
const processChunk = () => {
|
|
1177
|
+
if (this.closed) return;
|
|
981
1178
|
const end = Math.min(index + CHUNK, chats.length);
|
|
982
1179
|
for (; index < end; index++) {
|
|
983
1180
|
const chat = chats[index];
|
|
984
1181
|
validGuids.add(chat.guid);
|
|
985
|
-
|
|
1182
|
+
const expected = this.computeSlugForChat(chat).slug;
|
|
1183
|
+
if (this.guidToSlug.get(chat.guid) !== expected) {
|
|
986
1184
|
this.syncSlugForChat(chat);
|
|
987
1185
|
synced++;
|
|
988
1186
|
} else {
|
|
989
|
-
|
|
990
|
-
this.slugMap.set(existingSlug, chat);
|
|
1187
|
+
this.slugMap.set(expected, chat);
|
|
991
1188
|
}
|
|
992
1189
|
}
|
|
993
1190
|
if (index < chats.length) {
|
|
@@ -1102,13 +1299,17 @@ class IMessageDB {
|
|
|
1102
1299
|
return [];
|
|
1103
1300
|
}
|
|
1104
1301
|
const perChatLimit = Math.max(limit * 2, 50);
|
|
1302
|
+
const beforeDate = beforeMessageId != null ? this.dateOfMessage(beforeMessageId) : void 0;
|
|
1303
|
+
const afterDate = afterMessageId != null ? this.dateOfMessage(afterMessageId) : void 0;
|
|
1105
1304
|
const result = /* @__PURE__ */ new Map();
|
|
1106
1305
|
for (const chat of chats) {
|
|
1107
1306
|
const rows = this.fetchMessagesForChatRowId(
|
|
1108
1307
|
chat.ROWID,
|
|
1109
1308
|
perChatLimit,
|
|
1110
1309
|
beforeMessageId,
|
|
1111
|
-
afterMessageId
|
|
1310
|
+
afterMessageId,
|
|
1311
|
+
beforeDate,
|
|
1312
|
+
afterDate
|
|
1112
1313
|
);
|
|
1113
1314
|
const extBatch = this.fetchExtendedMessageDataBatch(rows.map((r) => r.ROWID));
|
|
1114
1315
|
const reactionsByGuid = includeReactionDetails ? this.fetchReactionsForChat(chat.ROWID) : void 0;
|
|
@@ -1139,6 +1340,140 @@ class IMessageDB {
|
|
|
1139
1340
|
span.end({ limit, chats: chats.length, returned: sorted.length });
|
|
1140
1341
|
return sorted;
|
|
1141
1342
|
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Get one chronological export page for a conversation.
|
|
1345
|
+
*
|
|
1346
|
+
* This deliberately does not use getMessagesForChat's ROWID-only
|
|
1347
|
+
* beforeMessageId contract. Exports need to walk the whole conversation in
|
|
1348
|
+
* display order across every chat row that resolves to the same visible
|
|
1349
|
+
* thread, dedupe messages linked to multiple chats before applying LIMIT,
|
|
1350
|
+
* and avoid letting reaction/system rows consume page slots.
|
|
1351
|
+
*/
|
|
1352
|
+
async getMessagesForChatExportPage(chatIdentifier, limit = 1e3, options = {}) {
|
|
1353
|
+
const span = perf("getMessagesForChatExportPage");
|
|
1354
|
+
const {
|
|
1355
|
+
includeReactions = false,
|
|
1356
|
+
includeReactionDetails = false,
|
|
1357
|
+
afterCursor,
|
|
1358
|
+
since,
|
|
1359
|
+
until
|
|
1360
|
+
} = options;
|
|
1361
|
+
const pageLimit = Math.max(1, limit);
|
|
1362
|
+
const chats = this.resolveChatsForConversation(chatIdentifier);
|
|
1363
|
+
if (chats.length === 0) {
|
|
1364
|
+
span.end({ limit: pageLimit, returned: 0 });
|
|
1365
|
+
return { messages: [], nextCursor: null, rawCount: 0 };
|
|
1366
|
+
}
|
|
1367
|
+
const chatIds = chats.map((chat) => chat.ROWID);
|
|
1368
|
+
const chatPlaceholders = chatIds.map(() => "?").join(",");
|
|
1369
|
+
const conditions = [
|
|
1370
|
+
`cmj.chat_id IN (${chatPlaceholders})`,
|
|
1371
|
+
"COALESCE(m.item_type, 0) = 0"
|
|
1372
|
+
];
|
|
1373
|
+
const params = [...chatIds];
|
|
1374
|
+
if (!includeReactions) {
|
|
1375
|
+
conditions.push(`COALESCE(m.associated_message_type, 0) = ${AssociatedMessageType.NORMAL}`);
|
|
1376
|
+
}
|
|
1377
|
+
if (afterCursor) {
|
|
1378
|
+
conditions.push("(m.date > ? OR (m.date = ? AND m.ROWID > ?))");
|
|
1379
|
+
params.push(afterCursor.date, afterCursor.date, afterCursor.rowid);
|
|
1380
|
+
}
|
|
1381
|
+
if (since) {
|
|
1382
|
+
conditions.push("m.date >= ?");
|
|
1383
|
+
params.push(dateToMacTimestamp(since));
|
|
1384
|
+
}
|
|
1385
|
+
if (until) {
|
|
1386
|
+
conditions.push("m.date <= ?");
|
|
1387
|
+
params.push(dateToMacTimestamp(until));
|
|
1388
|
+
}
|
|
1389
|
+
params.push(pageLimit);
|
|
1390
|
+
const stmt = this.raw.prepare(`
|
|
1391
|
+
WITH target_messages AS (
|
|
1392
|
+
SELECT
|
|
1393
|
+
m.ROWID,
|
|
1394
|
+
m.date,
|
|
1395
|
+
MIN(c.chat_identifier) as chat_identifier
|
|
1396
|
+
FROM ${Tables.MESSAGE} m
|
|
1397
|
+
JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
|
|
1398
|
+
JOIN ${Tables.CHAT} c ON c.ROWID = cmj.chat_id
|
|
1399
|
+
WHERE ${conditions.join("\n AND ")}
|
|
1400
|
+
GROUP BY m.ROWID, m.date
|
|
1401
|
+
ORDER BY m.date ASC, m.ROWID ASC
|
|
1402
|
+
LIMIT ?
|
|
1403
|
+
)
|
|
1404
|
+
SELECT
|
|
1405
|
+
m.ROWID,
|
|
1406
|
+
m.guid,
|
|
1407
|
+
m.text,
|
|
1408
|
+
m.attributedBody,
|
|
1409
|
+
m.date,
|
|
1410
|
+
m.is_from_me,
|
|
1411
|
+
h.id as handle_id,
|
|
1412
|
+
h.service as handle_service,
|
|
1413
|
+
m.cache_has_attachments,
|
|
1414
|
+
tm.chat_identifier,
|
|
1415
|
+
m.is_read,
|
|
1416
|
+
m.date_read,
|
|
1417
|
+
m.is_delivered,
|
|
1418
|
+
m.date_delivered,
|
|
1419
|
+
m.associated_message_type,
|
|
1420
|
+
m.associated_message_guid,
|
|
1421
|
+
m.associated_message_emoji,
|
|
1422
|
+
m.thread_originator_guid,
|
|
1423
|
+
m.thread_originator_part,
|
|
1424
|
+
m.balloon_bundle_id,
|
|
1425
|
+
m.item_type,
|
|
1426
|
+
m.date_edited,
|
|
1427
|
+
m.date_retracted,
|
|
1428
|
+
m.message_summary_info,
|
|
1429
|
+
m.payload_data
|
|
1430
|
+
FROM target_messages tm
|
|
1431
|
+
JOIN ${Tables.MESSAGE} m ON m.ROWID = tm.ROWID
|
|
1432
|
+
LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
|
|
1433
|
+
ORDER BY tm.date ASC, tm.ROWID ASC
|
|
1434
|
+
`);
|
|
1435
|
+
const rows = stmt.all(...params);
|
|
1436
|
+
const reactionsByGuid = /* @__PURE__ */ new Map();
|
|
1437
|
+
if (includeReactionDetails) {
|
|
1438
|
+
for (const chat of chats) {
|
|
1439
|
+
for (const [guid, reactions] of this.fetchReactionsForChat(chat.ROWID)) {
|
|
1440
|
+
const existing = reactionsByGuid.get(guid);
|
|
1441
|
+
if (existing) existing.push(...reactions);
|
|
1442
|
+
else reactionsByGuid.set(guid, [...reactions]);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
const messages = [];
|
|
1447
|
+
for (const row of rows) {
|
|
1448
|
+
const text = this.parseMessageText(row);
|
|
1449
|
+
if (isHiddenSystemItem(row.item_type)) continue;
|
|
1450
|
+
const converted = this.convertMessage(
|
|
1451
|
+
row,
|
|
1452
|
+
text,
|
|
1453
|
+
row.chat_identifier ?? chatIdentifier,
|
|
1454
|
+
row,
|
|
1455
|
+
false
|
|
1456
|
+
);
|
|
1457
|
+
if (!includeReactions && converted.isReaction) continue;
|
|
1458
|
+
if (includeReactionDetails && !converted.isReaction) {
|
|
1459
|
+
const reactions = reactionsByGuid.get(row.guid);
|
|
1460
|
+
if (reactions && reactions.length > 0) {
|
|
1461
|
+
converted.reactions = this.consolidateReactions(reactions);
|
|
1462
|
+
if (converted.reactions.length === 0) converted.reactions = void 0;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
messages.push(converted);
|
|
1466
|
+
}
|
|
1467
|
+
const lastRow = rows[rows.length - 1];
|
|
1468
|
+
const nextCursor = lastRow ? { date: lastRow.date, rowid: lastRow.ROWID } : null;
|
|
1469
|
+
span.end({
|
|
1470
|
+
limit: pageLimit,
|
|
1471
|
+
chats: chats.length,
|
|
1472
|
+
rows: rows.length,
|
|
1473
|
+
returned: messages.length
|
|
1474
|
+
});
|
|
1475
|
+
return { messages, nextCursor, rawCount: rows.length };
|
|
1476
|
+
}
|
|
1142
1477
|
/**
|
|
1143
1478
|
* Get unread messages across all conversations, sorted by date descending (newest first).
|
|
1144
1479
|
* Excludes reactions for cleaner output.
|
|
@@ -1195,7 +1530,7 @@ class IMessageDB {
|
|
|
1195
1530
|
*/
|
|
1196
1531
|
async getMessagesAfter(chatIdentifier, afterMessageId) {
|
|
1197
1532
|
const messages = await this.getMessagesForChat(chatIdentifier, 1e3, { afterMessageId });
|
|
1198
|
-
const filtered = messages.filter((m) => m.id
|
|
1533
|
+
const filtered = messages.filter((m) => m.id !== afterMessageId && !m.isFromMe);
|
|
1199
1534
|
filtered.sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
1200
1535
|
return filtered;
|
|
1201
1536
|
}
|
|
@@ -1215,8 +1550,7 @@ class IMessageDB {
|
|
|
1215
1550
|
last: lastByChat[chat.ROWID]
|
|
1216
1551
|
}));
|
|
1217
1552
|
sortEntries.sort((a, b) => b.lastDate - a.lastDate);
|
|
1218
|
-
const
|
|
1219
|
-
const prepared = candidates.map(({ chat, isGroup, last }) => {
|
|
1553
|
+
const enrich = ({ chat, isGroup, last }) => {
|
|
1220
1554
|
const lastDate = last ? macTimestampToDate(last.lastDate) : null;
|
|
1221
1555
|
const rawIdentifier = chat.chat_identifier;
|
|
1222
1556
|
let displayName = chat.display_name;
|
|
@@ -1246,8 +1580,17 @@ class IMessageDB {
|
|
|
1246
1580
|
serviceType
|
|
1247
1581
|
}
|
|
1248
1582
|
};
|
|
1249
|
-
}
|
|
1250
|
-
const
|
|
1583
|
+
};
|
|
1584
|
+
const CHUNK = Math.max(limit, 200);
|
|
1585
|
+
const prepared = [];
|
|
1586
|
+
let cursor = 0;
|
|
1587
|
+
let deduped = this.mergeDuplicateConversations(prepared);
|
|
1588
|
+
while (deduped.length < limit && cursor < sortEntries.length) {
|
|
1589
|
+
const chunk = sortEntries.slice(cursor, cursor + CHUNK);
|
|
1590
|
+
cursor += chunk.length;
|
|
1591
|
+
for (const entry of chunk) prepared.push(enrich(entry));
|
|
1592
|
+
deduped = this.mergeDuplicateConversations(prepared);
|
|
1593
|
+
}
|
|
1251
1594
|
const selected = deduped.slice(0, limit);
|
|
1252
1595
|
const result = selected.map(({ conversation, last }) => ({
|
|
1253
1596
|
...conversation,
|
|
@@ -1255,7 +1598,7 @@ class IMessageDB {
|
|
|
1255
1598
|
}));
|
|
1256
1599
|
span.end({
|
|
1257
1600
|
chats: chats.length,
|
|
1258
|
-
|
|
1601
|
+
enriched: cursor,
|
|
1259
1602
|
deduped: deduped.length,
|
|
1260
1603
|
returned: result.length
|
|
1261
1604
|
});
|
|
@@ -1532,6 +1875,76 @@ class IMessageDB {
|
|
|
1532
1875
|
(chat) => !(isGroupGuid(chat.guid) || isGroupChatIdentifier(chat.chat_identifier)) && this.getConversationMergeKey(chat.chat_identifier, chat.guid, false) === mergeKey
|
|
1533
1876
|
).map((chat) => toChatRow(chat));
|
|
1534
1877
|
}
|
|
1878
|
+
/**
|
|
1879
|
+
* Completeness diagnostic for exports. Finds non-group chats that belong to
|
|
1880
|
+
* the same identity as the exported conversation but that the merge-key logic
|
|
1881
|
+
* did NOT fold in — e.g. a contact whose phone and email landed on separate
|
|
1882
|
+
* Address Book cards, or chats Apple linked via `person_centric_id`.
|
|
1883
|
+
*
|
|
1884
|
+
* Two independent signals, unioned and deduped by chat ROWID:
|
|
1885
|
+
* 1. **contactId invariant** (primary): any non-group chat whose handle
|
|
1886
|
+
* resolves (via the loaded Address Books, incl. iCloud sources) to the
|
|
1887
|
+
* same contactId as the merged set but wasn't merged. Catches merge
|
|
1888
|
+
* regressions and normalization gaps.
|
|
1889
|
+
* 2. **person_centric_id** (fallback): Apple's own cross-handle link. Still
|
|
1890
|
+
* fires when the contact isn't in any Address Book — but note this column
|
|
1891
|
+
* is NULL on many real chat.dbs, so signal (1) is the dependable one.
|
|
1892
|
+
*
|
|
1893
|
+
* Inherent limit: a contact in NO Address Book *and* with a NULL
|
|
1894
|
+
* person_centric_id cannot be linked from data alone — that case is guarded
|
|
1895
|
+
* by the cross-source merge tests, not this diagnostic. Read-only.
|
|
1896
|
+
*/
|
|
1897
|
+
findUnmergedSiblingChats(chatIdentifier) {
|
|
1898
|
+
const merged = this.resolveChatsForConversation(chatIdentifier);
|
|
1899
|
+
if (merged.length === 0) return [];
|
|
1900
|
+
const mergedIds = new Set(merged.map((c) => c.ROWID));
|
|
1901
|
+
const siblings = /* @__PURE__ */ new Map();
|
|
1902
|
+
const isGroupChat = (guid, ident) => isGroupGuid(guid) || isGroupChatIdentifier(ident ?? "");
|
|
1903
|
+
const mergedContactIds = /* @__PURE__ */ new Set();
|
|
1904
|
+
for (const chat of merged) {
|
|
1905
|
+
const contact = this.contacts.lookupContact(chat.chat_identifier);
|
|
1906
|
+
if (contact) mergedContactIds.add(contact.contactId);
|
|
1907
|
+
}
|
|
1908
|
+
if (mergedContactIds.size > 0) {
|
|
1909
|
+
for (const chat of this.getAllChatsWithLastDate()) {
|
|
1910
|
+
if (mergedIds.has(chat.ROWID)) continue;
|
|
1911
|
+
if (isGroupChat(chat.guid, chat.chat_identifier)) continue;
|
|
1912
|
+
const contact = this.contacts.lookupContact(chat.chat_identifier);
|
|
1913
|
+
if (contact && mergedContactIds.has(contact.contactId)) {
|
|
1914
|
+
siblings.set(chat.ROWID, {
|
|
1915
|
+
chatIdentifier: chat.chat_identifier,
|
|
1916
|
+
reason: "resolves to the same contact but was not merged"
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
const mergedIdList = [...mergedIds];
|
|
1922
|
+
const placeholders = mergedIdList.map(() => "?").join(",");
|
|
1923
|
+
const rows = this.raw.prepare(`
|
|
1924
|
+
SELECT DISTINCT c.ROWID as rowid, c.guid, c.chat_identifier
|
|
1925
|
+
FROM ${Tables.CHAT} c
|
|
1926
|
+
JOIN ${Tables.CHAT_HANDLE_JOIN} chj ON chj.chat_id = c.ROWID
|
|
1927
|
+
JOIN ${Tables.HANDLE} h ON h.ROWID = chj.handle_id
|
|
1928
|
+
WHERE c.ROWID NOT IN (${placeholders})
|
|
1929
|
+
AND h.person_centric_id IN (
|
|
1930
|
+
SELECT DISTINCT h2.person_centric_id
|
|
1931
|
+
FROM ${Tables.CHAT_HANDLE_JOIN} chj2
|
|
1932
|
+
JOIN ${Tables.HANDLE} h2 ON h2.ROWID = chj2.handle_id
|
|
1933
|
+
WHERE chj2.chat_id IN (${placeholders})
|
|
1934
|
+
AND h2.person_centric_id IS NOT NULL
|
|
1935
|
+
AND h2.person_centric_id != ''
|
|
1936
|
+
)
|
|
1937
|
+
`).all(...mergedIdList, ...mergedIdList);
|
|
1938
|
+
for (const row of rows) {
|
|
1939
|
+
if (isGroupChat(row.guid, row.chat_identifier)) continue;
|
|
1940
|
+
if (siblings.has(row.rowid)) continue;
|
|
1941
|
+
siblings.set(row.rowid, {
|
|
1942
|
+
chatIdentifier: row.chat_identifier ?? row.guid,
|
|
1943
|
+
reason: "shares this contact's identity (person_centric_id) but was not merged"
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
return [...siblings.values()];
|
|
1947
|
+
}
|
|
1535
1948
|
/**
|
|
1536
1949
|
* Get all chats with their last message date in one join query.
|
|
1537
1950
|
* Used to resolve the correct chat for a contact when multiple chats exist (e.g. same number).
|
|
@@ -1642,53 +2055,45 @@ class IMessageDB {
|
|
|
1642
2055
|
).all();
|
|
1643
2056
|
return rows;
|
|
1644
2057
|
}
|
|
2058
|
+
/** Resolve a message ROWID to its date (for composite pagination cursors). */
|
|
2059
|
+
dateOfMessage(rowid) {
|
|
2060
|
+
const row = this.raw.prepare(`SELECT date FROM ${Tables.MESSAGE} WHERE ROWID = ?`).get(rowid);
|
|
2061
|
+
return row?.date;
|
|
2062
|
+
}
|
|
1645
2063
|
/**
|
|
1646
2064
|
* Fetch message rows for a chat ROWID, ordered by date DESC.
|
|
1647
|
-
*
|
|
2065
|
+
*
|
|
2066
|
+
* Pagination boundaries are composite `(date, ROWID)` cursors, NOT bare
|
|
2067
|
+
* ROWID comparisons: results are ordered by date, and in restored/merged
|
|
2068
|
+
* threads ROWID order diverges from date order — a ROWID-only bound both
|
|
2069
|
+
* skips older-date/higher-ROWID messages and re-shows newer-date/lower-ROWID
|
|
2070
|
+
* ones (paginating a real thread that way reached only ~47% of it; the same
|
|
2071
|
+
* defect made wait_for_reply able to miss replies entirely). ROWID-only is
|
|
2072
|
+
* kept solely as a fallback when the boundary message's date is unknown
|
|
2073
|
+
* (e.g. the boundary row was deleted).
|
|
1648
2074
|
*/
|
|
1649
|
-
fetchMessagesForChatRowId(chatRowId, limit, beforeMessageId, afterMessageId) {
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
SELECT
|
|
1653
|
-
m.ROWID, m.guid, m.text, m.attributedBody, m.date,
|
|
1654
|
-
m.is_from_me, h.id as handle_id, m.cache_has_attachments
|
|
1655
|
-
FROM ${Tables.MESSAGE} m
|
|
1656
|
-
LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
|
|
1657
|
-
LEFT JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
|
|
1658
|
-
WHERE cmj.chat_id = ? AND m.ROWID > ? AND m.ROWID < ?
|
|
1659
|
-
ORDER BY m.date DESC, m.ROWID DESC
|
|
1660
|
-
LIMIT ?
|
|
1661
|
-
`);
|
|
1662
|
-
return stmt2.all(chatRowId, afterMessageId, beforeMessageId, limit);
|
|
1663
|
-
}
|
|
2075
|
+
fetchMessagesForChatRowId(chatRowId, limit, beforeMessageId, afterMessageId, beforeDate, afterDate) {
|
|
2076
|
+
const conditions = ["cmj.chat_id = ?"];
|
|
2077
|
+
const params = [chatRowId];
|
|
1664
2078
|
if (beforeMessageId != null) {
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
WHERE cmj.chat_id = ? AND m.ROWID < ?
|
|
1673
|
-
ORDER BY m.date DESC, m.ROWID DESC
|
|
1674
|
-
LIMIT ?
|
|
1675
|
-
`);
|
|
1676
|
-
return stmt2.all(chatRowId, beforeMessageId, limit);
|
|
2079
|
+
if (beforeDate != null) {
|
|
2080
|
+
conditions.push("(m.date < ? OR (m.date = ? AND m.ROWID < ?))");
|
|
2081
|
+
params.push(beforeDate, beforeDate, beforeMessageId);
|
|
2082
|
+
} else {
|
|
2083
|
+
conditions.push("m.ROWID < ?");
|
|
2084
|
+
params.push(beforeMessageId);
|
|
2085
|
+
}
|
|
1677
2086
|
}
|
|
1678
2087
|
if (afterMessageId != null) {
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
WHERE cmj.chat_id = ? AND m.ROWID > ?
|
|
1687
|
-
ORDER BY m.date DESC, m.ROWID DESC
|
|
1688
|
-
LIMIT ?
|
|
1689
|
-
`);
|
|
1690
|
-
return stmt2.all(chatRowId, afterMessageId, limit);
|
|
2088
|
+
if (afterDate != null) {
|
|
2089
|
+
conditions.push("(m.date > ? OR (m.date = ? AND m.ROWID > ?))");
|
|
2090
|
+
params.push(afterDate, afterDate, afterMessageId);
|
|
2091
|
+
} else {
|
|
2092
|
+
conditions.push("m.ROWID > ?");
|
|
2093
|
+
params.push(afterMessageId);
|
|
2094
|
+
}
|
|
1691
2095
|
}
|
|
2096
|
+
params.push(limit);
|
|
1692
2097
|
const stmt = this.raw.prepare(`
|
|
1693
2098
|
SELECT
|
|
1694
2099
|
m.ROWID, m.guid, m.text, m.attributedBody, m.date,
|
|
@@ -1696,11 +2101,11 @@ class IMessageDB {
|
|
|
1696
2101
|
FROM ${Tables.MESSAGE} m
|
|
1697
2102
|
LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
|
|
1698
2103
|
LEFT JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
|
|
1699
|
-
WHERE
|
|
2104
|
+
WHERE ${conditions.join(" AND ")}
|
|
1700
2105
|
ORDER BY m.date DESC, m.ROWID DESC
|
|
1701
2106
|
LIMIT ?
|
|
1702
2107
|
`);
|
|
1703
|
-
return stmt.all(
|
|
2108
|
+
return stmt.all(...params);
|
|
1704
2109
|
}
|
|
1705
2110
|
/**
|
|
1706
2111
|
* Extract readable text from a message row.
|
|
@@ -1872,6 +2277,7 @@ class IMessageDB {
|
|
|
1872
2277
|
this.cachedAllChats = null;
|
|
1873
2278
|
this.cachedLastByChat = null;
|
|
1874
2279
|
this.cachedUnreadByChat = null;
|
|
2280
|
+
this.cachedReactionsByChat.clear();
|
|
1875
2281
|
this.getAllChatsWithLastDate();
|
|
1876
2282
|
this.scheduleBackgroundSlugSync();
|
|
1877
2283
|
} catch {
|
|
@@ -1886,6 +2292,7 @@ class IMessageDB {
|
|
|
1886
2292
|
* Close database connections
|
|
1887
2293
|
*/
|
|
1888
2294
|
async close() {
|
|
2295
|
+
this.closed = true;
|
|
1889
2296
|
this.raw.close();
|
|
1890
2297
|
this.contacts.close();
|
|
1891
2298
|
this.slugStore.close();
|
|
@@ -2126,6 +2533,10 @@ class IMessageDB {
|
|
|
2126
2533
|
* One query replaces N individual LIKE queries (the main perf bottleneck).
|
|
2127
2534
|
*/
|
|
2128
2535
|
fetchReactionsForChat(chatRowId) {
|
|
2536
|
+
const cached = this.cachedReactionsByChat.get(chatRowId);
|
|
2537
|
+
if (cached && Date.now() - cached.ts < IMessageDB.CACHE_TTL_MS) {
|
|
2538
|
+
return cached.data;
|
|
2539
|
+
}
|
|
2129
2540
|
const stmt = this.raw.prepare(`
|
|
2130
2541
|
SELECT
|
|
2131
2542
|
m.associated_message_type,
|
|
@@ -2161,6 +2572,7 @@ class IMessageDB {
|
|
|
2161
2572
|
if (existing) existing.push(reaction);
|
|
2162
2573
|
else result.set(targetGuid, [reaction]);
|
|
2163
2574
|
}
|
|
2575
|
+
this.cachedReactionsByChat.set(chatRowId, { data: result, ts: Date.now() });
|
|
2164
2576
|
return result;
|
|
2165
2577
|
}
|
|
2166
2578
|
/**
|
|
@@ -2242,6 +2654,7 @@ const imessageDb = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.definePr
|
|
|
2242
2654
|
export {
|
|
2243
2655
|
IMessageDB as I,
|
|
2244
2656
|
hasNativeModule as h,
|
|
2245
|
-
imessageDb as i
|
|
2657
|
+
imessageDb as i,
|
|
2658
|
+
normalizedPhoneVariants as n
|
|
2246
2659
|
};
|
|
2247
|
-
//# sourceMappingURL=imessage-db-
|
|
2660
|
+
//# sourceMappingURL=imessage-db-C7w6mu1Q.js.map
|