Skip to content

Commit f8db17f

Browse files
committed
Add LINE export parser
1 parent 923fef0 commit f8db17f

7 files changed

Lines changed: 376 additions & 5 deletions

File tree

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ Transform chat exports into geocoded activity suggestions.
77

88
## Overview
99

10-
ChatToMap extracts "things to do" from WhatsApp, iMessage, and Telegram exports - restaurants to try, places to visit, trips to take. It finds suggestions buried in years of chat history and puts them on a map.
10+
ChatToMap extracts "things to do" from WhatsApp, iMessage, Telegram, and LINE exports - restaurants to try, places to visit, trips to take. It finds suggestions buried in years of chat history and puts them on a map.
1111

1212
**Features:**
13-
- Parse WhatsApp (iOS/Android), iMessage, and Telegram Desktop JSON exports
13+
- Parse WhatsApp (iOS/Android), iMessage, Telegram Desktop JSON, and LINE text exports
1414
- Extract suggestions using multilingual regex patterns, embeddings, and URL detection
1515
- Classify with AI (activity vs errand, mappable vs general)
1616
- Scrape metadata from TikTok and YouTube links
@@ -53,6 +53,9 @@ chat-to-map analyze <input>
5353
# Telegram Desktop exports
5454
chat-to-map analyze "/path/to/ChatExport_2026-04-26/result.json"
5555

