diff --git a/packages/node-runtime/src/export/format-exporter.ts b/packages/node-runtime/src/export/format-exporter.ts index ae747439..cc85e335 100644 --- a/packages/node-runtime/src/export/format-exporter.ts +++ b/packages/node-runtime/src/export/format-exporter.ts @@ -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(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(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), diff --git a/src/components/MessageExport/ConditionPanel.vue b/src/components/MessageExport/ConditionPanel.vue index db3e7b51..09617c04 100644 --- a/src/components/MessageExport/ConditionPanel.vue +++ b/src/components/MessageExport/ConditionPanel.vue @@ -11,7 +11,7 @@ const exportFormat = defineModel('exportFormat', { default: 'txt' const formatTabItems = computed(() => [ { label: 'TXT', value: 'txt' }, - { label: 'JSON', value: 'json' }, + { label: 'ChatLab JSON', value: 'json' }, { label: 'Markdown', value: 'markdown' }, ]) diff --git a/tests/chatlab-json-export-roundtrip.test.ts b/tests/chatlab-json-export-roundtrip.test.ts new file mode 100644 index 00000000..d6772202 --- /dev/null +++ b/tests/chatlab-json-export-roundtrip.test.ts @@ -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 }) + } +})