imsg-mcp 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +4 -1
- package/dist/cli.js +49 -7
- package/dist/cli.js.map +1 -1
- package/dist/{types-Bbi1xtl-.js → exportFormats-wytSyBTp.js} +6 -12
- package/dist/exportFormats-wytSyBTp.js.map +1 -0
- package/dist/{exportStream-Bu5recJ4.js → exportStream-BCoZYUUZ.js} +19 -23
- package/dist/exportStream-BCoZYUUZ.js.map +1 -0
- package/dist/{imessage-db-BYU5mxM-.js → imessage-db-vDZF7E8B.js} +828 -410
- package/dist/imessage-db-vDZF7E8B.js.map +1 -0
- package/dist/index.js +71 -35
- package/dist/index.js.map +1 -1
- package/dist/{meta-D3NoTAjA.js → meta-DCZ9Xe9g.js} +2 -2
- package/dist/meta-DCZ9Xe9g.js.map +1 -0
- 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 +32 -14
- 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 +3 -2
- 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/meta-D3NoTAjA.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() {
|
|
@@ -307,317 +674,59 @@ function normalizeCandidate(text) {
|
|
|
307
674
|
function scoreCandidate(text) {
|
|
308
675
|
let score = 0;
|
|
309
676
|
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;
|
|
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)));
|
|
928
|
+
}
|
|
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 }));
|
|
800
933
|
}
|
|
801
|
-
/**
|
|
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,22 @@ 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
|
+
* Invalidation contract: entries expire via their per-entry TTL on read, and
|
|
1056
|
+
* the whole map is cleared only in scheduleBackgroundRefresh() — every read
|
|
1057
|
+
* path that can observe new messages schedules that refresh, so there is no
|
|
1058
|
+
* other clear site. Add one if you introduce a read path that bypasses it.
|
|
1059
|
+
*/
|
|
1060
|
+
cachedReactionsByChat = /* @__PURE__ */ new Map();
|
|
901
1061
|
backgroundSyncNeeded = true;
|
|
902
1062
|
backgroundRefreshScheduled = false;
|
|
1063
|
+
/** Set on close() so background chunked work stops touching a closed DB. */
|
|
1064
|
+
closed = false;
|
|
903
1065
|
constructor(dbPath, contactsDbPaths, slugStorePath) {
|
|
904
1066
|
const span = perf("IMessageDB.constructor");
|
|
905
1067
|
this.dbPath = dbPath || join(homedir(), "Library", "Messages", "chat.db");
|
|
@@ -923,9 +1085,11 @@ class IMessageDB {
|
|
|
923
1085
|
*/
|
|
924
1086
|
loadCachedSlugs() {
|
|
925
1087
|
const span = perf("loadCachedSlugs");
|
|
1088
|
+
for (const link of this.slugStore.guidLinks()) {
|
|
1089
|
+
this.guidToSlug.set(link.chatGuid, link.slug);
|
|
1090
|
+
}
|
|
926
1091
|
const records = this.slugStore.all();
|
|
927
1092
|
for (const r of records) {
|
|
928
|
-
this.guidToSlug.set(r.chatGuid, r.slug);
|
|
929
1093
|
this.slugMap.set(r.slug, {
|
|
930
1094
|
ROWID: 0,
|
|
931
1095
|
// placeholder -- will be filled on full sync
|
|
@@ -938,24 +1102,53 @@ class IMessageDB {
|
|
|
938
1102
|
}
|
|
939
1103
|
span.end({ loaded: records.length });
|
|
940
1104
|
}
|
|
941
|
-
/**
|
|
942
|
-
|
|
1105
|
+
/**
|
|
1106
|
+
* Compute the canonical slug for a chat without persisting anything.
|
|
1107
|
+
*
|
|
1108
|
+
* Identity key: every chat leg of one contact (phone + email, SMS +
|
|
1109
|
+
* iMessage) shares this, so they collapse to ONE stable slug. Groups stay
|
|
1110
|
+
* per-guid. Mirrors getConversationMergeKey so the slug tracks the merge.
|
|
1111
|
+
* The service segment is identity-wide (prefer iMessage) — otherwise the
|
|
1112
|
+
* SMS leg and iMessage leg of one contact would produce `…~sms~h` and
|
|
1113
|
+
* `…~imsg~h`, i.e. two slugs.
|
|
1114
|
+
*/
|
|
1115
|
+
computeSlugForChat(chat) {
|
|
943
1116
|
const isGroup = isGroupGuid(chat.guid) || isGroupChatIdentifier(chat.chat_identifier);
|
|
944
|
-
const
|
|
1117
|
+
const mergeKey = isGroup ? `group:${chat.guid}` : this.getConversationMergeKey(chat.chat_identifier, chat.guid, false);
|
|
1118
|
+
const canonicalService = isGroup ? this.detectServiceForChat(chat) : this.identityServiceMap?.get(mergeKey) ?? this.detectServiceForChat(chat);
|
|
1119
|
+
let identityKey = mergeKey;
|
|
1120
|
+
let resolvedName = null;
|
|
1121
|
+
if (!isGroup && chat.chat_identifier) {
|
|
1122
|
+
const lookup = this.contacts.lookupContact(chat.chat_identifier);
|
|
1123
|
+
if (lookup) {
|
|
1124
|
+
const anchor = this.contacts.stableAnchor(lookup.contactId);
|
|
1125
|
+
if (anchor) identityKey = `contact:${anchor}`;
|
|
1126
|
+
resolvedName = this.contacts.getContact(lookup.contactId)?.displayName ?? lookup.displayName;
|
|
1127
|
+
} else {
|
|
1128
|
+
const fallback = this.contacts.lookupHandle(chat.chat_identifier);
|
|
1129
|
+
resolvedName = fallback !== chat.chat_identifier ? fallback : null;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
945
1132
|
const slug = generateThreadSlug({
|
|
946
1133
|
chatIdentifier: chat.chat_identifier,
|
|
947
1134
|
guid: chat.guid,
|
|
948
1135
|
displayName: chat.display_name,
|
|
949
|
-
serviceName:
|
|
950
|
-
resolvedContactName: resolvedName
|
|
1136
|
+
serviceName: canonicalService,
|
|
1137
|
+
resolvedContactName: resolvedName,
|
|
1138
|
+
identityKey
|
|
951
1139
|
});
|
|
1140
|
+
return { slug, isGroup, resolvedName, canonicalService };
|
|
1141
|
+
}
|
|
1142
|
+
/** Generate and cache a slug for a single chat. */
|
|
1143
|
+
syncSlugForChat(chat) {
|
|
1144
|
+
const { slug, isGroup, resolvedName, canonicalService } = this.computeSlugForChat(chat);
|
|
952
1145
|
const participants = isGroup ? this.fetchChatParticipants(chat.ROWID) : [chat.chat_identifier];
|
|
953
1146
|
this.slugStore.upsert({
|
|
954
1147
|
slug,
|
|
955
1148
|
chatGuid: chat.guid,
|
|
956
1149
|
chatIdentifier: chat.chat_identifier,
|
|
957
|
-
displayName: resolvedName
|
|
958
|
-
service:
|
|
1150
|
+
displayName: resolvedName ?? chat.display_name ?? null,
|
|
1151
|
+
service: canonicalService,
|
|
959
1152
|
isGroup,
|
|
960
1153
|
participants: participants.join(","),
|
|
961
1154
|
updatedAt: Date.now()
|
|
@@ -973,21 +1166,30 @@ class IMessageDB {
|
|
|
973
1166
|
this.backgroundSyncNeeded = false;
|
|
974
1167
|
const span = perf("backgroundSlugSync");
|
|
975
1168
|
const chats = this.getAllChatsWithLastDate();
|
|
1169
|
+
const identityService = /* @__PURE__ */ new Map();
|
|
1170
|
+
for (const c of chats) {
|
|
1171
|
+
if (isGroupGuid(c.guid) || isGroupChatIdentifier(c.chat_identifier)) continue;
|
|
1172
|
+
const key = this.getConversationMergeKey(c.chat_identifier, c.guid, false);
|
|
1173
|
+
const svc = this.detectServiceForChat(c);
|
|
1174
|
+
if (svc === "iMessage" || !identityService.has(key)) identityService.set(key, svc);
|
|
1175
|
+
}
|
|
1176
|
+
this.identityServiceMap = identityService;
|
|
976
1177
|
const validGuids = /* @__PURE__ */ new Set();
|
|
977
1178
|
let index = 0;
|
|
978
1179
|
let synced = 0;
|
|
979
1180
|
const CHUNK = 100;
|
|
980
1181
|
const processChunk = () => {
|
|
1182
|
+
if (this.closed) return;
|
|
981
1183
|
const end = Math.min(index + CHUNK, chats.length);
|
|
982
1184
|
for (; index < end; index++) {
|
|
983
1185
|
const chat = chats[index];
|
|
984
1186
|
validGuids.add(chat.guid);
|
|
985
|
-
|
|
1187
|
+
const expected = this.computeSlugForChat(chat).slug;
|
|
1188
|
+
if (this.guidToSlug.get(chat.guid) !== expected) {
|
|
986
1189
|
this.syncSlugForChat(chat);
|
|
987
1190
|
synced++;
|
|
988
1191
|
} else {
|
|
989
|
-
|
|
990
|
-
this.slugMap.set(existingSlug, chat);
|
|
1192
|
+
this.slugMap.set(expected, chat);
|
|
991
1193
|
}
|
|
992
1194
|
}
|
|
993
1195
|
if (index < chats.length) {
|
|
@@ -1102,13 +1304,17 @@ class IMessageDB {
|
|
|
1102
1304
|
return [];
|
|
1103
1305
|
}
|
|
1104
1306
|
const perChatLimit = Math.max(limit * 2, 50);
|
|
1307
|
+
const beforeDate = beforeMessageId != null ? this.dateOfMessage(beforeMessageId) : void 0;
|
|
1308
|
+
const afterDate = afterMessageId != null ? this.dateOfMessage(afterMessageId) : void 0;
|
|
1105
1309
|
const result = /* @__PURE__ */ new Map();
|
|
1106
1310
|
for (const chat of chats) {
|
|
1107
1311
|
const rows = this.fetchMessagesForChatRowId(
|
|
1108
1312
|
chat.ROWID,
|
|
1109
1313
|
perChatLimit,
|
|
1110
1314
|
beforeMessageId,
|
|
1111
|
-
afterMessageId
|
|
1315
|
+
afterMessageId,
|
|
1316
|
+
beforeDate,
|
|
1317
|
+
afterDate
|
|
1112
1318
|
);
|
|
1113
1319
|
const extBatch = this.fetchExtendedMessageDataBatch(rows.map((r) => r.ROWID));
|
|
1114
1320
|
const reactionsByGuid = includeReactionDetails ? this.fetchReactionsForChat(chat.ROWID) : void 0;
|
|
@@ -1139,6 +1345,140 @@ class IMessageDB {
|
|
|
1139
1345
|
span.end({ limit, chats: chats.length, returned: sorted.length });
|
|
1140
1346
|
return sorted;
|
|
1141
1347
|
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Get one chronological export page for a conversation.
|
|
1350
|
+
*
|
|
1351
|
+
* This deliberately does not use getMessagesForChat's ROWID-only
|
|
1352
|
+
* beforeMessageId contract. Exports need to walk the whole conversation in
|
|
1353
|
+
* display order across every chat row that resolves to the same visible
|
|
1354
|
+
* thread, dedupe messages linked to multiple chats before applying LIMIT,
|
|
1355
|
+
* and avoid letting reaction/system rows consume page slots.
|
|
1356
|
+
*/
|
|
1357
|
+
async getMessagesForChatExportPage(chatIdentifier, limit = 1e3, options = {}) {
|
|
1358
|
+
const span = perf("getMessagesForChatExportPage");
|
|
1359
|
+
const {
|
|
1360
|
+
includeReactions = false,
|
|
1361
|
+
includeReactionDetails = false,
|
|
1362
|
+
afterCursor,
|
|
1363
|
+
since,
|
|
1364
|
+
until
|
|
1365
|
+
} = options;
|
|
1366
|
+
const pageLimit = Math.max(1, limit);
|
|
1367
|
+
const chats = this.resolveChatsForConversation(chatIdentifier);
|
|
1368
|
+
if (chats.length === 0) {
|
|
1369
|
+
span.end({ limit: pageLimit, returned: 0 });
|
|
1370
|
+
return { messages: [], nextCursor: null, rawCount: 0 };
|
|
1371
|
+
}
|
|
1372
|
+
const chatIds = chats.map((chat) => chat.ROWID);
|
|
1373
|
+
const chatPlaceholders = chatIds.map(() => "?").join(",");
|
|
1374
|
+
const conditions = [
|
|
1375
|
+
`cmj.chat_id IN (${chatPlaceholders})`,
|
|
1376
|
+
"COALESCE(m.item_type, 0) = 0"
|
|
1377
|
+
];
|
|
1378
|
+
const params = [...chatIds];
|
|
1379
|
+
if (!includeReactions) {
|
|
1380
|
+
conditions.push(`COALESCE(m.associated_message_type, 0) = ${AssociatedMessageType.NORMAL}`);
|
|
1381
|
+
}
|
|
1382
|
+
if (afterCursor) {
|
|
1383
|
+
conditions.push("(m.date > ? OR (m.date = ? AND m.ROWID > ?))");
|
|
1384
|
+
params.push(afterCursor.date, afterCursor.date, afterCursor.rowid);
|
|
1385
|
+
}
|
|
1386
|
+
if (since) {
|
|
1387
|
+
conditions.push("m.date >= ?");
|
|
1388
|
+
params.push(dateToMacTimestamp(since));
|
|
1389
|
+
}
|
|
1390
|
+
if (until) {
|
|
1391
|
+
conditions.push("m.date <= ?");
|
|
1392
|
+
params.push(dateToMacTimestamp(until));
|
|
1393
|
+
}
|
|
1394
|
+
params.push(pageLimit);
|
|
1395
|
+
const stmt = this.raw.prepare(`
|
|
1396
|
+
WITH target_messages AS (
|
|
1397
|
+
SELECT
|
|
1398
|
+
m.ROWID,
|
|
1399
|
+
m.date,
|
|
1400
|
+
MIN(c.chat_identifier) as chat_identifier
|
|
1401
|
+
FROM ${Tables.MESSAGE} m
|
|
1402
|
+
JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
|
|
1403
|
+
JOIN ${Tables.CHAT} c ON c.ROWID = cmj.chat_id
|
|
1404
|
+
WHERE ${conditions.join("\n AND ")}
|
|
1405
|
+
GROUP BY m.ROWID, m.date
|
|
1406
|
+
ORDER BY m.date ASC, m.ROWID ASC
|
|
1407
|
+
LIMIT ?
|
|
1408
|
+
)
|
|
1409
|
+
SELECT
|
|
1410
|
+
m.ROWID,
|
|
1411
|
+
m.guid,
|
|
1412
|
+
m.text,
|
|
1413
|
+
m.attributedBody,
|
|
1414
|
+
m.date,
|
|
1415
|
+
m.is_from_me,
|
|
1416
|
+
h.id as handle_id,
|
|
1417
|
+
h.service as handle_service,
|
|
1418
|
+
m.cache_has_attachments,
|
|
1419
|
+
tm.chat_identifier,
|
|
1420
|
+
m.is_read,
|
|
1421
|
+
m.date_read,
|
|
1422
|
+
m.is_delivered,
|
|
1423
|
+
m.date_delivered,
|
|
1424
|
+
m.associated_message_type,
|
|
1425
|
+
m.associated_message_guid,
|
|
1426
|
+
m.associated_message_emoji,
|
|
1427
|
+
m.thread_originator_guid,
|
|
1428
|
+
m.thread_originator_part,
|
|
1429
|
+
m.balloon_bundle_id,
|
|
1430
|
+
m.item_type,
|
|
1431
|
+
m.date_edited,
|
|
1432
|
+
m.date_retracted,
|
|
1433
|
+
m.message_summary_info,
|
|
1434
|
+
m.payload_data
|
|
1435
|
+
FROM target_messages tm
|
|
1436
|
+
JOIN ${Tables.MESSAGE} m ON m.ROWID = tm.ROWID
|
|
1437
|
+
LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
|
|
1438
|
+
ORDER BY tm.date ASC, tm.ROWID ASC
|
|
1439
|
+
`);
|
|
1440
|
+
const rows = stmt.all(...params);
|
|
1441
|
+
const reactionsByGuid = /* @__PURE__ */ new Map();
|
|
1442
|
+
if (includeReactionDetails) {
|
|
1443
|
+
for (const chat of chats) {
|
|
1444
|
+
for (const [guid, reactions] of this.fetchReactionsForChat(chat.ROWID)) {
|
|
1445
|
+
const existing = reactionsByGuid.get(guid);
|
|
1446
|
+
if (existing) existing.push(...reactions);
|
|
1447
|
+
else reactionsByGuid.set(guid, [...reactions]);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
const messages = [];
|
|
1452
|
+
for (const row of rows) {
|
|
1453
|
+
const text = this.parseMessageText(row);
|
|
1454
|
+
if (isHiddenSystemItem(row.item_type)) continue;
|
|
1455
|
+
const converted = this.convertMessage(
|
|
1456
|
+
row,
|
|
1457
|
+
text,
|
|
1458
|
+
row.chat_identifier ?? chatIdentifier,
|
|
1459
|
+
row,
|
|
1460
|
+
false
|
|
1461
|
+
);
|
|
1462
|
+
if (!includeReactions && converted.isReaction) continue;
|
|
1463
|
+
if (includeReactionDetails && !converted.isReaction) {
|
|
1464
|
+
const reactions = reactionsByGuid.get(row.guid);
|
|
1465
|
+
if (reactions && reactions.length > 0) {
|
|
1466
|
+
converted.reactions = this.consolidateReactions(reactions);
|
|
1467
|
+
if (converted.reactions.length === 0) converted.reactions = void 0;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
messages.push(converted);
|
|
1471
|
+
}
|
|
1472
|
+
const lastRow = rows[rows.length - 1];
|
|
1473
|
+
const nextCursor = lastRow ? { date: lastRow.date, rowid: lastRow.ROWID } : null;
|
|
1474
|
+
span.end({
|
|
1475
|
+
limit: pageLimit,
|
|
1476
|
+
chats: chats.length,
|
|
1477
|
+
rows: rows.length,
|
|
1478
|
+
returned: messages.length
|
|
1479
|
+
});
|
|
1480
|
+
return { messages, nextCursor, rawCount: rows.length };
|
|
1481
|
+
}
|
|
1142
1482
|
/**
|
|
1143
1483
|
* Get unread messages across all conversations, sorted by date descending (newest first).
|
|
1144
1484
|
* Excludes reactions for cleaner output.
|
|
@@ -1195,7 +1535,7 @@ class IMessageDB {
|
|
|
1195
1535
|
*/
|
|
1196
1536
|
async getMessagesAfter(chatIdentifier, afterMessageId) {
|
|
1197
1537
|
const messages = await this.getMessagesForChat(chatIdentifier, 1e3, { afterMessageId });
|
|
1198
|
-
const filtered = messages.filter((m) => m.id
|
|
1538
|
+
const filtered = messages.filter((m) => m.id !== afterMessageId && !m.isFromMe);
|
|
1199
1539
|
filtered.sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
1200
1540
|
return filtered;
|
|
1201
1541
|
}
|
|
@@ -1215,8 +1555,7 @@ class IMessageDB {
|
|
|
1215
1555
|
last: lastByChat[chat.ROWID]
|
|
1216
1556
|
}));
|
|
1217
1557
|
sortEntries.sort((a, b) => b.lastDate - a.lastDate);
|
|
1218
|
-
const
|
|
1219
|
-
const prepared = candidates.map(({ chat, isGroup, last }) => {
|
|
1558
|
+
const enrich = ({ chat, isGroup, last }) => {
|
|
1220
1559
|
const lastDate = last ? macTimestampToDate(last.lastDate) : null;
|
|
1221
1560
|
const rawIdentifier = chat.chat_identifier;
|
|
1222
1561
|
let displayName = chat.display_name;
|
|
@@ -1246,8 +1585,17 @@ class IMessageDB {
|
|
|
1246
1585
|
serviceType
|
|
1247
1586
|
}
|
|
1248
1587
|
};
|
|
1249
|
-
}
|
|
1250
|
-
const
|
|
1588
|
+
};
|
|
1589
|
+
const CHUNK = Math.max(limit, 200);
|
|
1590
|
+
const prepared = [];
|
|
1591
|
+
let cursor = 0;
|
|
1592
|
+
let deduped = this.mergeDuplicateConversations(prepared);
|
|
1593
|
+
while (deduped.length < limit && cursor < sortEntries.length) {
|
|
1594
|
+
const chunk = sortEntries.slice(cursor, cursor + CHUNK);
|
|
1595
|
+
cursor += chunk.length;
|
|
1596
|
+
for (const entry of chunk) prepared.push(enrich(entry));
|
|
1597
|
+
deduped = this.mergeDuplicateConversations(prepared);
|
|
1598
|
+
}
|
|
1251
1599
|
const selected = deduped.slice(0, limit);
|
|
1252
1600
|
const result = selected.map(({ conversation, last }) => ({
|
|
1253
1601
|
...conversation,
|
|
@@ -1255,7 +1603,7 @@ class IMessageDB {
|
|
|
1255
1603
|
}));
|
|
1256
1604
|
span.end({
|
|
1257
1605
|
chats: chats.length,
|
|
1258
|
-
|
|
1606
|
+
enriched: cursor,
|
|
1259
1607
|
deduped: deduped.length,
|
|
1260
1608
|
returned: result.length
|
|
1261
1609
|
});
|
|
@@ -1532,6 +1880,76 @@ class IMessageDB {
|
|
|
1532
1880
|
(chat) => !(isGroupGuid(chat.guid) || isGroupChatIdentifier(chat.chat_identifier)) && this.getConversationMergeKey(chat.chat_identifier, chat.guid, false) === mergeKey
|
|
1533
1881
|
).map((chat) => toChatRow(chat));
|
|
1534
1882
|
}
|
|
1883
|
+
/**
|
|
1884
|
+
* Completeness diagnostic for exports. Finds non-group chats that belong to
|
|
1885
|
+
* the same identity as the exported conversation but that the merge-key logic
|
|
1886
|
+
* did NOT fold in — e.g. a contact whose phone and email landed on separate
|
|
1887
|
+
* Address Book cards, or chats Apple linked via `person_centric_id`.
|
|
1888
|
+
*
|
|
1889
|
+
* Two independent signals, unioned and deduped by chat ROWID:
|
|
1890
|
+
* 1. **contactId invariant** (primary): any non-group chat whose handle
|
|
1891
|
+
* resolves (via the loaded Address Books, incl. iCloud sources) to the
|
|
1892
|
+
* same contactId as the merged set but wasn't merged. Catches merge
|
|
1893
|
+
* regressions and normalization gaps.
|
|
1894
|
+
* 2. **person_centric_id** (fallback): Apple's own cross-handle link. Still
|
|
1895
|
+
* fires when the contact isn't in any Address Book — but note this column
|
|
1896
|
+
* is NULL on many real chat.dbs, so signal (1) is the dependable one.
|
|
1897
|
+
*
|
|
1898
|
+
* Inherent limit: a contact in NO Address Book *and* with a NULL
|
|
1899
|
+
* person_centric_id cannot be linked from data alone — that case is guarded
|
|
1900
|
+
* by the cross-source merge tests, not this diagnostic. Read-only.
|
|
1901
|
+
*/
|
|
1902
|
+
findUnmergedSiblingChats(chatIdentifier) {
|
|
1903
|
+
const merged = this.resolveChatsForConversation(chatIdentifier);
|
|
1904
|
+
if (merged.length === 0) return [];
|
|
1905
|
+
const mergedIds = new Set(merged.map((c) => c.ROWID));
|
|
1906
|
+
const siblings = /* @__PURE__ */ new Map();
|
|
1907
|
+
const isGroupChat = (guid, ident) => isGroupGuid(guid) || isGroupChatIdentifier(ident ?? "");
|
|
1908
|
+
const mergedContactIds = /* @__PURE__ */ new Set();
|
|
1909
|
+
for (const chat of merged) {
|
|
1910
|
+
const contact = this.contacts.lookupContact(chat.chat_identifier);
|
|
1911
|
+
if (contact) mergedContactIds.add(contact.contactId);
|
|
1912
|
+
}
|
|
1913
|
+
if (mergedContactIds.size > 0) {
|
|
1914
|
+
for (const chat of this.getAllChatsWithLastDate()) {
|
|
1915
|
+
if (mergedIds.has(chat.ROWID)) continue;
|
|
1916
|
+
if (isGroupChat(chat.guid, chat.chat_identifier)) continue;
|
|
1917
|
+
const contact = this.contacts.lookupContact(chat.chat_identifier);
|
|
1918
|
+
if (contact && mergedContactIds.has(contact.contactId)) {
|
|
1919
|
+
siblings.set(chat.ROWID, {
|
|
1920
|
+
chatIdentifier: chat.chat_identifier,
|
|
1921
|
+
reason: "resolves to the same contact but was not merged"
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
const mergedIdList = [...mergedIds];
|
|
1927
|
+
const placeholders = mergedIdList.map(() => "?").join(",");
|
|
1928
|
+
const rows = this.raw.prepare(`
|
|
1929
|
+
SELECT DISTINCT c.ROWID as rowid, c.guid, c.chat_identifier
|
|
1930
|
+
FROM ${Tables.CHAT} c
|
|
1931
|
+
JOIN ${Tables.CHAT_HANDLE_JOIN} chj ON chj.chat_id = c.ROWID
|
|
1932
|
+
JOIN ${Tables.HANDLE} h ON h.ROWID = chj.handle_id
|
|
1933
|
+
WHERE c.ROWID NOT IN (${placeholders})
|
|
1934
|
+
AND h.person_centric_id IN (
|
|
1935
|
+
SELECT DISTINCT h2.person_centric_id
|
|
1936
|
+
FROM ${Tables.CHAT_HANDLE_JOIN} chj2
|
|
1937
|
+
JOIN ${Tables.HANDLE} h2 ON h2.ROWID = chj2.handle_id
|
|
1938
|
+
WHERE chj2.chat_id IN (${placeholders})
|
|
1939
|
+
AND h2.person_centric_id IS NOT NULL
|
|
1940
|
+
AND h2.person_centric_id != ''
|
|
1941
|
+
)
|
|
1942
|
+
`).all(...mergedIdList, ...mergedIdList);
|
|
1943
|
+
for (const row of rows) {
|
|
1944
|
+
if (isGroupChat(row.guid, row.chat_identifier)) continue;
|
|
1945
|
+
if (siblings.has(row.rowid)) continue;
|
|
1946
|
+
siblings.set(row.rowid, {
|
|
1947
|
+
chatIdentifier: row.chat_identifier ?? row.guid,
|
|
1948
|
+
reason: "shares this contact's identity (person_centric_id) but was not merged"
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
return [...siblings.values()];
|
|
1952
|
+
}
|
|
1535
1953
|
/**
|
|
1536
1954
|
* Get all chats with their last message date in one join query.
|
|
1537
1955
|
* Used to resolve the correct chat for a contact when multiple chats exist (e.g. same number).
|
|
@@ -1642,53 +2060,45 @@ class IMessageDB {
|
|
|
1642
2060
|
).all();
|
|
1643
2061
|
return rows;
|
|
1644
2062
|
}
|
|
2063
|
+
/** Resolve a message ROWID to its date (for composite pagination cursors). */
|
|
2064
|
+
dateOfMessage(rowid) {
|
|
2065
|
+
const row = this.raw.prepare(`SELECT date FROM ${Tables.MESSAGE} WHERE ROWID = ?`).get(rowid);
|
|
2066
|
+
return row?.date;
|
|
2067
|
+
}
|
|
1645
2068
|
/**
|
|
1646
2069
|
* Fetch message rows for a chat ROWID, ordered by date DESC.
|
|
1647
|
-
*
|
|
2070
|
+
*
|
|
2071
|
+
* Pagination boundaries are composite `(date, ROWID)` cursors, NOT bare
|
|
2072
|
+
* ROWID comparisons: results are ordered by date, and in restored/merged
|
|
2073
|
+
* threads ROWID order diverges from date order — a ROWID-only bound both
|
|
2074
|
+
* skips older-date/higher-ROWID messages and re-shows newer-date/lower-ROWID
|
|
2075
|
+
* ones (paginating a real thread that way reached only ~47% of it; the same
|
|
2076
|
+
* defect made wait_for_reply able to miss replies entirely). ROWID-only is
|
|
2077
|
+
* kept solely as a fallback when the boundary message's date is unknown
|
|
2078
|
+
* (e.g. the boundary row was deleted).
|
|
1648
2079
|
*/
|
|
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
|
-
}
|
|
2080
|
+
fetchMessagesForChatRowId(chatRowId, limit, beforeMessageId, afterMessageId, beforeDate, afterDate) {
|
|
2081
|
+
const conditions = ["cmj.chat_id = ?"];
|
|
2082
|
+
const params = [chatRowId];
|
|
1664
2083
|
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);
|
|
2084
|
+
if (beforeDate != null) {
|
|
2085
|
+
conditions.push("(m.date < ? OR (m.date = ? AND m.ROWID < ?))");
|
|
2086
|
+
params.push(beforeDate, beforeDate, beforeMessageId);
|
|
2087
|
+
} else {
|
|
2088
|
+
conditions.push("m.ROWID < ?");
|
|
2089
|
+
params.push(beforeMessageId);
|
|
2090
|
+
}
|
|
1677
2091
|
}
|
|
1678
2092
|
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);
|
|
2093
|
+
if (afterDate != null) {
|
|
2094
|
+
conditions.push("(m.date > ? OR (m.date = ? AND m.ROWID > ?))");
|
|
2095
|
+
params.push(afterDate, afterDate, afterMessageId);
|
|
2096
|
+
} else {
|
|
2097
|
+
conditions.push("m.ROWID > ?");
|
|
2098
|
+
params.push(afterMessageId);
|
|
2099
|
+
}
|
|
1691
2100
|
}
|
|
2101
|
+
params.push(limit);
|
|
1692
2102
|
const stmt = this.raw.prepare(`
|
|
1693
2103
|
SELECT
|
|
1694
2104
|
m.ROWID, m.guid, m.text, m.attributedBody, m.date,
|
|
@@ -1696,11 +2106,11 @@ class IMessageDB {
|
|
|
1696
2106
|
FROM ${Tables.MESSAGE} m
|
|
1697
2107
|
LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
|
|
1698
2108
|
LEFT JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
|
|
1699
|
-
WHERE
|
|
2109
|
+
WHERE ${conditions.join(" AND ")}
|
|
1700
2110
|
ORDER BY m.date DESC, m.ROWID DESC
|
|
1701
2111
|
LIMIT ?
|
|
1702
2112
|
`);
|
|
1703
|
-
return stmt.all(
|
|
2113
|
+
return stmt.all(...params);
|
|
1704
2114
|
}
|
|
1705
2115
|
/**
|
|
1706
2116
|
* Extract readable text from a message row.
|
|
@@ -1872,6 +2282,7 @@ class IMessageDB {
|
|
|
1872
2282
|
this.cachedAllChats = null;
|
|
1873
2283
|
this.cachedLastByChat = null;
|
|
1874
2284
|
this.cachedUnreadByChat = null;
|
|
2285
|
+
this.cachedReactionsByChat.clear();
|
|
1875
2286
|
this.getAllChatsWithLastDate();
|
|
1876
2287
|
this.scheduleBackgroundSlugSync();
|
|
1877
2288
|
} catch {
|
|
@@ -1886,6 +2297,7 @@ class IMessageDB {
|
|
|
1886
2297
|
* Close database connections
|
|
1887
2298
|
*/
|
|
1888
2299
|
async close() {
|
|
2300
|
+
this.closed = true;
|
|
1889
2301
|
this.raw.close();
|
|
1890
2302
|
this.contacts.close();
|
|
1891
2303
|
this.slugStore.close();
|
|
@@ -2126,6 +2538,10 @@ class IMessageDB {
|
|
|
2126
2538
|
* One query replaces N individual LIKE queries (the main perf bottleneck).
|
|
2127
2539
|
*/
|
|
2128
2540
|
fetchReactionsForChat(chatRowId) {
|
|
2541
|
+
const cached = this.cachedReactionsByChat.get(chatRowId);
|
|
2542
|
+
if (cached && Date.now() - cached.ts < IMessageDB.CACHE_TTL_MS) {
|
|
2543
|
+
return cached.data;
|
|
2544
|
+
}
|
|
2129
2545
|
const stmt = this.raw.prepare(`
|
|
2130
2546
|
SELECT
|
|
2131
2547
|
m.associated_message_type,
|
|
@@ -2161,6 +2577,7 @@ class IMessageDB {
|
|
|
2161
2577
|
if (existing) existing.push(reaction);
|
|
2162
2578
|
else result.set(targetGuid, [reaction]);
|
|
2163
2579
|
}
|
|
2580
|
+
this.cachedReactionsByChat.set(chatRowId, { data: result, ts: Date.now() });
|
|
2164
2581
|
return result;
|
|
2165
2582
|
}
|
|
2166
2583
|
/**
|
|
@@ -2242,6 +2659,7 @@ const imessageDb = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.definePr
|
|
|
2242
2659
|
export {
|
|
2243
2660
|
IMessageDB as I,
|
|
2244
2661
|
hasNativeModule as h,
|
|
2245
|
-
imessageDb as i
|
|
2662
|
+
imessageDb as i,
|
|
2663
|
+
normalizedPhoneVariants as n
|
|
2246
2664
|
};
|
|
2247
|
-
//# sourceMappingURL=imessage-db-
|
|
2665
|
+
//# sourceMappingURL=imessage-db-vDZF7E8B.js.map
|