56+
# LINE text exports
57+
chat-to-map analyze "/path/to/[LINE] Chat with Friends.txt"
58+
5659
# List previously processed chats
5760
chat-to-map list
5861
```

src/cli/commands/parse.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import { initContext, stepParse } from '../steps/index'
1919
function formatSource(source: ChatSource): string {
2020
if (source === 'whatsapp') return 'WhatsApp'
2121
if (source === 'imessage') return 'iMessage'
22-
return 'Telegram'
22+
if (source === 'telegram') return 'Telegram'
23+
return 'LINE'
2324
}
2425

2526
/**

src/core/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,12 +151,15 @@ export {
151151
export {
152152
detectChatSource,
153153
detectFormat,
154+
isLineExport,
154155
isTelegramExport,
155156
parseChat,
156157
parseChatStream,
157158
parseChatWithStats,
158159
parseIMessageChat,
159160
parseIMessageChatStream,
161+
parseLineChat,
162+
parseLineChatStream,
160163
parseTelegramExport,
161164
parseWhatsAppChat,
162165
parseWhatsAppChatStream

src/parser/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
/**
22
* Parser Module
33
*
4-
* Parse WhatsApp, iMessage, and Telegram exports into structured messages.
4+
* Parse WhatsApp, iMessage, Telegram, and LINE exports into structured messages.
55
*/
66

77
import type { ChatSource, MediaType, ParsedMessage, ParseResult, ParserOptions } from '../types'
88
import { parseIMessageChat, parseIMessageChatStream } from './imessage'
9+
import { isLineExport, parseLineChat, parseLineChatStream } from './line'
910
import { isTelegramExport, parseTelegramExport } from './telegram'
1011
import { parseWhatsAppChat, parseWhatsAppChatStream } from './whatsapp'
1112

1213
export { parseIMessageChat, parseIMessageChatStream } from './imessage'
14+
export { isLineExport, parseLineChat, parseLineChatStream } from './line'
1315
export { isTelegramExport, parseTelegramExport } from './telegram'
1416
export {
1517
detectFormat,
@@ -183,6 +185,10 @@ export function detectChatSource(content: string): ChatSource {
183185
return 'telegram'
184186
}
185187

188+
if (isLineExport(content)) {
189+
return 'line'
190+
}
191+
186192
// Check for WhatsApp patterns (timestamp in brackets)
187193
if (/^\[\d{1,2}\/\d{1,2}\/\d{2,4},/.test(content)) {
188194
return 'whatsapp'
@@ -216,6 +222,10 @@ export function parseChat(raw: string, options?: ParserOptions): ParsedMessage[]
216222
return parseTelegramExport(raw)
217223
}
218224

225+
if (source === 'line') {
226+
return parseLineChat(raw)
227+
}
228+
219229
return parseWhatsAppChat(raw, options)
220230
}
221231

@@ -255,6 +265,8 @@ export async function* parseChatStream(
255265
yield* parseIMessageChatStream(lines)
256266
} else if (source === 'telegram') {
257267
throw new Error('Telegram JSON exports must be parsed with parseChat')
268+
} else if (source === 'line') {
269+
yield* parseLineChatStream(lines)
258270
} else {
259271
yield* parseWhatsAppChatStream(lines, options)
260272
}

src/parser/line.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { detectChatSource, parseChat, parseChatStream } from './index'
3+
import { isLineExport, parseLineChat } from './line'
4+
5+
const SAMPLE_EXPORT = `[LINE] Chat history with Travel Friends
6+
Saved on: 26/04/2026, 04:36PM
7+
8+
Sun, 26/04/2026
9+
04:35PM\tAlex\tLet's try the ramen place https://example.com.
10+
04:36PM\tMaya\t[Sticker]
11+
`
12+
13+
describe('LINE parser', () => {
14+
it('detects LINE text exports', () => {
15+
expect(isLineExport(SAMPLE_EXPORT)).toBe(true)
16+
expect(detectChatSource(SAMPLE_EXPORT)).toBe('line')
17+
})
18+
19+
it('parses tab-separated LINE messages', () => {
20+
const messages = parseLineChat(SAMPLE_EXPORT)
21+
22+
expect(messages).toHaveLength(2)
23+
expect(messages[0]?.id).toBe(0)
24+
expect(messages[0]?.sender).toBe('Alex')
25+
expect(messages[0]?.content).toBe("Let's try the ramen place https://example.com.")
26+
expect(messages[0]?.source).toBe('line')
27+
expect(messages[0]?.timestamp).toEqual(new Date(2026, 3, 26, 16, 35, 0))
28+
expect(messages[0]?.urls).toEqual(['https://example.com'])
29+
})
30+
31+
it('detects LINE media placeholders', () => {
32+
const messages = parseLineChat(SAMPLE_EXPORT)
33+
34+
expect(messages[1]?.content).toBe('[Sticker]')
35+
expect(messages[1]?.hasMedia).toBe(true)
36+
expect(messages[1]?.mediaType).toBe('sticker')
37+
})
38+
39+
it('parses year-first date lines and 24-hour times', () => {
40+
const raw = `[LINE] Chat history with Travel Friends
41+
42+
2026.04.26 Sunday
43+
16:35\tAlex\tDinner tomorrow?
44+
`
45+
46+
const messages = parseLineChat(raw)
47+
48+
expect(messages).toHaveLength(1)
49+
expect(messages[0]?.timestamp).toEqual(new Date(2026, 3, 26, 16, 35, 0))
50+
})
51+
52+
it('preserves continuation lines in multi-line messages', () => {
53+
const raw = `[LINE] Chat history with Travel Friends
54+
55+
Sun, 26/04/2026
56+
04:35PM\tAlex\tFirst line
57+
second line
58+
04:36PM\tMaya\tNext message
59+
`
60+
61+
const messages = parseLineChat(raw)
62+
63+
expect(messages).toHaveLength(2)
64+
expect(messages[0]?.content).toBe('First line\nsecond line')
65+
expect(messages[0]?.rawLine).toContain('second line')
66+
})
67+
68+
it('routes LINE exports through parseChat auto-detection', () => {
69+
const messages = parseChat(SAMPLE_EXPORT)
70+
71+
expect(messages).toHaveLength(2)
72+
expect(messages[0]?.source).toBe('line')
73+
})
74+
75+
it('streams LINE messages', async () => {
76+
const lines = (async function* () {
77+
yield '[LINE] Chat history with Travel Friends'
78+
yield ''
79+
yield 'Sun, 26/04/2026'
80+
yield '04:35PM\tAlex\tOne'
81+
yield '04:36PM\tMaya\tTwo'
82+
})()
83+
const messages = []
84+
85+
for await (const message of parseChatStream(lines, 'line')) {
86+
messages.push(message)
87+
}
88+
89+
expect(messages).toHaveLength(2)
90+
expect(messages[0]?.content).toBe('One')
91+
expect(messages[1]?.sender).toBe('Maya')
92+
})
93+
94+
it('does not detect generic text as LINE', () => {
95+
expect(isLineExport('Some random text')).toBe(false)
96+
expect(detectChatSource('Some random text')).toBe('whatsapp')
97+
})
98+
})

0 commit comments

Comments
 (0)