@chronicle.app/imessage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Louis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # @chronicle.app/imessage
2
+
3
+ Read `chat.db` with `ImessageExtractor`, then emit MessageAction/Message records with `ImessageTransformer`. `input` defaults to the macOS Messages database; use a consistent backup for exported data. The connection is read-only via built-in `node:sqlite`. Date windows are exclusive, records newest-first, and `limit: 0` means unlimited.
4
+
5
+ Automatic iCloud account and AddressBook enrichment comes from `@chronicle.app/icloud`. `includeContactNames: false` skips contacts. An explicit `account` object (or null) lets callers describe exported data without host account lookup. Transformer config can inject `lookupContact` for exported contacts. Missing permissions/account/contact data fall back to source-local identity and unlabelled participants.
6
+
7
+ Attachments are local file references with MIME metadata, not copied bytes. Missing files are skipped. Attributed-body decoding retains the existing heuristic; it is not a complete typedstream decoder. Raw records preserve the source blob, and large integer timestamps are decimal strings so serialization is safe.
8
+
9
+ Tests use synthetic Messages and AddressBook databases, account command fixtures, and temporary attachment files. They do not read personal messages or contacts.
@@ -0,0 +1,19 @@
1
+ import { Record } from '@chronicle.app/etl';
2
+ import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
3
+ import ImessageTransformer from './ImessageTransformer.js';
4
+ export declare class ImessageExtractor extends SqliteExtractor<typeof ImessageExtractor> {
5
+ static recordTypes: string[];
6
+ static source: string;
7
+ static description: string;
8
+ static delivery: "local";
9
+ static strategy: string;
10
+ static default: boolean;
11
+ static schema: any;
12
+ static defaultTransformer: typeof ImessageTransformer;
13
+ extract(): AsyncGenerator<Record>;
14
+ private queryMessages;
15
+ private loadChats;
16
+ private loadAttachments;
17
+ private processAttributedBody;
18
+ }
19
+ //# sourceMappingURL=ImessageExtractor.d.ts.map
@@ -0,0 +1,177 @@
1
+ import { SqliteExtractor, iosToUnixTimestamp, timeRangeConditions, APPLE_EPOCH_OFFSET_SECONDS, } from '@chronicle.app/etl-sqlite';
2
+ import { getICloudAccount } from '@chronicle.app/icloud';
3
+ import ImessageTransformer from './ImessageTransformer.js';
4
+ import { z } from 'zod';
5
+ export class ImessageExtractor extends SqliteExtractor {
6
+ static recordTypes = ['messages'];
7
+ static source = 'imessage';
8
+ static description = 'Messages and attachments from chat.db';
9
+ static delivery = 'local';
10
+ static strategy = 'app-db';
11
+ static default = true;
12
+ static schema = SqliteExtractor.schema.omit({ input: true }).extend({
13
+ input: z
14
+ .string()
15
+ .default(`${process.env.HOME}/Library/Messages/chat.db`)
16
+ .describe('The path to the iMessage sqlite database'),
17
+ includeContactNames: z
18
+ .boolean()
19
+ .optional()
20
+ .default(true)
21
+ .describe('Include contact names from AddressBook (default: true)'),
22
+ account: z
23
+ .object({
24
+ accountID: z.string(),
25
+ email: z.string(),
26
+ displayName: z.string(),
27
+ dsid: z.string().optional(),
28
+ })
29
+ .nullable()
30
+ .optional(),
31
+ });
32
+ static defaultTransformer = ImessageTransformer;
33
+ async *extract() {
34
+ const chats = this.loadChats();
35
+ const rows = this.queryMessages();
36
+ const attachments = this.loadAttachments();
37
+ // Get real iCloud account info automatically
38
+ const realIcloudAccount = this.config.account === undefined ? await getICloudAccount() : this.config.account;
39
+ for (const row of rows) {
40
+ const attributedBody = this.processAttributedBody(row.attributedBody);
41
+ const timestamp = iosToUnixTimestamp(Number.parseInt(row.date, 10));
42
+ const participants = chats[row.chat_id] || [];
43
+ const messageAttachments = attachments[row.ROWID] || [];
44
+ yield this.createRecord(row, {
45
+ attributedBody,
46
+ timestamp,
47
+ messageId: row.ROWID,
48
+ guid: row.guid,
49
+ service: row.service,
50
+ isFromMe: row.is_from_me,
51
+ handleId: row.handle_id,
52
+ handleIdText: row.handle_id_text,
53
+ participants,
54
+ attachments: messageAttachments,
55
+ myIcloudAccount: realIcloudAccount,
56
+ includeContactNames: this.config.includeContactNames,
57
+ });
58
+ }
59
+ }
60
+ queryMessages() {
61
+ let sql = `
62
+ SELECT
63
+ m.ROWID, m.guid, m.text, m.service, m.date, m.is_from_me, m.handle_id,
64
+ m.attributedBody, h.id as handle_id_text, h.service as handle_service,
65
+ cm.chat_id
66
+ FROM
67
+ message AS m
68
+ LEFT OUTER JOIN handle AS h ON m.handle_id = h.ROWID
69
+ INNER JOIN chat_message_join AS cm ON m.ROWID = cm.message_id`;
70
+ // chat.db stores message.date as nanoseconds since the Apple epoch
71
+ const { conditions, values } = timeRangeConditions('m.date', this.config, {
72
+ convert: date => (BigInt(date.getTime()) * 1000000n -
73
+ BigInt(APPLE_EPOCH_OFFSET_SECONDS) * 1000000000n).toString(),
74
+ });
75
+ if (conditions.length > 0) {
76
+ sql += ` WHERE ${conditions.join(' AND ')}`;
77
+ }
78
+ sql += ` ORDER BY m.date DESC`;
79
+ const effectiveLimit = this.getEffectiveLimit();
80
+ if (effectiveLimit !== null) {
81
+ sql += ` LIMIT ?`;
82
+ values.push(effectiveLimit);
83
+ }
84
+ const stmt = this.db.prepare(sql);
85
+ stmt.setReadBigInts(true);
86
+ return stmt
87
+ .all(...values)
88
+ .map(row => Object.fromEntries(Object.entries(row).map(([key, value]) => [
89
+ key,
90
+ typeof value === 'bigint'
91
+ ? key === 'date' || value > BigInt(Number.MAX_SAFE_INTEGER)
92
+ ? value.toString()
93
+ : Number(value)
94
+ : value,
95
+ ])));
96
+ }
97
+ loadChats() {
98
+ const sql = `
99
+ SELECT ch.chat_id, h.ROWID as handle_rowid, h.id, h.service as handle_service
100
+ FROM chat_handle_join AS ch
101
+ INNER JOIN handle AS h ON ch.handle_id = h.ROWID
102
+ INNER JOIN chat AS c ON ch.chat_id = c.ROWID
103
+ `;
104
+ const stmt = this.db.prepare(sql);
105
+ const results = stmt.all();
106
+ // Group handles by chat_id
107
+ const chats = {};
108
+ for (const row of results) {
109
+ const chatId = row.chat_id;
110
+ if (!chats[chatId]) {
111
+ chats[chatId] = [];
112
+ }
113
+ chats[chatId].push({
114
+ handleRowid: row.handle_rowid,
115
+ id: row.id,
116
+ service: row.handle_service,
117
+ });
118
+ }
119
+ return chats;
120
+ }
121
+ loadAttachments() {
122
+ const sql = `
123
+ SELECT
124
+ maj.message_id,
125
+ a.ROWID as attachment_id,
126
+ a.guid,
127
+ a.filename,
128
+ a.mime_type,
129
+ a.uti,
130
+ a.total_bytes,
131
+ a.transfer_name,
132
+ a.is_outgoing
133
+ FROM message_attachment_join AS maj
134
+ INNER JOIN attachment AS a ON maj.attachment_id = a.ROWID
135
+ WHERE a.filename IS NOT NULL
136
+ `;
137
+ const stmt = this.db.prepare(sql);
138
+ const results = stmt.all();
139
+ // Group attachments by message_id
140
+ const attachments = {};
141
+ for (const row of results) {
142
+ const messageId = row.message_id;
143
+ if (!attachments[messageId]) {
144
+ attachments[messageId] = [];
145
+ }
146
+ attachments[messageId].push({
147
+ attachmentId: row.attachment_id,
148
+ guid: row.guid,
149
+ filename: row.filename,
150
+ mimeType: row.mime_type,
151
+ uti: row.uti,
152
+ totalBytes: row.total_bytes,
153
+ transferName: row.transfer_name,
154
+ isOutgoing: row.is_outgoing,
155
+ });
156
+ }
157
+ return attachments;
158
+ }
159
+ processAttributedBody(attributedBody) {
160
+ if (!attributedBody)
161
+ return '';
162
+ attributedBody = (typeof attributedBody === 'string'
163
+ ? Buffer.from(attributedBody, 'binary')
164
+ : Buffer.from(attributedBody)).toString('utf8');
165
+ if (attributedBody.includes('NSNumber')) {
166
+ attributedBody = attributedBody.split('NSNumber')[0];
167
+ if (attributedBody.includes('NSString')) {
168
+ attributedBody = attributedBody.split('NSString')[1];
169
+ if (attributedBody.includes('NSDictionary')) {
170
+ attributedBody = attributedBody.split('NSDictionary')[0];
171
+ attributedBody = attributedBody.slice(6, -12);
172
+ }
173
+ }
174
+ }
175
+ return attributedBody;
176
+ }
177
+ }
@@ -0,0 +1,43 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { ActionAndChildren } from '@chronicle.app/schema';
3
+ export default class ImessageTransformer extends ChronicleTransformer {
4
+ static source: string;
5
+ transform(record: Record): Promise<ActionAndChildren[]>;
6
+ private buildMessageAction;
7
+ private buildMessage;
8
+ private buildParticipants;
9
+ /**
10
+ * One chat participant as an Agent in the service's identity namespace
11
+ * (`icloud` for iMessage, `phone` for SMS), keyed on the handle exactly as
12
+ * chat.db stores it — an email address or a phone number; no sourceId, since
13
+ * the database carries no DSID. Linked by `sameAs` to the email identities the
14
+ * same handle is known by, so the participant collapses with the person the
15
+ * email plugin saw.
16
+ */
17
+ private buildParticipantAgent;
18
+ /**
19
+ * The `email`-namespace identities this participant is: the handle itself when
20
+ * it is an email address (iMessage addresses people by their iCloud email),
21
+ * plus every email on the AddressBook card the handle resolves to when contact
22
+ * lookup is on. Each is a full entity-reference node keyed the way the email
23
+ * plugin keys its own agents — `Agent` / `email` / the address as written, no
24
+ * case folding on either side — so the fold merges the two through the shared
25
+ * keyset. Only identifiers the source actually carries; one address spelled
26
+ * more than once keeps its first spelling.
27
+ */
28
+ private emailIdentities;
29
+ private buildMyIdentity;
30
+ private buildMyPhoneIdentity;
31
+ private determineAgentAndRecipients;
32
+ private getServiceSource;
33
+ private getAgentSource;
34
+ private normalizedBody;
35
+ private processAttachments;
36
+ private getMimeTypeFromExtension;
37
+ /** First 16 bytes of a file — enough for every signature in sniffMimeType. */
38
+ private readLeadingBytes;
39
+ /** Last-resort detection from magic bytes (e.g. rich-link previews with dyn.* UTIs). */
40
+ private sniffMimeType;
41
+ private resolveAttachmentPath;
42
+ }
43
+ //# sourceMappingURL=ImessageTransformer.d.ts.map
@@ -0,0 +1,321 @@
1
+ import { ChronicleTransformer, normalizePhoneNumber, createMediaObject, } from '@chronicle.app/etl';
2
+ import { lookupContact, buildICloudPersonSchema } from '@chronicle.app/icloud';
3
+ import * as fs from 'node:fs';
4
+ import * as path from 'node:path';
5
+ export default class ImessageTransformer extends ChronicleTransformer {
6
+ static source = 'imessage';
7
+ async transform(record) {
8
+ const actions = [];
9
+ if (record.extraction.recordType === 'messages') {
10
+ actions.push(...(await this.buildMessageAction(record)));
11
+ }
12
+ return actions;
13
+ }
14
+ async buildMessageAction(record) {
15
+ const service = this.getServiceSource(record.context.service);
16
+ const participants = this.buildParticipants(record);
17
+ const myIdentity = await this.buildMyIdentity(record);
18
+ const { agent, recipients } = this.determineAgentAndRecipients(record, participants, myIdentity);
19
+ const obj = {
20
+ '@type': 'MessageAction',
21
+ timestamp: new Date(record.context.timestamp),
22
+ source: service,
23
+ sourceId: record.context.guid, // Use GUID instead of ROWID
24
+ '@key': ['source', 'sourceId', '@type'],
25
+ object: this.buildMessage(record, recipients),
26
+ agent,
27
+ };
28
+ return [obj];
29
+ }
30
+ buildMessage(record, recipients) {
31
+ const messageSource = this.getServiceSource(record.context.service);
32
+ const message = {
33
+ '@type': 'Message',
34
+ '@key': ['@type', 'source', 'sourceId'],
35
+ source: messageSource, // Use service-based source (sms or imessage)
36
+ sourceId: record.context.guid, // Use GUID for message identity
37
+ body: this.normalizedBody(record),
38
+ };
39
+ // Add recipients if available
40
+ if (recipients.length > 0) {
41
+ message.recipient = recipients;
42
+ }
43
+ // Add attachments using contains property (following Bluesky pattern)
44
+ const attachments = record.context.attachments || [];
45
+ if (attachments.length > 0) {
46
+ message.contains = this.processAttachments(attachments);
47
+ }
48
+ return message;
49
+ }
50
+ buildParticipants(record) {
51
+ const agentSource = this.getAgentSource(record.context.service);
52
+ const chatParticipants = record.context.participants || [];
53
+ return chatParticipants.map(participant => this.buildParticipantAgent(record, agentSource, participant.id));
54
+ }
55
+ /**
56
+ * One chat participant as an Agent in the service's identity namespace
57
+ * (`icloud` for iMessage, `phone` for SMS), keyed on the handle exactly as
58
+ * chat.db stores it — an email address or a phone number; no sourceId, since
59
+ * the database carries no DSID. Linked by `sameAs` to the email identities the
60
+ * same handle is known by, so the participant collapses with the person the
61
+ * email plugin saw.
62
+ */
63
+ buildParticipantAgent(record, agentSource, handle) {
64
+ const contact = record.context.includeContactNames
65
+ ? (this.config.lookupContact ?? lookupContact)(handle)
66
+ : null;
67
+ const sameAs = this.emailIdentities(handle, contact);
68
+ return {
69
+ '@type': 'Agent',
70
+ source: agentSource,
71
+ handle,
72
+ name: contact?.fullName,
73
+ '@key': ['@type', 'source', 'handle'],
74
+ ...(sameAs.length > 0 ? { sameAs } : {}),
75
+ };
76
+ }
77
+ /**
78
+ * The `email`-namespace identities this participant is: the handle itself when
79
+ * it is an email address (iMessage addresses people by their iCloud email),
80
+ * plus every email on the AddressBook card the handle resolves to when contact
81
+ * lookup is on. Each is a full entity-reference node keyed the way the email
82
+ * plugin keys its own agents — `Agent` / `email` / the address as written, no
83
+ * case folding on either side — so the fold merges the two through the shared
84
+ * keyset. Only identifiers the source actually carries; one address spelled
85
+ * more than once keeps its first spelling.
86
+ */
87
+ emailIdentities(handle, contact) {
88
+ const candidates = [...(handle.includes('@') ? [handle] : []), ...(contact?.emails ?? [])];
89
+ const seen = new Set();
90
+ const emails = [];
91
+ for (const email of candidates) {
92
+ const key = email.trim().toLowerCase();
93
+ if (!key || seen.has(key))
94
+ continue;
95
+ seen.add(key);
96
+ emails.push(email);
97
+ }
98
+ return emails.map(email => ({
99
+ '@type': 'Agent',
100
+ source: 'email',
101
+ handle: email,
102
+ '@key': ['@type', 'source', 'handle'],
103
+ }));
104
+ }
105
+ async buildMyIdentity(record) {
106
+ const { service } = record.context;
107
+ const agentSource = this.getAgentSource(service);
108
+ const identity = agentSource === 'icloud'
109
+ ? (await buildICloudPersonSchema(record.context.myIcloudAccount))
110
+ : this.buildMyPhoneIdentity(record);
111
+ // Tag the account owner as self. The iCloud branch (buildICloudPersonSchema)
112
+ // already self-tags, so add `@me` only when it isn't already present.
113
+ const sameAs = identity?.sameAs ?? [];
114
+ return { ...identity, sameAs: sameAs.includes('@me') ? sameAs : [...sameAs, '@me'] };
115
+ }
116
+ buildMyPhoneIdentity(record) {
117
+ // Try to find my phone number by matching my iCloud email in AddressBook
118
+ if (record?.context?.myIcloudAccount?.email && record?.context?.includeContactNames) {
119
+ const myContact = (this.config.lookupContact ?? lookupContact)(record.context.myIcloudAccount.email);
120
+ if (myContact?.phoneNumbers && myContact.phoneNumbers.length > 0) {
121
+ // Found my contact with phone numbers - use the first one (normalized)
122
+ const normalizedPhone = normalizePhoneNumber(myContact.phoneNumbers[0]);
123
+ if (normalizedPhone) {
124
+ return {
125
+ '@type': 'Person',
126
+ source: 'phone',
127
+ handle: normalizedPhone,
128
+ '@key': ['@type', 'source', 'handle'],
129
+ };
130
+ }
131
+ }
132
+ }
133
+ // Fallback when we can't determine my phone number
134
+ // Still need a unique identifier - use iCloud email as fallback handle
135
+ const fallbackHandle = record?.context?.myIcloudAccount?.email;
136
+ if (fallbackHandle) {
137
+ return {
138
+ '@type': 'Person',
139
+ source: 'phone',
140
+ handle: fallbackHandle, // Use iCloud email as unique identifier
141
+ '@key': ['@type', 'source', 'handle'],
142
+ };
143
+ }
144
+ // Last resort - no unique info available
145
+ return {
146
+ '@type': 'Person',
147
+ source: 'phone',
148
+ '@key': ['@type', 'source'],
149
+ };
150
+ }
151
+ determineAgentAndRecipients(record, participants, myIdentity) {
152
+ const isFromMe = record.context.isFromMe === 1;
153
+ if (isFromMe) {
154
+ // I sent the message - I'm the agent, everyone else are recipients
155
+ const recipients = myIdentity.handle
156
+ ? participants.filter(p => p.handle !== myIdentity.handle)
157
+ : participants; // If I have no handle (SMS fallback), all participants are recipients
158
+ return {
159
+ agent: myIdentity,
160
+ recipients,
161
+ };
162
+ }
163
+ // Someone else sent the message - find them as agent, I'm a recipient
164
+ const { handleIdText } = record.context;
165
+ const agent = participants.find(p => p.handle === handleIdText);
166
+ if (!agent) {
167
+ // The sender is not among the chat's participants: build them from the
168
+ // message's own handle, the same way as any other participant.
169
+ const agentSource = this.getAgentSource(record.context.service);
170
+ return {
171
+ agent: this.buildParticipantAgent(record, agentSource, handleIdText),
172
+ recipients: [myIdentity],
173
+ };
174
+ }
175
+ return {
176
+ agent,
177
+ recipients: [...participants.filter(p => p.handle !== agent.handle), myIdentity],
178
+ };
179
+ }
180
+ getServiceSource(_service) {
181
+ // All messages come from iMessage database, so use imessage source regardless of service type
182
+ return 'imessage';
183
+ }
184
+ getAgentSource(service) {
185
+ // For Person source - differentiates identity systems
186
+ switch (service?.toLowerCase()) {
187
+ case 'sms':
188
+ return 'phone';
189
+ case 'imessage':
190
+ default:
191
+ return 'icloud';
192
+ }
193
+ }
194
+ normalizedBody(record) {
195
+ // Use text field first, fallback to processed attributedBody
196
+ return record.data.text || record.context.attributedBody || '';
197
+ }
198
+ processAttachments(attachments) {
199
+ const mediaObjects = [];
200
+ for (const attachment of attachments) {
201
+ try {
202
+ const filePath = this.resolveAttachmentPath(attachment.filename);
203
+ // Check if file exists
204
+ if (!fs.existsSync(filePath)) {
205
+ console.warn(`Attachment file not found: ${filePath}`);
206
+ continue;
207
+ }
208
+ // MIME drives the media object @type (ImageObject vs DocumentObject…).
209
+ // chat.db's mime_type is NULL for some transfers (dynamic UTIs like
210
+ // rich-link previews), so fall back to extension, then magic bytes.
211
+ const mimeType = attachment.mimeType ||
212
+ this.getMimeTypeFromExtension(filePath) ||
213
+ this.sniffMimeType(this.readLeadingBytes(filePath)) ||
214
+ 'application/octet-stream';
215
+ // Reference the file by path; ingest reads the bytes into the
216
+ // attachment store — no base64 inflation through the pipeline.
217
+ const mediaObject = createMediaObject({
218
+ path: filePath,
219
+ mimeType,
220
+ description: attachment.transferName || path.basename(attachment.filename),
221
+ });
222
+ // Add iMessage attachment GUID for deduplication
223
+ mediaObject.sourceId = attachment.guid;
224
+ mediaObject.source = 'imessage';
225
+ mediaObject['@key'] = ['source', 'sourceId'];
226
+ mediaObjects.push(mediaObject);
227
+ }
228
+ catch (error) {
229
+ console.warn(`Failed to process attachment ${attachment.filename}:`, error);
230
+ }
231
+ }
232
+ return mediaObjects;
233
+ }
234
+ getMimeTypeFromExtension(filePath) {
235
+ const ext = path.extname(filePath).toLowerCase();
236
+ const mimeTypes = {
237
+ '.png': 'image/png',
238
+ '.jpg': 'image/jpeg',
239
+ '.jpeg': 'image/jpeg',
240
+ '.gif': 'image/gif',
241
+ '.webp': 'image/webp',
242
+ '.heic': 'image/heic',
243
+ '.heif': 'image/heif',
244
+ '.tiff': 'image/tiff',
245
+ '.tif': 'image/tiff',
246
+ '.ico': 'image/x-icon',
247
+ '.mp4': 'video/mp4',
248
+ '.mov': 'video/quicktime',
249
+ '.avi': 'video/x-msvideo',
250
+ '.3gp': 'video/3gpp',
251
+ '.mp3': 'audio/mpeg',
252
+ '.wav': 'audio/wav',
253
+ '.m4a': 'audio/mp4',
254
+ '.caf': 'audio/x-caf',
255
+ '.amr': 'audio/amr',
256
+ '.pdf': 'application/pdf',
257
+ '.vcf': 'text/vcard',
258
+ };
259
+ return mimeTypes[ext];
260
+ }
261
+ /** First 16 bytes of a file — enough for every signature in sniffMimeType. */
262
+ readLeadingBytes(filePath) {
263
+ const buf = Buffer.alloc(16);
264
+ const fd = fs.openSync(filePath, 'r');
265
+ try {
266
+ const n = fs.readSync(fd, buf, 0, 16, 0);
267
+ return buf.subarray(0, n);
268
+ }
269
+ finally {
270
+ fs.closeSync(fd);
271
+ }
272
+ }
273
+ /** Last-resort detection from magic bytes (e.g. rich-link previews with dyn.* UTIs). */
274
+ sniffMimeType(bytes) {
275
+ const ascii = (start, text) => bytes.length >= start + text.length &&
276
+ bytes.toString('latin1', start, start + text.length) === text;
277
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
278
+ return 'image/jpeg';
279
+ if (bytes.length >= 4 && bytes[0] === 0x89 && ascii(1, 'PNG'))
280
+ return 'image/png';
281
+ if (ascii(0, 'GIF8'))
282
+ return 'image/gif';
283
+ if (ascii(0, 'RIFF') && ascii(8, 'WEBP'))
284
+ return 'image/webp';
285
+ if (ascii(0, 'RIFF') && ascii(8, 'WAVE'))
286
+ return 'audio/wav';
287
+ if (ascii(4, 'ftypheic') || ascii(4, 'ftypheix') || ascii(4, 'ftypmif1'))
288
+ return 'image/heic';
289
+ if (ascii(4, 'ftypqt'))
290
+ return 'video/quicktime';
291
+ if (ascii(4, 'ftypM4A'))
292
+ return 'audio/mp4';
293
+ if (ascii(4, 'ftyp'))
294
+ return 'video/mp4';
295
+ if (ascii(0, '%PDF'))
296
+ return 'application/pdf';
297
+ if (ascii(0, 'ID3'))
298
+ return 'audio/mpeg';
299
+ if (ascii(0, 'caff'))
300
+ return 'audio/x-caf';
301
+ if (ascii(0, '#!AMR'))
302
+ return 'audio/amr';
303
+ if (bytes.length >= 4 && bytes[0] === 0 && bytes[1] === 0 && bytes[2] === 1 && bytes[3] === 0)
304
+ return 'image/x-icon';
305
+ return undefined;
306
+ }
307
+ resolveAttachmentPath(filename) {
308
+ // iMessage filename field contains paths like ~/Library/Messages/Attachments/...
309
+ // Need to expand the ~ to the actual home directory
310
+ if (filename.startsWith('~/')) {
311
+ return path.join(process.env.HOME || '', filename.slice(2));
312
+ }
313
+ // If it's already an absolute path, use as-is
314
+ if (path.isAbsolute(filename)) {
315
+ return filename;
316
+ }
317
+ // Handle relative paths by prepending the standard iMessage attachments directory
318
+ const attachmentsDir = path.join(process.env.HOME || '', 'Library', 'Messages', 'Attachments');
319
+ return path.join(attachmentsDir, filename);
320
+ }
321
+ }
@@ -0,0 +1,3 @@
1
+ export { ImessageExtractor } from './connectors/ImessageExtractor.js';
2
+ export { default as ImessageTransformer } from './connectors/ImessageTransformer.js';
3
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ // Export extractors for plugin scanning
2
+ export { ImessageExtractor } from './connectors/ImessageExtractor.js';
3
+ export { default as ImessageTransformer } from './connectors/ImessageTransformer.js';
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@chronicle.app/imessage",
3
+ "version": "0.1.0",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "files": [
7
+ "dist/**/*.js",
8
+ "dist/**/*.d.ts"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc -b tsconfig.lib.json",
12
+ "typecheck": "tsc -p tsconfig.json",
13
+ "lint": "eslint src --ext .ts,.js",
14
+ "test": "node --test src/*.test.js"
15
+ },
16
+ "keywords": [],
17
+ "author": "Andrew Louis",
18
+ "license": "MIT",
19
+ "type": "module",
20
+ "description": "Chronicle imessage extraction plugin",
21
+ "devDependencies": {
22
+ "@chronicle.app/tsconfig": "0.1.0",
23
+ "@types/node": "^22.10.1",
24
+ "typescript": "^5.7.2"
25
+ },
26
+ "homepage": "https://chronicle.app",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/chronicle-app/chronicle.git",
30
+ "directory": "plugins/imessage"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "registry": "https://registry.npmjs.org/"
35
+ },
36
+ "dependencies": {
37
+ "@chronicle.app/etl": "0.1.0",
38
+ "@chronicle.app/schema": "0.1.0",
39
+ "zod": "^3.24.1",
40
+ "@chronicle.app/etl-sqlite": "0.1.0",
41
+ "@chronicle.app/icloud": "0.1.0"
42
+ },
43
+ "engines": {
44
+ "node": ">=22.13.0"
45
+ },
46
+ "exports": {
47
+ ".": {
48
+ "types": "./dist/index.d.ts",
49
+ "import": "./dist/index.js"
50
+ }
51
+ },
52
+ "chronicle": {
53
+ "plugin": true
54
+ },
55
+ "oclif": {}
56
+ }