Skip to content

Commit 6dadb51

Browse files
ndbroadbentclaude
andcommitted
Add iMessage JSON multi-chat parser
Adds parseIMessageJsonBundle for the format the ChatToMap desktop app produces: a zip with manifest.json + N chat_NNN.json files where each chat is { meta, messages: [{ timestamp, sender, is_from_me, text }] }. Preserves chatId provenance per ParsedMessage and adds an optional chats: ChatMetadata[] field on ParseResult so callers can render per-chat attribution without re-parsing. Following the Telegram pattern, JSON parsing is non-streaming and exposed via dedicated entry points rather than the generic parseChat(string) API. The existing iMessage TEXT parser (imessage-exporter format) is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3c6eac2 commit 6dadb51

6 files changed

Lines changed: 514 additions & 0 deletions

File tree

src/core/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,16 +148,26 @@ export {
148148
} from '../images/index'
149149

150150
// Parser module
151+
export type {
152+
IMessageJsonChat,
153+
IMessageJsonChatEntry,
154+
IMessageJsonChatMeta,
155+
IMessageJsonManifest,
156+
IMessageJsonMessage
157+
} from '../parser/index'
151158
export {
152159
detectChatSource,
153160
detectFormat,
161+
isIMessageJsonChat,
162+
isIMessageJsonManifest,
154163
isLineExport,
155164
isTelegramExport,
156165
parseChat,
157166
parseChatStream,
158167
parseChatWithStats,
159168
parseIMessageChat,
160169
parseIMessageChatStream,
170+
parseIMessageJsonBundle,
161171
parseLineChat,
162172
parseLineChatStream,
163173
parseTelegramExport,

src/parser/imessage-json.test.ts

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
type IMessageJsonChat,
4+
type IMessageJsonChatEntry,
5+
type IMessageJsonManifest,
6+
type IMessageJsonMessage,
7+
isIMessageJsonChat,
8+
isIMessageJsonManifest,
9+
parseIMessageJsonBundle
10+
} from './imessage-json'
11+
12+
// ---------------------------------------------------------------------------
13+
// Builders
14+
// ---------------------------------------------------------------------------
15+
16+
function makeManifest(overrides: Partial<IMessageJsonManifest> = {}): IMessageJsonManifest {
17+
return {
18+
version: '1.0',
19+
source: 'imessage',
20+
chat_count: 1,
21+
total_messages: 0,
22+
...overrides
23+
}
24+
}
25+
26+
function makeMessage(overrides: Partial<IMessageJsonMessage> = {}): IMessageJsonMessage {
27+
return {
28+
timestamp: '2025-04-02T08:52:29.000Z',
29+
sender: 'Alice',
30+
is_from_me: false,
31+
text: 'Hello',
32+
...overrides
33+
}
34+
}
35+
36+
function makeChat(name: string, messages: readonly IMessageJsonMessage[]): IMessageJsonChat {
37+
return {
38+
meta: {
39+
name,
40+
identifier: `+1555${name.length.toString().padStart(7, '0')}`,
41+
service: 'iMessage',
42+
message_count: messages.length
43+
},
44+
messages
45+
}
46+
}
47+
48+
function makeEntry(id: string, chat: IMessageJsonChat): IMessageJsonChatEntry {
49+
return { id, chat }
50+
}
51+
52+
// ---------------------------------------------------------------------------
53+
// Manifest type guard
54+
// ---------------------------------------------------------------------------
55+
56+
describe('isIMessageJsonManifest', () => {
57+
it('accepts a valid manifest', () => {
58+
expect(isIMessageJsonManifest(makeManifest({ chat_count: 3, total_messages: 42 }))).toBe(true)
59+
})
60+
61+
it('rejects wrong source', () => {
62+
expect(isIMessageJsonManifest({ ...makeManifest(), source: 'whatsapp' })).toBe(false)
63+
})
64+
65+
it('rejects missing fields', () => {
66+
expect(isIMessageJsonManifest({ source: 'imessage' })).toBe(false)
67+
expect(isIMessageJsonManifest(null)).toBe(false)
68+
expect(isIMessageJsonManifest('not an object')).toBe(false)
69+
})
70+
71+
it('rejects wrong field types', () => {
72+
expect(isIMessageJsonManifest({ ...makeManifest(), chat_count: '3' })).toBe(false)
73+
})
74+
})
75+
76+
// ---------------------------------------------------------------------------
77+
// Chat type guard
78+
// ---------------------------------------------------------------------------
79+
80+
describe('isIMessageJsonChat', () => {
81+
it('accepts a valid chat', () => {
82+
expect(isIMessageJsonChat(makeChat('A', [makeMessage()]))).toBe(true)
83+
})
84+
85+
it('rejects missing meta', () => {
86+
expect(isIMessageJsonChat({ messages: [] })).toBe(false)
87+
})
88+
89+
it('rejects malformed messages', () => {
90+
const bad = {
91+
meta: { name: 'A', identifier: 'x', service: 'iMessage', message_count: 1 },
92+
messages: [{ timestamp: '2025-04-02T00:00:00Z', sender: 'A', text: 'hi' }] // missing is_from_me
93+
}
94+
expect(isIMessageJsonChat(bad)).toBe(false)
95+
})
96+
})
97+
98+
// ---------------------------------------------------------------------------
99+
// Bundle parsing
100+
// ---------------------------------------------------------------------------
101+
102+
describe('parseIMessageJsonBundle', () => {
103+
it('parses a single chat with multiple messages', () => {
104+
const chat = makeChat('Travel', [
105+
makeMessage({ timestamp: '2025-04-02T08:00:00Z', sender: 'Alice', text: 'Try the cafe' }),
106+
makeMessage({ timestamp: '2025-04-02T09:00:00Z', is_from_me: true, text: 'Sure!' })
107+
])
108+
const result = parseIMessageJsonBundle(makeManifest({ chat_count: 1, total_messages: 2 }), [
109+
makeEntry('chat_001', chat)
110+
])
111+
112+
expect(result.messageCount).toBe(2)
113+
expect(result.messages[0]?.sender).toBe('Alice')
114+
expect(result.messages[0]?.content).toBe('Try the cafe')
115+
expect(result.messages[0]?.source).toBe('imessage')
116+
expect(result.messages[0]?.chatId).toBe('chat_001')
117+
expect(result.messages[1]?.sender).toBe('Me')
118+
expect(result.messages[1]?.chatId).toBe('chat_001')
119+
expect(result.chats).toHaveLength(1)
120+
expect(result.chats?.[0]?.name).toBe('Travel')
121+
})
122+
123+
it('preserves chatId across multi-chat bundles', () => {
124+
const chatA = makeChat('Travel', [makeMessage({ text: 'cafe melt' })])
125+
const chatB = makeChat('Sports', [makeMessage({ text: 'gym session' })])
126+
const chatC = makeChat('Work', [makeMessage({ text: 'lunch spot' })])
127+
128+
const result = parseIMessageJsonBundle(makeManifest({ chat_count: 3, total_messages: 3 }), [
129+
makeEntry('chat_001', chatA),
130+
makeEntry('chat_002', chatB),
131+
makeEntry('chat_003', chatC)
132+
])
133+
134+
expect(result.messages.map((m) => m.chatId)).toEqual(['chat_001', 'chat_002', 'chat_003'])
135+
expect(result.chats?.map((c) => c.name)).toEqual(['Travel', 'Sports', 'Work'])
136+
})
137+
138+
it('assigns globally unique IDs across chats', () => {
139+
const chatA = makeChat('A', [makeMessage({ text: 'one' }), makeMessage({ text: 'two' })])
140+
const chatB = makeChat('B', [makeMessage({ text: 'three' })])
141+
142+
const result = parseIMessageJsonBundle(makeManifest({ chat_count: 2, total_messages: 3 }), [
143+
makeEntry('chat_001', chatA),
144+
makeEntry('chat_002', chatB)
145+
])
146+
147+
expect(result.messages.map((m) => m.id)).toEqual([0, 1, 2])
148+
})
149+
150+
it('throws when manifest chat_count does not match entries', () => {
151+
expect(() =>
152+
parseIMessageJsonBundle(makeManifest({ chat_count: 2, total_messages: 1 }), [
153+
makeEntry('chat_001', makeChat('A', [makeMessage()]))
154+
])
155+
).toThrow(/chat_count=2/)
156+
})
157+
158+
it('skips messages with empty text', () => {
159+
const chat = makeChat('A', [
160+
makeMessage({ text: '' }),
161+
makeMessage({ text: ' ' }),
162+
makeMessage({ text: 'real content' })
163+
])
164+
const result = parseIMessageJsonBundle(makeManifest({ chat_count: 1 }), [
165+
makeEntry('chat_001', chat)
166+
])
167+
expect(result.messageCount).toBe(1)
168+
expect(result.messages[0]?.content).toBe('real content')
169+
})
170+
171+
it('skips messages with invalid timestamps', () => {
172+
const chat = makeChat('A', [
173+
makeMessage({ timestamp: 'not-a-date', text: 'broken' }),
174+
makeMessage({ timestamp: '2025-04-02T08:00:00Z', text: 'ok' })
175+
])
176+
const result = parseIMessageJsonBundle(makeManifest({ chat_count: 1 }), [
177+
makeEntry('chat_001', chat)
178+
])
179+
expect(result.messageCount).toBe(1)
180+
expect(result.messages[0]?.content).toBe('ok')
181+
})
182+
183+
it('extracts URLs from message text', () => {
184+
const chat = makeChat('A', [
185+
makeMessage({ text: 'Check https://example.com/cafe and https://maps.app/x' })
186+
])
187+
const result = parseIMessageJsonBundle(makeManifest({ chat_count: 1 }), [
188+
makeEntry('chat_001', chat)
189+
])
190+
expect(result.messages[0]?.urls).toEqual(['https://example.com/cafe', 'https://maps.app/x'])
191+
})
192+
193+
it('chunks long messages and propagates chatId to every chunk', () => {
194+
const longText = 'A'.repeat(700)
195+
const chat = makeChat('A', [makeMessage({ text: longText })])
196+
const result = parseIMessageJsonBundle(makeManifest({ chat_count: 1 }), [
197+
makeEntry('chat_001', chat)
198+
])
199+
expect(result.messages.length).toBeGreaterThan(1)
200+
for (const msg of result.messages) {
201+
expect(msg.chatId).toBe('chat_001')
202+
}
203+
})
204+
205+
it('uses "Me" sender label when is_from_me is true', () => {
206+
const chat = makeChat('A', [
207+
makeMessage({ sender: 'Some Name', is_from_me: true, text: 'mine' })
208+
])
209+
const result = parseIMessageJsonBundle(makeManifest({ chat_count: 1 }), [
210+
makeEntry('chat_001', chat)
211+
])
212+
expect(result.messages[0]?.sender).toBe('Me')
213+
})
214+
215+
it('falls back to Unknown when sender is empty for non-self messages', () => {
216+
const chat = makeChat('A', [makeMessage({ sender: ' ', text: 'who?' })])
217+
const result = parseIMessageJsonBundle(makeManifest({ chat_count: 1 }), [
218+
makeEntry('chat_001', chat)
219+
])
220+
expect(result.messages[0]?.sender).toBe('Unknown')
221+
})
222+
223+
it('collects unique senders across chats', () => {
224+
const chatA = makeChat('A', [makeMessage({ sender: 'Alice' }), makeMessage({ sender: 'Bob' })])
225+
const chatB = makeChat('B', [makeMessage({ sender: 'Bob' }), makeMessage({ sender: 'Carol' })])
226+
const result = parseIMessageJsonBundle(makeManifest({ chat_count: 2 }), [
227+
makeEntry('chat_001', chatA),
228+
makeEntry('chat_002', chatB)
229+
])
230+
expect(result.senders).toEqual(['Alice', 'Bob', 'Carol'])
231+
})
232+
233+
it('computes a date range across all chats', () => {
234+
const chatA = makeChat('A', [makeMessage({ timestamp: '2025-04-02T08:00:00Z' })])
235+
const chatB = makeChat('B', [makeMessage({ timestamp: '2025-04-05T22:00:00Z' })])
236+
const result = parseIMessageJsonBundle(makeManifest({ chat_count: 2 }), [
237+
makeEntry('chat_001', chatA),
238+
makeEntry('chat_002', chatB)
239+
])
240+
expect(result.dateRange.start.toISOString()).toBe('2025-04-02T08:00:00.000Z')
241+
expect(result.dateRange.end.toISOString()).toBe('2025-04-05T22:00:00.000Z')
242+
})
243+
244+
it('returns empty result when all messages are filtered out', () => {
245+
const chat = makeChat('A', [makeMessage({ text: '' })])
246+
const result = parseIMessageJsonBundle(makeManifest({ chat_count: 1 }), [
247+
makeEntry('chat_001', chat)
248+
])
249+
expect(result.messageCount).toBe(0)
250+
expect(result.chats).toHaveLength(1)
251+
})
252+
})

0 commit comments

Comments
 (0)