mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(export): generate importable ChatLab JSON
This commit is contained in:
1 parent
e1288c3cc7
commit
96235915b8
3 files changed
+235
-14
No files matched your search
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { DatabaseAdapter } from '@openchatlab/core'
|
||||
import { appLogger } from '../logging/app-logger'
|
||||
|
||||
export type ExportFormat = 'txt' | 'json' | 'markdown'
|
||||
|
||||
@@ -26,7 +27,31 @@ export interface FormatExportResult {
|
||||
interface MessageRow {
|
||||
ts: number
|
||||
senderName: string
|
||||
sender: string
|
||||
accountName: string
|
||||
groupNickname: string | null
|
||||
type: number
|
||||
content: string | null
|
||||
platformMessageId: string | null
|
||||
replyToMessageId: string | null
|
||||
}
|
||||
|
||||
interface MetaRow {
|
||||
name: string
|
||||
platform: string
|
||||
type: string
|
||||
groupId: string | null
|
||||
groupAvatar: string | null
|
||||
ownerId: string | null
|
||||
}
|
||||
|
||||
interface MemberRow {
|
||||
platformId: string
|
||||
accountName: string | null
|
||||
groupNickname: string | null
|
||||
aliases: string | null
|
||||
avatar: string | null
|
||||
roles: string | null
|
||||
}
|
||||
|
||||
function queryMessages(db: DatabaseAdapter, timeFilter?: { startTs: number; endTs: number }): MessageRow[] {
|
||||
@@ -34,7 +59,13 @@ function queryMessages(db: DatabaseAdapter, timeFilter?: { startTs: number; endT
|
||||
const sql = `
|
||||
SELECT msg.ts,
|
||||
COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName,
|
||||
msg.content
|
||||
m.platform_id as sender,
|
||||
COALESCE(msg.sender_account_name, m.account_name, m.platform_id) as accountName,
|
||||
COALESCE(msg.sender_group_nickname, m.group_nickname) as groupNickname,
|
||||
msg.type,
|
||||
msg.content,
|
||||
msg.platform_message_id as platformMessageId,
|
||||
msg.reply_to_message_id as replyToMessageId
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
${hasFilter ? 'WHERE msg.ts >= ? AND msg.ts <= ?' : ''}
|
||||
@@ -47,10 +78,6 @@ function queryMessages(db: DatabaseAdapter, timeFilter?: { startTs: number; endT
|
||||
return db.prepare(sql).all(...params) as unknown as MessageRow[]
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
function formatTimeShort(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
@@ -69,16 +96,75 @@ function exportAsTxt(messages: MessageRow[], sessionName: string): string {
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function exportAsJson(messages: MessageRow[], sessionName: string): string {
|
||||
function parseOptionalArray<T>(value: string | null): T[] | undefined {
|
||||
if (!value) return undefined
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed) && parsed.length > 0 ? (parsed as T[]) : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function exportAsJson(db: DatabaseAdapter, messages: MessageRow[], sessionName: string): string {
|
||||
const meta = db
|
||||
.prepare(
|
||||
`SELECT name,
|
||||
platform,
|
||||
type,
|
||||
group_id as groupId,
|
||||
group_avatar as groupAvatar,
|
||||
owner_id as ownerId
|
||||
FROM meta
|
||||
LIMIT 1`
|
||||
)
|
||||
.get() as unknown as MetaRow | undefined
|
||||
if (!meta) throw new Error('Cannot read session metadata')
|
||||
|
||||
const members = db
|
||||
.prepare(
|
||||
`SELECT platform_id as platformId,
|
||||
account_name as accountName,
|
||||
group_nickname as groupNickname,
|
||||
aliases,
|
||||
avatar,
|
||||
roles
|
||||
FROM member
|
||||
ORDER BY id ASC`
|
||||
)
|
||||
.all() as unknown as MemberRow[]
|
||||
|
||||
const data = {
|
||||
sessionName,
|
||||
exportTime: new Date().toISOString(),
|
||||
totalMessages: messages.length,
|
||||
chatlab: {
|
||||
version: '0.0.2',
|
||||
exportedAt: Math.floor(Date.now() / 1000),
|
||||
generator: 'ChatLab',
|
||||
},
|
||||
meta: {
|
||||
name: meta.name || sessionName,
|
||||
platform: meta.platform,
|
||||
type: meta.type,
|
||||
groupId: meta.groupId || undefined,
|
||||
groupAvatar: meta.groupAvatar || undefined,
|
||||
ownerId: meta.ownerId || undefined,
|
||||
},
|
||||
members: members.map((member) => ({
|
||||
platformId: member.platformId,
|
||||
accountName: member.accountName || member.platformId,
|
||||
groupNickname: member.groupNickname || undefined,
|
||||
aliases: parseOptionalArray<string>(member.aliases),
|
||||
avatar: member.avatar || undefined,
|
||||
roles: parseOptionalArray<{ id: string; name?: string }>(member.roles),
|
||||
})),
|
||||
messages: messages.map((msg) => ({
|
||||
sender: msg.sender,
|
||||
accountName: msg.accountName,
|
||||
groupNickname: msg.groupNickname || undefined,
|
||||
timestamp: msg.ts,
|
||||
time: formatTime(msg.ts),
|
||||
sender: msg.senderName,
|
||||
content: msg.content || '',
|
||||
type: msg.type,
|
||||
content: msg.content,
|
||||
platformMessageId: msg.platformMessageId || undefined,
|
||||
replyToMessageId: msg.replyToMessageId || undefined,
|
||||
})),
|
||||
}
|
||||
return JSON.stringify(data, null, 2)
|
||||
@@ -133,7 +219,7 @@ export function exportWithFormat(
|
||||
content = exportAsTxt(messages, params.sessionName)
|
||||
break
|
||||
case 'json':
|
||||
content = exportAsJson(messages, params.sessionName)
|
||||
content = exportAsJson(db, messages, params.sessionName)
|
||||
break
|
||||
case 'markdown':
|
||||
content = exportAsMarkdown(messages, params.sessionName)
|
||||
@@ -142,8 +228,15 @@ export function exportWithFormat(
|
||||
|
||||
const timestamp = Date.now()
|
||||
const filename = `${params.sessionName}_export_${timestamp}.${ext}`
|
||||
appLogger.info('export', 'chat export generated', {
|
||||
sessionId: params.sessionId,
|
||||
format: params.format,
|
||||
totalMessages: messages.length,
|
||||
filteredByTime: !!params.timeFilter,
|
||||
})
|
||||
return { success: true, totalMessages: messages.length, content, filename, mimeType: mime }
|
||||
} catch (error) {
|
||||
appLogger.error('export', 'chat export failed', error)
|
||||
return {
|
||||
success: false,
|
||||
error: String(error),
|
||||
|
||||
@@ -11,7 +11,7 @@ const exportFormat = defineModel<ExportFormat>('exportFormat', { default: 'txt'
|
||||
|
||||
const formatTabItems = computed(() => [
|
||||
{ label: 'TXT', value: 'txt' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
{ label: 'ChatLab JSON', value: 'json' },
|
||||
{ label: 'Markdown', value: 'markdown' },
|
||||
])
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import Database from 'better-sqlite3'
|
||||
import { detectFormat, parseFileSync } from '@openchatlab/parser'
|
||||
import { CHAT_DB_SCHEMA } from '../packages/core/src/schema/tables'
|
||||
import { BetterSqliteAdapter } from '../packages/node-runtime/src/better-sqlite3-adapter'
|
||||
import { exportWithFormat } from '../packages/node-runtime/src/export/format-exporter'
|
||||
|
||||
test('exports JSON as ChatLab format that can be parsed for re-import', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'chatlab-json-export-'))
|
||||
const filePath = join(tempDir, 'roundtrip.json')
|
||||
const rawDb = new Database(':memory:')
|
||||
|
||||
try {
|
||||
rawDb.exec(CHAT_DB_SCHEMA)
|
||||
rawDb
|
||||
.prepare(
|
||||
`INSERT INTO meta (name, platform, type, imported_at, group_id, group_avatar, owner_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run('测试群', 'qq', 'group', 100, 'group-1', 'group-avatar', 'alice')
|
||||
|
||||
const insertMember = rawDb.prepare(
|
||||
`INSERT INTO member (platform_id, account_name, group_nickname, aliases, avatar, roles)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
const aliceId = insertMember.run(
|
||||
'alice',
|
||||
'Alice Account',
|
||||
'爱丽丝',
|
||||
JSON.stringify(['Alice']),
|
||||
'alice-avatar',
|
||||
JSON.stringify([{ id: 'owner' }])
|
||||
).lastInsertRowid
|
||||
const bobId = insertMember.run('bob', 'Bob Account', '鲍勃', '[]', null, '[]').lastInsertRowid
|
||||
|
||||
const insertMessage = rawDb.prepare(
|
||||
`INSERT INTO message
|
||||
(sender_id, sender_account_name, sender_group_nickname, ts, type, content, platform_message_id, reply_to_message_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
insertMessage.run(aliceId, 'Alice Account', '爱丽丝', 100, 0, '筛选范围外', 'msg-1', null)
|
||||
insertMessage.run(bobId, 'Bob Account', '鲍勃', 200, 0, '你好', 'msg-2', null)
|
||||
insertMessage.run(aliceId, 'Alice Account', '爱丽丝', 250, 1, null, 'msg-3', 'msg-2')
|
||||
|
||||
const result = exportWithFormat(
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
sessionName: 'UI 中的会话名',
|
||||
format: 'json',
|
||||
timeFilter: { startTs: 150, endTs: 300 },
|
||||
},
|
||||
() => new BetterSqliteAdapter(rawDb)
|
||||
)
|
||||
|
||||
assert.equal(result.success, true)
|
||||
const exported = JSON.parse(result.content)
|
||||
assert.equal(exported.chatlab.version, '0.0.2')
|
||||
assert.equal(exported.meta.ownerId, 'alice')
|
||||
assert.deepEqual(exported.members[0].aliases, ['Alice'])
|
||||
assert.deepEqual(
|
||||
exported.messages.map((message: { timestamp: number }) => message.timestamp),
|
||||
[200, 250]
|
||||
)
|
||||
|
||||
writeFileSync(filePath, result.content, 'utf8')
|
||||
assert.equal(detectFormat(filePath)?.id, 'chatlab')
|
||||
|
||||
const parsed = await parseFileSync(filePath)
|
||||
assert.deepEqual(parsed.meta, {
|
||||
name: '测试群',
|
||||
platform: 'qq',
|
||||
type: 'group',
|
||||
groupId: 'group-1',
|
||||
groupAvatar: 'group-avatar',
|
||||
})
|
||||
assert.deepEqual(
|
||||
parsed.members.map((member) => ({
|
||||
platformId: member.platformId,
|
||||
accountName: member.accountName,
|
||||
groupNickname: member.groupNickname,
|
||||
roles: member.roles,
|
||||
})),
|
||||
[
|
||||
{
|
||||
platformId: 'alice',
|
||||
accountName: 'Alice Account',
|
||||
groupNickname: '爱丽丝',
|
||||
roles: [{ id: 'owner' }],
|
||||
},
|
||||
{
|
||||
platformId: 'bob',
|
||||
accountName: 'Bob Account',
|
||||
groupNickname: '鲍勃',
|
||||
roles: undefined,
|
||||
},
|
||||
]
|
||||
)
|
||||
assert.deepEqual(parsed.messages, [
|
||||
{
|
||||
senderPlatformId: 'bob',
|
||||
senderAccountName: 'Bob Account',
|
||||
senderGroupNickname: '鲍勃',
|
||||
timestamp: 200,
|
||||
type: 0,
|
||||
content: '你好',
|
||||
platformMessageId: 'msg-2',
|
||||
replyToMessageId: undefined,
|
||||
},
|
||||
{
|
||||
senderPlatformId: 'alice',
|
||||
senderAccountName: 'Alice Account',
|
||||
senderGroupNickname: '爱丽丝',
|
||||
timestamp: 250,
|
||||
type: 1,
|
||||
content: null,
|
||||
platformMessageId: 'msg-3',
|
||||
replyToMessageId: 'msg-2',
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
rawDb.close()
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user