@chronicle.app/imessage 0.3.0 → 0.5.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/README.md
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# @chronicle.app/imessage
|
|
2
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.
|
|
3
|
+
Read `chat.db` with `ImessageExtractor`, then emit MessageAction/Message records with `ImessageTransformer`. Sending a message brings it into being, so the `Message` is the action's `result`, and the action is keyed by it. `input` defaults to the macOS Messages database; use a consistent backup for exported data. Reading it needs Full Disk Access for the app that runs Chronicle (Terminal, iTerm, your editor): System Settings → Privacy & Security → Full Disk Access. Without it the run stops with that hint, not SQLite's `unable to open database file`. The connection is read-only via built-in `node:sqlite`. Date windows are exclusive, records newest-first, and `limit: 0` means unlimited.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
A message in a group chat is part of the group's `Channel` (`isPartOf`), keyed in the `imessage` namespace by Apple's ID for the group: `original_group_id`, the ID it started with, `sameAs` `group_id` when Messages replaced it. `groupChatChannel` in `@chronicle.app/apple` builds it, and the Apple Photos plugin's Shared with You keys the same group with it. Each device keeps its own `chat_identifier` for a chat, so that isn't the key. The Channel's `name` is the group's name when it has one, and its `member` edges are the chat's participants and you, from the first message a run reads in the chat. chat.db's roster lists only the others, but every group chat in it is one you're in, or were. You're left out when there's no identifier for you, as you are from a message's sender and recipients. A group message's recipients are still each other participant and you. A one-to-one chat has no `Channel`.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Automatic iCloud account and AddressBook enrichment comes from `@chronicle.app/apple`. `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. When no identifier for the account owner is available, the owner is left out of the agent and recipients.
|
|
8
|
+
|
|
9
|
+
Messages keeps system events, such as a group being renamed or someone joining it, as rows beside the messages, with no text and often no sender. They aren't messages, so they're left out, and a survey doesn't count them. Attachments are local file references with MIME metadata, not copied bytes. Missing files are skipped. So are files Messages keeps hidden, such as a link preview's images and icon, which no one sent: the link is in the message's text. 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.
|
|
10
|
+
|
|
11
|
+
The plugin declares a `deepLinks` template for `Message` (`messages://open?message-guid=<GUID>`), so a host can open the message in Messages. SMS messages are linked the same way, since they are in `chat.db` too. Attachments are not linked, because an attachment's GUID is not a message GUID.
|
|
8
12
|
|
|
9
13
|
Tests use synthetic Messages and AddressBook databases, account command fixtures, and temporary attachment files. They do not read personal messages or contacts.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Record } from '@chronicle.app/etl';
|
|
1
|
+
import { Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
|
|
2
2
|
import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
|
|
3
3
|
import ImessageTransformer from './ImessageTransformer.js';
|
|
4
4
|
export declare class ImessageExtractor extends SqliteExtractor<typeof ImessageExtractor> {
|
|
@@ -8,11 +8,43 @@ export declare class ImessageExtractor extends SqliteExtractor<typeof ImessageEx
|
|
|
8
8
|
static delivery: "local";
|
|
9
9
|
static strategy: string;
|
|
10
10
|
static default: boolean;
|
|
11
|
+
static databases: {
|
|
12
|
+
tables: {
|
|
13
|
+
message: string[];
|
|
14
|
+
handle: string[];
|
|
15
|
+
chat: string[];
|
|
16
|
+
chat_message_join: string[];
|
|
17
|
+
chat_handle_join: string[];
|
|
18
|
+
message_attachment_join: string[];
|
|
19
|
+
attachment: string[];
|
|
20
|
+
};
|
|
21
|
+
}[];
|
|
11
22
|
static schema: any;
|
|
12
23
|
static defaultTransformer: typeof ImessageTransformer;
|
|
13
24
|
extract(): AsyncGenerator<Record>;
|
|
25
|
+
/** The messages a run reads, one per chat they're in, and when, from three aggregates. */
|
|
26
|
+
survey(options?: SurveyOptions): Promise<Survey>;
|
|
27
|
+
/**
|
|
28
|
+
* Messages within since and until. chat.db stores message.date as nanoseconds
|
|
29
|
+
* since the Apple epoch. A system event, such as a group being renamed or
|
|
30
|
+
* someone joining it, is a row too, with no text and often no sender, so
|
|
31
|
+
* only rows that are messages (`item_type` 0) are read.
|
|
32
|
+
*/
|
|
33
|
+
private messageFilter;
|
|
14
34
|
private queryMessages;
|
|
15
35
|
private loadChats;
|
|
36
|
+
/**
|
|
37
|
+
* The group chats, by chat, with their names and Apple's IDs for them. An
|
|
38
|
+
* older Messages without a column has none of what it holds.
|
|
39
|
+
*/
|
|
40
|
+
private loadGroups;
|
|
41
|
+
/** A column of `chat` the schema marks optional, or NULL when this Messages has none. */
|
|
42
|
+
private chatColumn;
|
|
43
|
+
/**
|
|
44
|
+
* The attachments a run reads: files Messages shows. It keeps others hidden,
|
|
45
|
+
* such as the images and icon of a link preview, which no one sent.
|
|
46
|
+
*/
|
|
47
|
+
private attachmentFilter;
|
|
16
48
|
private loadAttachments;
|
|
17
49
|
private processAttributedBody;
|
|
18
50
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { SqliteExtractor, iosToUnixTimestamp, timeRangeConditions, APPLE_EPOCH_OFFSET_SECONDS, } from '@chronicle.app/etl-sqlite';
|
|
2
|
-
import { getICloudAccount } from '@chronicle.app/
|
|
1
|
+
import { SqliteExtractor, countsByMonth, iosToUnixTimestamp, monthSql, timeRangeConditions, APPLE_EPOCH_OFFSET_SECONDS, } from '@chronicle.app/etl-sqlite';
|
|
2
|
+
import { GROUP_CHAT_STYLE, getICloudAccount } from '@chronicle.app/apple';
|
|
3
3
|
import ImessageTransformer from './ImessageTransformer.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
export class ImessageExtractor extends SqliteExtractor {
|
|
@@ -9,6 +9,40 @@ export class ImessageExtractor extends SqliteExtractor {
|
|
|
9
9
|
static delivery = 'local';
|
|
10
10
|
static strategy = 'app-db';
|
|
11
11
|
static default = true;
|
|
12
|
+
// What extract() and survey() read, which a chat.db must have.
|
|
13
|
+
static databases = [
|
|
14
|
+
{
|
|
15
|
+
tables: {
|
|
16
|
+
message: [
|
|
17
|
+
'ROWID',
|
|
18
|
+
'guid',
|
|
19
|
+
'text',
|
|
20
|
+
'service',
|
|
21
|
+
'date',
|
|
22
|
+
'is_from_me',
|
|
23
|
+
'handle_id',
|
|
24
|
+
'attributedBody',
|
|
25
|
+
'item_type?',
|
|
26
|
+
],
|
|
27
|
+
handle: ['ROWID', 'id', 'service'],
|
|
28
|
+
chat: ['ROWID', 'style', 'display_name?', 'group_id?', 'original_group_id?'],
|
|
29
|
+
chat_message_join: ['chat_id', 'message_id'],
|
|
30
|
+
chat_handle_join: ['chat_id', 'handle_id'],
|
|
31
|
+
message_attachment_join: ['message_id', 'attachment_id'],
|
|
32
|
+
attachment: [
|
|
33
|
+
'ROWID',
|
|
34
|
+
'guid',
|
|
35
|
+
'filename',
|
|
36
|
+
'mime_type',
|
|
37
|
+
'uti',
|
|
38
|
+
'total_bytes',
|
|
39
|
+
'transfer_name',
|
|
40
|
+
'is_outgoing',
|
|
41
|
+
'hide_attachment?',
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
];
|
|
12
46
|
static schema = SqliteExtractor.schema.omit({ input: true }).extend({
|
|
13
47
|
input: z
|
|
14
48
|
.string()
|
|
@@ -32,11 +66,20 @@ export class ImessageExtractor extends SqliteExtractor {
|
|
|
32
66
|
static defaultTransformer = ImessageTransformer;
|
|
33
67
|
async *extract() {
|
|
34
68
|
const chats = this.loadChats();
|
|
69
|
+
const groups = this.loadGroups();
|
|
35
70
|
const rows = this.queryMessages();
|
|
36
71
|
const attachments = this.loadAttachments();
|
|
37
72
|
// Get real iCloud account info automatically
|
|
38
73
|
const realIcloudAccount = this.config.account === undefined ? await getICloudAccount() : this.config.account;
|
|
74
|
+
// The group chats whose roster a message has carried, so each goes on its
|
|
75
|
+
// Channel once, on the first message seen in it, not on every message.
|
|
76
|
+
const rostered = new Set();
|
|
39
77
|
for (const row of rows) {
|
|
78
|
+
const chatId = row.chat_id;
|
|
79
|
+
const group = groups[chatId];
|
|
80
|
+
const groupRoster = group !== undefined && !rostered.has(chatId);
|
|
81
|
+
if (groupRoster)
|
|
82
|
+
rostered.add(chatId);
|
|
40
83
|
const attributedBody = this.processAttributedBody(row.attributedBody);
|
|
41
84
|
const timestamp = iosToUnixTimestamp(Number.parseInt(row.date, 10));
|
|
42
85
|
const participants = chats[row.chat_id] || [];
|
|
@@ -52,11 +95,62 @@ export class ImessageExtractor extends SqliteExtractor {
|
|
|
52
95
|
handleIdText: row.handle_id_text,
|
|
53
96
|
participants,
|
|
54
97
|
attachments: messageAttachments,
|
|
98
|
+
group,
|
|
99
|
+
groupRoster,
|
|
55
100
|
myIcloudAccount: realIcloudAccount,
|
|
56
101
|
includeContactNames: this.config.includeContactNames,
|
|
57
102
|
});
|
|
58
103
|
}
|
|
59
104
|
}
|
|
105
|
+
/** The messages a run reads, one per chat they're in, and when, from three aggregates. */
|
|
106
|
+
async survey(options = {}) {
|
|
107
|
+
const { conditions, values } = this.messageFilter();
|
|
108
|
+
const inRange = conditions.map(condition => ` AND ${condition}`).join('');
|
|
109
|
+
// A message's chats, as the run joins them, and only those in range.
|
|
110
|
+
const read = `SELECT 1 FROM message AS m INNER JOIN chat_message_join AS cm ON m.ROWID = cm.message_id`;
|
|
111
|
+
const messages = this.db.prepare(`SELECT COUNT(*) AS n, MIN(m.date) AS first, MAX(m.date) AS last
|
|
112
|
+
FROM message AS m INNER JOIN chat_message_join AS cm ON m.ROWID = cm.message_id
|
|
113
|
+
WHERE 1${inRange}`);
|
|
114
|
+
messages.setReadBigInts(true);
|
|
115
|
+
const { n, first, last } = messages.get(...values);
|
|
116
|
+
const { chats } = this.db.prepare(`SELECT COUNT(*) AS chats FROM chat WHERE EXISTS (${read} WHERE cm.chat_id = chat.ROWID${inRange})`).get(...values);
|
|
117
|
+
const { bytes } = this.db.prepare(`SELECT COALESCE(SUM(a.total_bytes), 0) AS bytes
|
|
118
|
+
FROM message_attachment_join AS maj INNER JOIN attachment AS a ON maj.attachment_id = a.ROWID
|
|
119
|
+
WHERE ${this.attachmentFilter()} AND EXISTS (${read} WHERE m.ROWID = maj.message_id${inRange})`).get(...values);
|
|
120
|
+
const at = (date) => date === null ? undefined : new Date(iosToUnixTimestamp(Number(date)));
|
|
121
|
+
const count = Number(n);
|
|
122
|
+
const month = monthSql(`m.date / 1000000000 + ${APPLE_EPOCH_OFFSET_SECONDS}`);
|
|
123
|
+
const byMonth = options.byMonth &&
|
|
124
|
+
countsByMonth(this.db.prepare(`SELECT ${month} AS month, COUNT(*) AS n
|
|
125
|
+
FROM message AS m INNER JOIN chat_message_join AS cm ON m.ROWID = cm.message_id
|
|
126
|
+
WHERE 1${inRange} GROUP BY month`).all(...values));
|
|
127
|
+
return {
|
|
128
|
+
messages: {
|
|
129
|
+
count,
|
|
130
|
+
...(count > 0 && { earliest: at(first), latest: at(last) }),
|
|
131
|
+
...(byMonth && { byMonth }),
|
|
132
|
+
measures: [
|
|
133
|
+
{ key: 'conversations', label: 'conversations', value: chats },
|
|
134
|
+
{ key: 'attachmentBytes', label: 'attachments', value: bytes, unit: 'bytes' },
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Messages within since and until. chat.db stores message.date as nanoseconds
|
|
141
|
+
* since the Apple epoch. A system event, such as a group being renamed or
|
|
142
|
+
* someone joining it, is a row too, with no text and often no sender, so
|
|
143
|
+
* only rows that are messages (`item_type` 0) are read.
|
|
144
|
+
*/
|
|
145
|
+
messageFilter() {
|
|
146
|
+
const filter = timeRangeConditions('m.date', this.config, {
|
|
147
|
+
convert: date => (BigInt(date.getTime()) * 1000000n -
|
|
148
|
+
BigInt(APPLE_EPOCH_OFFSET_SECONDS) * 1000000000n).toString(),
|
|
149
|
+
});
|
|
150
|
+
if (this.has('message.item_type'))
|
|
151
|
+
filter.conditions.push('m.item_type = 0');
|
|
152
|
+
return filter;
|
|
153
|
+
}
|
|
60
154
|
queryMessages() {
|
|
61
155
|
let sql = `
|
|
62
156
|
SELECT
|
|
@@ -67,11 +161,7 @@ export class ImessageExtractor extends SqliteExtractor {
|
|
|
67
161
|
message AS m
|
|
68
162
|
LEFT OUTER JOIN handle AS h ON m.handle_id = h.ROWID
|
|
69
163
|
INNER JOIN chat_message_join AS cm ON m.ROWID = cm.message_id`;
|
|
70
|
-
|
|
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
|
-
});
|
|
164
|
+
const { conditions, values } = this.messageFilter();
|
|
75
165
|
if (conditions.length > 0) {
|
|
76
166
|
sql += ` WHERE ${conditions.join(' AND ')}`;
|
|
77
167
|
}
|
|
@@ -118,6 +208,39 @@ export class ImessageExtractor extends SqliteExtractor {
|
|
|
118
208
|
}
|
|
119
209
|
return chats;
|
|
120
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* The group chats, by chat, with their names and Apple's IDs for them. An
|
|
213
|
+
* older Messages without a column has none of what it holds.
|
|
214
|
+
*/
|
|
215
|
+
loadGroups() {
|
|
216
|
+
const rows = this.db.prepare(`SELECT ROWID AS chat_id, ${this.chatColumn('display_name')} AS name,
|
|
217
|
+
${this.chatColumn('group_id')} AS group_id,
|
|
218
|
+
${this.chatColumn('original_group_id')} AS original_group_id
|
|
219
|
+
FROM chat WHERE style = ?`).all(GROUP_CHAT_STYLE);
|
|
220
|
+
const groups = {};
|
|
221
|
+
for (const row of rows) {
|
|
222
|
+
groups[row.chat_id] = {
|
|
223
|
+
name: row.name?.trim() || null,
|
|
224
|
+
groupId: row.group_id,
|
|
225
|
+
originalGroupId: row.original_group_id,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return groups;
|
|
229
|
+
}
|
|
230
|
+
/** A column of `chat` the schema marks optional, or NULL when this Messages has none. */
|
|
231
|
+
chatColumn(name) {
|
|
232
|
+
return this.has(`chat.${name}`) ? name : 'NULL';
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The attachments a run reads: files Messages shows. It keeps others hidden,
|
|
236
|
+
* such as the images and icon of a link preview, which no one sent.
|
|
237
|
+
*/
|
|
238
|
+
attachmentFilter() {
|
|
239
|
+
const shown = this.has('attachment.hide_attachment')
|
|
240
|
+
? ' AND COALESCE(a.hide_attachment, 0) = 0'
|
|
241
|
+
: '';
|
|
242
|
+
return `a.filename IS NOT NULL${shown}`;
|
|
243
|
+
}
|
|
121
244
|
loadAttachments() {
|
|
122
245
|
const sql = `
|
|
123
246
|
SELECT
|
|
@@ -132,7 +255,7 @@ export class ImessageExtractor extends SqliteExtractor {
|
|
|
132
255
|
a.is_outgoing
|
|
133
256
|
FROM message_attachment_join AS maj
|
|
134
257
|
INNER JOIN attachment AS a ON maj.attachment_id = a.ROWID
|
|
135
|
-
WHERE
|
|
258
|
+
WHERE ${this.attachmentFilter()}
|
|
136
259
|
`;
|
|
137
260
|
const stmt = this.db.prepare(sql);
|
|
138
261
|
const results = stmt.all();
|
|
@@ -5,6 +5,14 @@ export default class ImessageTransformer extends ChronicleTransformer {
|
|
|
5
5
|
transform(record: Record): Promise<ActionAndChildren[]>;
|
|
6
6
|
private buildMessageAction;
|
|
7
7
|
private buildMessage;
|
|
8
|
+
/**
|
|
9
|
+
* The group chat a message is in, keyed by Apple's ID for the group, as
|
|
10
|
+
* `groupChatChannel` keys it for every plugin. Its members are the chat's
|
|
11
|
+
* roster and you: chat.db's roster lists only the others, but every group
|
|
12
|
+
* chat in it is one you're in. They go on it from the first message the
|
|
13
|
+
* extractor saw in the chat, as WhatsApp does, rather than from every message.
|
|
14
|
+
*/
|
|
15
|
+
private buildChannel;
|
|
8
16
|
private buildParticipants;
|
|
9
17
|
/**
|
|
10
18
|
* One chat participant as an Agent in the service's identity namespace
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ChronicleTransformer, normalizePhoneNumber, createMediaObject, } from '@chronicle.app/etl';
|
|
2
|
-
import { lookupContact, buildICloudPersonSchema } from '@chronicle.app/
|
|
2
|
+
import { lookupContact, buildICloudPersonSchema, groupChatChannel, } from '@chronicle.app/apple';
|
|
3
3
|
import * as fs from 'node:fs';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
export default class ImessageTransformer extends ChronicleTransformer {
|
|
@@ -20,14 +20,15 @@ export default class ImessageTransformer extends ChronicleTransformer {
|
|
|
20
20
|
'@type': 'MessageAction',
|
|
21
21
|
timestamp: new Date(record.context.timestamp),
|
|
22
22
|
source: service,
|
|
23
|
-
|
|
24
|
-
'@key': ['
|
|
25
|
-
|
|
23
|
+
// Keyed by the message it sent.
|
|
24
|
+
'@key': ['@type', 'source', 'result.sourceId'],
|
|
25
|
+
// Sending brings the message into being, so it's the action's result.
|
|
26
|
+
result: this.buildMessage(record, recipients, this.buildChannel(record, participants, myIdentity)),
|
|
26
27
|
...(agent && { agent }),
|
|
27
28
|
};
|
|
28
29
|
return [obj];
|
|
29
30
|
}
|
|
30
|
-
buildMessage(record, recipients) {
|
|
31
|
+
buildMessage(record, recipients, channel) {
|
|
31
32
|
const messageSource = this.getServiceSource(record.context.service);
|
|
32
33
|
const message = {
|
|
33
34
|
'@type': 'Message',
|
|
@@ -40,6 +41,10 @@ export default class ImessageTransformer extends ChronicleTransformer {
|
|
|
40
41
|
if (recipients.length > 0) {
|
|
41
42
|
message.recipient = recipients;
|
|
42
43
|
}
|
|
44
|
+
// A group chat's message is part of its Channel. A one-to-one chat has none.
|
|
45
|
+
if (channel) {
|
|
46
|
+
message.isPartOf = [channel];
|
|
47
|
+
}
|
|
43
48
|
// Add attachments using contains property (following Bluesky pattern)
|
|
44
49
|
const attachments = record.context.attachments || [];
|
|
45
50
|
if (attachments.length > 0) {
|
|
@@ -47,6 +52,25 @@ export default class ImessageTransformer extends ChronicleTransformer {
|
|
|
47
52
|
}
|
|
48
53
|
return message;
|
|
49
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* The group chat a message is in, keyed by Apple's ID for the group, as
|
|
57
|
+
* `groupChatChannel` keys it for every plugin. Its members are the chat's
|
|
58
|
+
* roster and you: chat.db's roster lists only the others, but every group
|
|
59
|
+
* chat in it is one you're in. They go on it from the first message the
|
|
60
|
+
* extractor saw in the chat, as WhatsApp does, rather than from every message.
|
|
61
|
+
*/
|
|
62
|
+
buildChannel(record, participants, myIdentity) {
|
|
63
|
+
const { group, groupRoster } = record.context;
|
|
64
|
+
const channel = group ? groupChatChannel(group) : null;
|
|
65
|
+
if (!channel)
|
|
66
|
+
return null;
|
|
67
|
+
if (group.name)
|
|
68
|
+
channel.name = group.name;
|
|
69
|
+
const members = [...participants, ...(myIdentity ? [myIdentity] : [])];
|
|
70
|
+
if (groupRoster && members.length > 0)
|
|
71
|
+
channel.member = members;
|
|
72
|
+
return channel;
|
|
73
|
+
}
|
|
50
74
|
buildParticipants(record) {
|
|
51
75
|
const agentSource = this.getAgentSource(record.context.service);
|
|
52
76
|
const chatParticipants = record.context.participants || [];
|
|
@@ -163,6 +187,9 @@ export default class ImessageTransformer extends ChronicleTransformer {
|
|
|
163
187
|
const { handleIdText } = record.context;
|
|
164
188
|
const agent = participants.find(p => p.handle === handleIdText);
|
|
165
189
|
if (!agent) {
|
|
190
|
+
// A sender whose handle is gone from chat.db is unknown.
|
|
191
|
+
if (!handleIdText)
|
|
192
|
+
return { agent: undefined, recipients: myIdentity ? [myIdentity] : [] };
|
|
166
193
|
// The sender is not among the chat's participants: build them from the
|
|
167
194
|
// message's own handle, the same way as any other participant.
|
|
168
195
|
const agentSource = this.getAgentSource(record.context.service);
|
|
@@ -204,7 +231,7 @@ export default class ImessageTransformer extends ChronicleTransformer {
|
|
|
204
231
|
const filePath = this.resolveAttachmentPath(attachment.filename);
|
|
205
232
|
// Check if file exists
|
|
206
233
|
if (!fs.existsSync(filePath)) {
|
|
207
|
-
|
|
234
|
+
this.logger.warn('Attachment file not found', { file: filePath });
|
|
208
235
|
continue;
|
|
209
236
|
}
|
|
210
237
|
// MIME drives the media object @type (ImageObject vs DocumentObject…).
|
|
@@ -228,7 +255,10 @@ export default class ImessageTransformer extends ChronicleTransformer {
|
|
|
228
255
|
mediaObjects.push(mediaObject);
|
|
229
256
|
}
|
|
230
257
|
catch (error) {
|
|
231
|
-
|
|
258
|
+
this.logger.warn('Failed to process attachment', {
|
|
259
|
+
file: attachment.filename,
|
|
260
|
+
error: String(error),
|
|
261
|
+
});
|
|
232
262
|
}
|
|
233
263
|
}
|
|
234
264
|
return mediaObjects;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chronicle.app/imessage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"types": "./dist/index.d.ts",
|
|
6
6
|
"files": [
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
"type": "module",
|
|
20
20
|
"description": "Chronicle imessage extraction plugin",
|
|
21
21
|
"devDependencies": {
|
|
22
|
-
"@chronicle.app/etl": "0.
|
|
23
|
-
"@chronicle.app/schema": "0.
|
|
24
|
-
"@chronicle.app/tsconfig": "0.
|
|
22
|
+
"@chronicle.app/etl": "0.5.0",
|
|
23
|
+
"@chronicle.app/schema": "0.5.0",
|
|
24
|
+
"@chronicle.app/tsconfig": "0.5.0",
|
|
25
25
|
"@types/node": "^22.10.1",
|
|
26
26
|
"typescript": "^5.7.2"
|
|
27
27
|
},
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"registry": "https://registry.npmjs.org/"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@chronicle.app/etl-sqlite": "0.
|
|
40
|
-
"@chronicle.app/
|
|
39
|
+
"@chronicle.app/etl-sqlite": "0.5.0",
|
|
40
|
+
"@chronicle.app/apple": "0.5.0",
|
|
41
41
|
"zod": "^3.24.1"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
@@ -63,7 +63,64 @@
|
|
|
63
63
|
"recordTypes": [
|
|
64
64
|
"messages"
|
|
65
65
|
],
|
|
66
|
-
"default": true
|
|
66
|
+
"default": true,
|
|
67
|
+
"locations": [
|
|
68
|
+
{
|
|
69
|
+
"path": "~/Library/Messages/chat.db"
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
"databases": [
|
|
73
|
+
{
|
|
74
|
+
"tables": {
|
|
75
|
+
"message": [
|
|
76
|
+
"ROWID",
|
|
77
|
+
"guid",
|
|
78
|
+
"text",
|
|
79
|
+
"service",
|
|
80
|
+
"date",
|
|
81
|
+
"is_from_me",
|
|
82
|
+
"handle_id",
|
|
83
|
+
"attributedBody",
|
|
84
|
+
"item_type?"
|
|
85
|
+
],
|
|
86
|
+
"handle": [
|
|
87
|
+
"ROWID",
|
|
88
|
+
"id",
|
|
89
|
+
"service"
|
|
90
|
+
],
|
|
91
|
+
"chat": [
|
|
92
|
+
"ROWID",
|
|
93
|
+
"style",
|
|
94
|
+
"display_name?",
|
|
95
|
+
"group_id?",
|
|
96
|
+
"original_group_id?"
|
|
97
|
+
],
|
|
98
|
+
"chat_message_join": [
|
|
99
|
+
"chat_id",
|
|
100
|
+
"message_id"
|
|
101
|
+
],
|
|
102
|
+
"chat_handle_join": [
|
|
103
|
+
"chat_id",
|
|
104
|
+
"handle_id"
|
|
105
|
+
],
|
|
106
|
+
"message_attachment_join": [
|
|
107
|
+
"message_id",
|
|
108
|
+
"attachment_id"
|
|
109
|
+
],
|
|
110
|
+
"attachment": [
|
|
111
|
+
"ROWID",
|
|
112
|
+
"guid",
|
|
113
|
+
"filename",
|
|
114
|
+
"mime_type",
|
|
115
|
+
"uti",
|
|
116
|
+
"total_bytes",
|
|
117
|
+
"transfer_name",
|
|
118
|
+
"is_outgoing",
|
|
119
|
+
"hide_attachment?"
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
]
|
|
67
124
|
}
|
|
68
125
|
},
|
|
69
126
|
"platforms": [
|
|
@@ -73,6 +130,11 @@
|
|
|
73
130
|
"full-disk-access"
|
|
74
131
|
]
|
|
75
132
|
}
|
|
133
|
+
},
|
|
134
|
+
"deepLinks": {
|
|
135
|
+
"imessage": {
|
|
136
|
+
"Message": "messages://open?message-guid={sourceId}"
|
|
137
|
+
}
|
|
76
138
|
}
|
|
77
139
|
}
|
|
78
140
|
}
|