mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-04 04:01:16 +08:00
feat: 完成聊天记录解析和基础数据渲染
This commit is contained in:
634
electron/main/database/index.ts
Normal file
634
electron/main/database/index.ts
Normal file
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* 数据库服务模块
|
||||
* 管理 SQLite 数据库的创建、查询和分析
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3'
|
||||
import { app } from 'electron'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import type {
|
||||
DbMeta,
|
||||
ParseResult,
|
||||
AnalysisSession,
|
||||
MemberActivity,
|
||||
HourlyActivity,
|
||||
DailyActivity,
|
||||
MessageType,
|
||||
} from '../../../src/types/chat'
|
||||
|
||||
// 数据库存储目录
|
||||
let DB_DIR: string | null = null
|
||||
|
||||
/**
|
||||
* 获取数据库目录(懒加载)
|
||||
*/
|
||||
function getDbDir(): string {
|
||||
if (DB_DIR) return DB_DIR
|
||||
|
||||
try {
|
||||
// 使用 Documents 目录,避免开发环境下 userData 被重置的问题,也方便用户管理数据
|
||||
const docPath = app.getPath('documents')
|
||||
console.log('[Database] app.getPath("documents"):', docPath)
|
||||
DB_DIR = path.join(docPath, 'ChatLens', 'databases')
|
||||
} catch (error) {
|
||||
console.error('[Database] Error getting userData path:', error)
|
||||
DB_DIR = path.join(process.cwd(), 'databases')
|
||||
console.log('[Database] Using fallback DB_DIR:', DB_DIR)
|
||||
}
|
||||
|
||||
return DB_DIR
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保数据库目录存在
|
||||
*/
|
||||
function ensureDbDir(): void {
|
||||
const dir = getDbDir()
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一的会话ID
|
||||
*/
|
||||
function generateSessionId(): string {
|
||||
const timestamp = Date.now()
|
||||
const random = Math.random().toString(36).substring(2, 8)
|
||||
return `chat_${timestamp}_${random}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库文件路径
|
||||
*/
|
||||
function getDbPath(sessionId: string): string {
|
||||
return path.join(getDbDir(), `${sessionId}.db`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新数据库并初始化表结构
|
||||
*/
|
||||
function createDatabase(sessionId: string): Database.Database {
|
||||
ensureDbDir()
|
||||
const dbPath = getDbPath(sessionId)
|
||||
const db = new Database(dbPath)
|
||||
|
||||
// 启用 WAL 模式提升性能
|
||||
db.pragma('journal_mode = WAL')
|
||||
|
||||
// 创建表结构
|
||||
db.exec(`
|
||||
-- 元信息表(单行)
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
name TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
imported_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 成员表
|
||||
CREATE TABLE IF NOT EXISTS member (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 消息表
|
||||
CREATE TABLE IF NOT EXISTS message (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender_id INTEGER NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
type INTEGER NOT NULL,
|
||||
content TEXT,
|
||||
FOREIGN KEY(sender_id) REFERENCES member(id)
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_message_ts ON message(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_sender ON message(sender_id);
|
||||
`)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开已存在的数据库
|
||||
*/
|
||||
function openDatabase(sessionId: string): Database.Database | null {
|
||||
const dbPath = getDbPath(sessionId)
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return null
|
||||
}
|
||||
const db = new Database(dbPath, { readonly: true })
|
||||
db.pragma('journal_mode = WAL')
|
||||
return db
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入解析后的数据到数据库
|
||||
*/
|
||||
export function importData(parseResult: ParseResult): string {
|
||||
console.log('[Database] importData called')
|
||||
const sessionId = generateSessionId()
|
||||
console.log('[Database] Generated sessionId:', sessionId)
|
||||
|
||||
const dbPath = getDbPath(sessionId)
|
||||
console.log('[Database] Creating database at:', dbPath)
|
||||
|
||||
const db = createDatabase(sessionId)
|
||||
console.log('[Database] Database created successfully')
|
||||
|
||||
try {
|
||||
// 使用事务提升性能
|
||||
const importTransaction = db.transaction(() => {
|
||||
// 插入元信息
|
||||
const insertMeta = db.prepare(`
|
||||
INSERT INTO meta (name, platform, type, imported_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`)
|
||||
insertMeta.run(
|
||||
parseResult.meta.name,
|
||||
parseResult.meta.platform,
|
||||
parseResult.meta.type,
|
||||
Math.floor(Date.now() / 1000)
|
||||
)
|
||||
|
||||
// 插入成员并建立 platformId -> id 映射
|
||||
const insertMember = db.prepare(`
|
||||
INSERT OR IGNORE INTO member (platform_id, name) VALUES (?, ?)
|
||||
`)
|
||||
const updateMemberName = db.prepare(`
|
||||
UPDATE member SET name = ? WHERE platform_id = ?
|
||||
`)
|
||||
const getMemberId = db.prepare(`
|
||||
SELECT id FROM member WHERE platform_id = ?
|
||||
`)
|
||||
|
||||
const memberIdMap = new Map<string, number>()
|
||||
|
||||
for (const member of parseResult.members) {
|
||||
insertMember.run(member.platformId, member.name)
|
||||
// 更新为最新昵称
|
||||
updateMemberName.run(member.name, member.platformId)
|
||||
const row = getMemberId.get(member.platformId) as { id: number }
|
||||
memberIdMap.set(member.platformId, row.id)
|
||||
}
|
||||
|
||||
// 批量插入消息
|
||||
const insertMessage = db.prepare(`
|
||||
INSERT INTO message (sender_id, ts, type, content) VALUES (?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
for (const msg of parseResult.messages) {
|
||||
const senderId = memberIdMap.get(msg.senderPlatformId)
|
||||
if (senderId !== undefined) {
|
||||
insertMessage.run(senderId, msg.timestamp, msg.type, msg.content)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[Database] Executing transaction...')
|
||||
importTransaction()
|
||||
console.log('[Database] Transaction completed')
|
||||
|
||||
// 验证文件是否存在
|
||||
const dbPath = getDbPath(sessionId)
|
||||
const fileExists = fs.existsSync(dbPath)
|
||||
console.log('[Database] File exists after transaction:', fileExists, dbPath)
|
||||
|
||||
return sessionId
|
||||
} catch (error) {
|
||||
console.error('[Database] Error in importData:', error)
|
||||
throw error
|
||||
} finally {
|
||||
console.log('[Database] Closing database...')
|
||||
db.close()
|
||||
console.log('[Database] Database closed')
|
||||
|
||||
// 再次验证文件
|
||||
const dbPath = getDbPath(sessionId)
|
||||
const fileExists = fs.existsSync(dbPath)
|
||||
console.log('[Database] File exists after close:', fileExists)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有分析会话列表
|
||||
*/
|
||||
export function getAllSessions(): AnalysisSession[] {
|
||||
ensureDbDir()
|
||||
const sessions: AnalysisSession[] = []
|
||||
|
||||
const dbDir = getDbDir()
|
||||
console.log('[Database] getAllSessions: DB_DIR =', dbDir)
|
||||
console.log('[Database] getAllSessions: DB_DIR exists =', fs.existsSync(dbDir))
|
||||
|
||||
// 列出目录内容
|
||||
const allFiles = fs.readdirSync(dbDir)
|
||||
console.log('[Database] getAllSessions: all files in dir:', allFiles)
|
||||
|
||||
const files = allFiles.filter((f) => f.endsWith('.db'))
|
||||
console.log('[Database] getAllSessions: filtered .db files:', files)
|
||||
|
||||
for (const file of files) {
|
||||
const sessionId = file.replace('.db', '')
|
||||
const dbPath = getDbPath(sessionId)
|
||||
console.log('[Database] Opening database:', dbPath)
|
||||
|
||||
try {
|
||||
// 不使用 readonly 模式,以便能正确读取 WAL 日志
|
||||
const db = new Database(dbPath)
|
||||
db.pragma('journal_mode = WAL')
|
||||
|
||||
// 获取元信息
|
||||
const meta = db.prepare('SELECT * FROM meta LIMIT 1').get() as DbMeta | undefined
|
||||
console.log('[Database] Meta:', meta)
|
||||
|
||||
if (meta) {
|
||||
// 获取消息数和成员数(排除系统消息)
|
||||
const messageCount = (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
WHERE m.name != '系统消息'`
|
||||
)
|
||||
.get() as { count: number }
|
||||
).count
|
||||
const memberCount = (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM member
|
||||
WHERE name != '系统消息'`
|
||||
)
|
||||
.get() as { count: number }
|
||||
).count
|
||||
console.log('[Database] Counts:', { messageCount, memberCount })
|
||||
|
||||
sessions.push({
|
||||
id: sessionId,
|
||||
name: meta.name,
|
||||
platform: meta.platform as AnalysisSession['platform'],
|
||||
type: meta.type as AnalysisSession['type'],
|
||||
importedAt: meta.imported_at,
|
||||
messageCount,
|
||||
memberCount,
|
||||
dbPath,
|
||||
})
|
||||
}
|
||||
|
||||
db.close()
|
||||
} catch (error) {
|
||||
// 跳过无法读取的数据库文件
|
||||
console.error(`[Database] Failed to read database ${file}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Database] getAllSessions: returning', sessions.length, 'sessions')
|
||||
// 按导入时间倒序排列
|
||||
return sessions.sort((a, b) => b.importedAt - a.importedAt)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个会话信息
|
||||
*/
|
||||
export function getSession(sessionId: string): AnalysisSession | null {
|
||||
const db = openDatabase(sessionId)
|
||||
if (!db) return null
|
||||
|
||||
try {
|
||||
const meta = db.prepare('SELECT * FROM meta LIMIT 1').get() as DbMeta | undefined
|
||||
if (!meta) return null
|
||||
|
||||
// 排除系统消息的消息数
|
||||
const messageCount = (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
WHERE m.name != '系统消息'`
|
||||
)
|
||||
.get() as { count: number }
|
||||
).count
|
||||
|
||||
// 排除系统消息的成员数
|
||||
const memberCount = (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM member
|
||||
WHERE name != '系统消息'`
|
||||
)
|
||||
.get() as { count: number }
|
||||
).count
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
name: meta.name,
|
||||
platform: meta.platform as AnalysisSession['platform'],
|
||||
type: meta.type as AnalysisSession['type'],
|
||||
importedAt: meta.imported_at,
|
||||
messageCount,
|
||||
memberCount,
|
||||
dbPath: getDbPath(sessionId),
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
*/
|
||||
export function deleteSession(sessionId: string): boolean {
|
||||
const dbPath = getDbPath(sessionId)
|
||||
const walPath = dbPath + '-wal'
|
||||
const shmPath = dbPath + '-shm'
|
||||
|
||||
try {
|
||||
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath)
|
||||
if (fs.existsSync(walPath)) fs.unlinkSync(walPath)
|
||||
if (fs.existsSync(shmPath)) fs.unlinkSync(shmPath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 分析查询 ====================
|
||||
|
||||
/**
|
||||
* 时间过滤参数
|
||||
*/
|
||||
export interface TimeFilter {
|
||||
startTs?: number // 开始时间戳(秒)
|
||||
endTs?: number // 结束时间戳(秒)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建时间过滤 WHERE 子句
|
||||
*/
|
||||
function buildTimeFilter(filter?: TimeFilter): { clause: string; params: number[] } {
|
||||
const conditions: string[] = []
|
||||
const params: number[] = []
|
||||
|
||||
if (filter?.startTs !== undefined) {
|
||||
conditions.push('ts >= ?')
|
||||
params.push(filter.startTs)
|
||||
}
|
||||
if (filter?.endTs !== undefined) {
|
||||
conditions.push('ts <= ?')
|
||||
params.push(filter.endTs)
|
||||
}
|
||||
|
||||
return {
|
||||
clause: conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '',
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建排除系统消息的过滤条件
|
||||
* @param existingClause 已有的 WHERE 子句(如时间过滤)
|
||||
*/
|
||||
function buildSystemMessageFilter(existingClause: string): string {
|
||||
const systemFilter = "m.name != '系统消息'"
|
||||
|
||||
if (existingClause.includes('WHERE')) {
|
||||
return existingClause + ' AND ' + systemFilter
|
||||
} else {
|
||||
return ' WHERE ' + systemFilter
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的年份列表
|
||||
*/
|
||||
export function getAvailableYears(sessionId: string): number[] {
|
||||
const db = openDatabase(sessionId)
|
||||
if (!db) return []
|
||||
|
||||
try {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT DISTINCT CAST(strftime('%Y', ts, 'unixepoch', 'localtime') AS INTEGER) as year
|
||||
FROM message
|
||||
ORDER BY year
|
||||
`
|
||||
)
|
||||
.all() as Array<{ year: number }>
|
||||
|
||||
return rows.map((r) => r.year)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成员活跃度排行
|
||||
*/
|
||||
export function getMemberActivity(sessionId: string, filter?: TimeFilter): MemberActivity[] {
|
||||
const db = openDatabase(sessionId)
|
||||
if (!db) return []
|
||||
|
||||
try {
|
||||
const { clause, params } = buildTimeFilter(filter)
|
||||
|
||||
// 构建消息过滤条件(排除系统消息 + 时间过滤)
|
||||
const msgFilterBase = clause ? clause.replace('WHERE', 'AND') : ''
|
||||
const msgFilterWithSystem = msgFilterBase + " AND m.name != '系统消息'"
|
||||
|
||||
// 计算总消息数(排除系统消息)
|
||||
const totalClauseWithSystem = buildSystemMessageFilter(clause)
|
||||
const totalMessages = (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
${totalClauseWithSystem}`
|
||||
)
|
||||
.get(...params) as { count: number }
|
||||
).count
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
m.id as memberId,
|
||||
m.platform_id as platformId,
|
||||
m.name,
|
||||
COUNT(msg.id) as messageCount
|
||||
FROM member m
|
||||
LEFT JOIN message msg ON m.id = msg.sender_id ${msgFilterWithSystem}
|
||||
WHERE m.name != '系统消息'
|
||||
GROUP BY m.id
|
||||
HAVING messageCount > 0
|
||||
ORDER BY messageCount DESC
|
||||
`
|
||||
)
|
||||
.all(...params) as Array<{
|
||||
memberId: number
|
||||
platformId: string
|
||||
name: string
|
||||
messageCount: number
|
||||
}>
|
||||
|
||||
return rows.map((row) => ({
|
||||
memberId: row.memberId,
|
||||
platformId: row.platformId,
|
||||
name: row.name,
|
||||
messageCount: row.messageCount,
|
||||
percentage: totalMessages > 0 ? Math.round((row.messageCount / totalMessages) * 10000) / 100 : 0,
|
||||
}))
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取每小时活跃度分布
|
||||
*/
|
||||
export function getHourlyActivity(sessionId: string, filter?: TimeFilter): HourlyActivity[] {
|
||||
const db = openDatabase(sessionId)
|
||||
if (!db) return []
|
||||
|
||||
try {
|
||||
const { clause, params } = buildTimeFilter(filter)
|
||||
const clauseWithSystem = buildSystemMessageFilter(clause)
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
CAST(strftime('%H', msg.ts, 'unixepoch', 'localtime') AS INTEGER) as hour,
|
||||
COUNT(*) as messageCount
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
${clauseWithSystem}
|
||||
GROUP BY hour
|
||||
ORDER BY hour
|
||||
`
|
||||
)
|
||||
.all(...params) as Array<{ hour: number; messageCount: number }>
|
||||
|
||||
// 补齐所有24小时
|
||||
const result: HourlyActivity[] = []
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const found = rows.find((r) => r.hour === h)
|
||||
result.push({
|
||||
hour: h,
|
||||
messageCount: found ? found.messageCount : 0,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取每日活跃度趋势
|
||||
*/
|
||||
export function getDailyActivity(sessionId: string, filter?: TimeFilter): DailyActivity[] {
|
||||
const db = openDatabase(sessionId)
|
||||
if (!db) return []
|
||||
|
||||
try {
|
||||
const { clause, params } = buildTimeFilter(filter)
|
||||
const clauseWithSystem = buildSystemMessageFilter(clause)
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime') as date,
|
||||
COUNT(*) as messageCount
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
${clauseWithSystem}
|
||||
GROUP BY date
|
||||
ORDER BY date
|
||||
`
|
||||
)
|
||||
.all(...params) as Array<{ date: string; messageCount: number }>
|
||||
|
||||
return rows
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息类型分布
|
||||
*/
|
||||
export function getMessageTypeDistribution(
|
||||
sessionId: string,
|
||||
filter?: TimeFilter
|
||||
): Array<{ type: MessageType; count: number }> {
|
||||
const db = openDatabase(sessionId)
|
||||
if (!db) return []
|
||||
|
||||
try {
|
||||
const { clause, params } = buildTimeFilter(filter)
|
||||
const clauseWithSystem = buildSystemMessageFilter(clause)
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT msg.type, COUNT(*) as count
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
${clauseWithSystem}
|
||||
GROUP BY msg.type
|
||||
ORDER BY count DESC
|
||||
`
|
||||
)
|
||||
.all(...params) as Array<{ type: number; count: number }>
|
||||
|
||||
return rows.map((r) => ({
|
||||
type: r.type as MessageType,
|
||||
count: r.count,
|
||||
}))
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取时间范围
|
||||
*/
|
||||
export function getTimeRange(sessionId: string): { start: number; end: number } | null {
|
||||
const db = openDatabase(sessionId)
|
||||
if (!db) return null
|
||||
|
||||
try {
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT MIN(ts) as start, MAX(ts) as end FROM message
|
||||
`
|
||||
)
|
||||
.get() as { start: number | null; end: number | null }
|
||||
|
||||
if (row.start === null || row.end === null) return null
|
||||
|
||||
return { start: row.start, end: row.end }
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库存储目录
|
||||
*/
|
||||
export function getDbDirectory(): string {
|
||||
ensureDbDir()
|
||||
return getDbDir()
|
||||
}
|
||||
@@ -63,7 +63,6 @@ class MainProcess {
|
||||
minHeight: 720,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
titleBarStyle: 'hidden',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
@@ -98,15 +97,24 @@ class MainProcess {
|
||||
// 主应用程序事件
|
||||
mainAppEvents() {
|
||||
app.whenReady().then(async () => {
|
||||
console.log('[Main] App is ready')
|
||||
// 设置Windows应用程序用户模型id
|
||||
if (process.platform === 'win32') app.setAppUserModelId(app.getName())
|
||||
|
||||
// 创建主窗口
|
||||
this.createWindow()
|
||||
console.log('[Main] Creating window...')
|
||||
await this.createWindow()
|
||||
console.log('[Main] Window created')
|
||||
|
||||
// 检查更新逻辑
|
||||
checkUpdate(this.mainWindow)
|
||||
|
||||
// 引入主进程ipcMain
|
||||
mainIpcMain(this.mainWindow)
|
||||
if (this.mainWindow) {
|
||||
console.log('[Main] Registering IPC handlers...')
|
||||
mainIpcMain(this.mainWindow)
|
||||
console.log('[Main] IPC handlers registered')
|
||||
}
|
||||
|
||||
// 开发环境下 F12 打开控制台
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
@@ -130,7 +138,7 @@ class MainProcess {
|
||||
if (d.reason == 'crashed') {
|
||||
w.reload()
|
||||
}
|
||||
fs.appendFile(`./error-log-${+new Date()}.txt`, `${new Date()}渲染进程被杀死${d.reason}\n`)
|
||||
// fs.appendFile(`./error-log-${+new Date()}.txt`, `${new Date()}渲染进程被杀死${d.reason}\n`)
|
||||
})
|
||||
|
||||
// 自定义协议
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { ipcMain, app, dialog, clipboard, shell } from 'electron'
|
||||
import { ipcMain, app, dialog, clipboard, shell, BrowserWindow } from 'electron'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import * as fs from 'fs/promises'
|
||||
|
||||
const mainIpcMain = (win) => {
|
||||
// 导入数据库和解析器模块
|
||||
import * as database from './database'
|
||||
import * as parser from './parser'
|
||||
|
||||
console.log('[IpcMain] Database and Parser modules imported')
|
||||
|
||||
const mainIpcMain = (win: BrowserWindow) => {
|
||||
console.log('[IpcMain] Registering IPC handlers...')
|
||||
// ==================== 窗口操作 ====================
|
||||
ipcMain.on('window-min', (ev) => {
|
||||
ev.preventDefault()
|
||||
@@ -105,6 +112,242 @@ const mainIpcMain = (win) => {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== 聊天记录导入与分析 ====================
|
||||
|
||||
/**
|
||||
* 选择聊天记录文件
|
||||
*/
|
||||
ipcMain.handle('chat:selectFile', async () => {
|
||||
try {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
title: '选择聊天记录文件',
|
||||
defaultPath: app.getPath('documents'),
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{ name: '聊天记录', extensions: ['json', 'txt'] },
|
||||
{ name: '所有文件', extensions: ['*'] },
|
||||
],
|
||||
buttonLabel: '导入',
|
||||
})
|
||||
|
||||
if (canceled || filePaths.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const filePath = filePaths[0]
|
||||
console.log('[IpcMain] File selected:', filePath)
|
||||
|
||||
// 检测文件格式
|
||||
const format = parser.detectFormat(filePath)
|
||||
console.log('[IpcMain] Detected format:', format)
|
||||
if (!format) {
|
||||
return { error: '无法识别的文件格式' }
|
||||
}
|
||||
|
||||
return { filePath, format }
|
||||
} catch (error) {
|
||||
console.error('[IpcMain] Error selecting file:', error)
|
||||
return { error: String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 导入聊天记录
|
||||
*/
|
||||
ipcMain.handle('chat:import', async (_, filePath: string) => {
|
||||
console.log('[IpcMain] chat:import called with:', filePath)
|
||||
|
||||
try {
|
||||
// 发送进度:开始解析
|
||||
win.webContents.send('chat:importProgress', {
|
||||
stage: 'parsing',
|
||||
progress: 10,
|
||||
message: '正在解析文件...',
|
||||
})
|
||||
|
||||
console.log('[IpcMain] Parsing file...')
|
||||
// 解析文件
|
||||
const parseResult = parser.parseFile(filePath)
|
||||
console.log('[IpcMain] Parse result:', {
|
||||
memberCount: parseResult.members.length,
|
||||
messageCount: parseResult.messages.length,
|
||||
})
|
||||
|
||||
// 发送进度:开始保存
|
||||
win.webContents.send('chat:importProgress', {
|
||||
stage: 'saving',
|
||||
progress: 50,
|
||||
message: `正在保存 ${parseResult.messages.length} 条消息...`,
|
||||
})
|
||||
|
||||
console.log('[IpcMain] Importing to database...')
|
||||
// 导入到数据库
|
||||
const sessionId = database.importData(parseResult)
|
||||
console.log('[IpcMain] Import successful, sessionId:', sessionId)
|
||||
|
||||
// 发送进度:完成
|
||||
win.webContents.send('chat:importProgress', {
|
||||
stage: 'done',
|
||||
progress: 100,
|
||||
message: '导入完成',
|
||||
})
|
||||
|
||||
return { success: true, sessionId }
|
||||
} catch (error) {
|
||||
console.error('[IpcMain] Import failed:', error)
|
||||
|
||||
win.webContents.send('chat:importProgress', {
|
||||
stage: 'error',
|
||||
progress: 0,
|
||||
message: String(error),
|
||||
})
|
||||
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取所有分析会话列表
|
||||
*/
|
||||
ipcMain.handle('chat:getSessions', async () => {
|
||||
console.log('[IpcMain] chat:getSessions called')
|
||||
try {
|
||||
const sessions = database.getAllSessions()
|
||||
console.log('[IpcMain] Found sessions:', sessions.length)
|
||||
return sessions
|
||||
} catch (error) {
|
||||
console.error('[IpcMain] Error getting sessions:', error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取单个会话信息
|
||||
*/
|
||||
ipcMain.handle('chat:getSession', async (_, sessionId: string) => {
|
||||
try {
|
||||
return database.getSession(sessionId)
|
||||
} catch (error) {
|
||||
console.error('获取会话信息失败:', error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
*/
|
||||
ipcMain.handle('chat:deleteSession', async (_, sessionId: string) => {
|
||||
try {
|
||||
return database.deleteSession(sessionId)
|
||||
} catch (error) {
|
||||
console.error('删除会话失败:', error)
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取可用年份列表
|
||||
*/
|
||||
ipcMain.handle('chat:getAvailableYears', async (_, sessionId: string) => {
|
||||
try {
|
||||
return database.getAvailableYears(sessionId)
|
||||
} catch (error) {
|
||||
console.error('获取可用年份失败:', error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取成员活跃度排行
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'chat:getMemberActivity',
|
||||
async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => {
|
||||
try {
|
||||
return database.getMemberActivity(sessionId, filter)
|
||||
} catch (error) {
|
||||
console.error('获取成员活跃度失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取每小时活跃度分布
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'chat:getHourlyActivity',
|
||||
async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => {
|
||||
try {
|
||||
return database.getHourlyActivity(sessionId, filter)
|
||||
} catch (error) {
|
||||
console.error('获取小时活跃度失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取每日活跃度趋势
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'chat:getDailyActivity',
|
||||
async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => {
|
||||
try {
|
||||
return database.getDailyActivity(sessionId, filter)
|
||||
} catch (error) {
|
||||
console.error('获取日活跃度失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取消息类型分布
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'chat:getMessageTypeDistribution',
|
||||
async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => {
|
||||
try {
|
||||
return database.getMessageTypeDistribution(sessionId, filter)
|
||||
} catch (error) {
|
||||
console.error('获取消息类型分布失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取时间范围
|
||||
*/
|
||||
ipcMain.handle('chat:getTimeRange', async (_, sessionId: string) => {
|
||||
try {
|
||||
return database.getTimeRange(sessionId)
|
||||
} catch (error) {
|
||||
console.error('获取时间范围失败:', error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取数据库存储目录
|
||||
*/
|
||||
ipcMain.handle('chat:getDbDirectory', async () => {
|
||||
try {
|
||||
return database.getDbDirectory()
|
||||
} catch (error) {
|
||||
console.error('获取数据库目录失败:', error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取支持的格式列表
|
||||
*/
|
||||
ipcMain.handle('chat:getSupportedFormats', async () => {
|
||||
return parser.getSupportedFormats()
|
||||
})
|
||||
}
|
||||
|
||||
export default mainIpcMain
|
||||
|
||||
73
electron/main/parser/index.ts
Normal file
73
electron/main/parser/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Parser 模块入口
|
||||
* 自动检测文件格式并使用对应的解析器
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import type { ChatParser } from './types'
|
||||
import { qqJsonParser } from './qqJsonParser'
|
||||
import { qqTxtParser } from './qqTxtParser'
|
||||
import type { ParseResult } from '../../../src/types/chat'
|
||||
|
||||
// 注册所有解析器(按优先级排序)
|
||||
const parsers: ChatParser[] = [
|
||||
qqJsonParser, // JSON 格式优先
|
||||
qqTxtParser // TXT 格式兜底
|
||||
]
|
||||
|
||||
/**
|
||||
* 自动检测文件格式并解析
|
||||
* @param filePath 文件路径
|
||||
* @returns 解析结果
|
||||
*/
|
||||
export function parseFile(filePath: string): ParseResult {
|
||||
// 读取文件内容
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
const filename = filePath.split(/[/\\]/).pop() || ''
|
||||
|
||||
// 尝试每个解析器
|
||||
for (const parser of parsers) {
|
||||
if (parser.detect(content, filename)) {
|
||||
console.log(`使用解析器: ${parser.name}`)
|
||||
return parser.parse(content, filename)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`无法识别文件格式: ${filename}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测文件格式
|
||||
* @param filePath 文件路径
|
||||
* @returns 解析器名称,如果无法识别则返回 null
|
||||
*/
|
||||
export function detectFormat(filePath: string): string | null {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
const filename = filePath.split(/[/\\]/).pop() || ''
|
||||
|
||||
for (const parser of parsers) {
|
||||
if (parser.detect(content, filename)) {
|
||||
return parser.name
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 文件读取失败
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支持的格式列表
|
||||
*/
|
||||
export function getSupportedFormats(): Array<{ name: string; platform: string }> {
|
||||
return parsers.map((p) => ({
|
||||
name: p.name,
|
||||
platform: p.platform
|
||||
}))
|
||||
}
|
||||
|
||||
// 导出类型
|
||||
export type { ChatParser, ParseError } from './types'
|
||||
|
||||
207
electron/main/parser/qqJsonParser.ts
Normal file
207
electron/main/parser/qqJsonParser.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* QQ Chat Exporter V4 JSON 格式解析器
|
||||
* 支持 https://github.com/shuakami/qq-chat-exporter 导出的 JSON 格式
|
||||
*/
|
||||
|
||||
import type { ChatParser, ParseError } from './types'
|
||||
import {
|
||||
ChatPlatform,
|
||||
ChatType,
|
||||
MessageType,
|
||||
type ParseResult,
|
||||
type ParsedMember,
|
||||
type ParsedMessage
|
||||
} from '../../../src/types/chat'
|
||||
|
||||
/**
|
||||
* QQ JSON 导出格式的消息结构
|
||||
*/
|
||||
interface QQJsonMessage {
|
||||
id: string
|
||||
seq?: string
|
||||
timestamp: number
|
||||
time?: string
|
||||
sender: {
|
||||
uid?: string
|
||||
uin: string
|
||||
name: string
|
||||
}
|
||||
type: string
|
||||
content: {
|
||||
text: string
|
||||
html?: string
|
||||
elements?: Array<{
|
||||
type: string
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
resources?: Array<{
|
||||
type: string
|
||||
filename?: string
|
||||
size?: number
|
||||
url?: string
|
||||
}>
|
||||
}
|
||||
recalled?: boolean
|
||||
system?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* QQ JSON 导出格式的根结构
|
||||
*/
|
||||
interface QQJsonExport {
|
||||
metadata?: {
|
||||
name?: string
|
||||
version?: string
|
||||
}
|
||||
chatInfo: {
|
||||
name: string
|
||||
type: 'private' | 'group'
|
||||
}
|
||||
statistics?: {
|
||||
totalMessages?: number
|
||||
senders?: Array<{
|
||||
uid?: string
|
||||
name: string
|
||||
messageCount: number
|
||||
}>
|
||||
}
|
||||
messages: QQJsonMessage[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 QQ 消息类型转换为统一类型
|
||||
*/
|
||||
function convertMessageType(qqType: string, content: QQJsonMessage['content']): MessageType {
|
||||
// 检查是否有资源(图片等)
|
||||
if (content.resources && content.resources.length > 0) {
|
||||
const resourceType = content.resources[0].type
|
||||
switch (resourceType) {
|
||||
case 'image':
|
||||
return MessageType.IMAGE
|
||||
case 'video':
|
||||
return MessageType.VIDEO
|
||||
case 'voice':
|
||||
case 'audio':
|
||||
return MessageType.VOICE
|
||||
case 'file':
|
||||
return MessageType.FILE
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 elements 中是否有特殊类型
|
||||
if (content.elements) {
|
||||
for (const elem of content.elements) {
|
||||
if (elem.type === 'market_face' || elem.type === 'face') {
|
||||
return MessageType.EMOJI
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据 QQ 原始类型判断
|
||||
switch (qqType) {
|
||||
case 'type_1':
|
||||
return MessageType.TEXT
|
||||
case 'type_17': // 表情包
|
||||
return MessageType.EMOJI
|
||||
case 'type_3': // 图片
|
||||
return MessageType.IMAGE
|
||||
case 'type_7': // 语音
|
||||
return MessageType.VOICE
|
||||
default:
|
||||
return MessageType.TEXT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* QQ JSON 格式解析器
|
||||
*/
|
||||
export const qqJsonParser: ChatParser = {
|
||||
name: 'QQ Chat Exporter JSON',
|
||||
platform: 'qq',
|
||||
|
||||
detect(content: string, filename: string): boolean {
|
||||
// 检查文件扩展名
|
||||
if (!filename.toLowerCase().endsWith('.json')) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(content)
|
||||
// 检查是否有 QQ Chat Exporter 的特征
|
||||
return (
|
||||
data.chatInfo &&
|
||||
typeof data.chatInfo.name === 'string' &&
|
||||
Array.isArray(data.messages) &&
|
||||
(data.metadata?.name?.includes('QQChatExporter') ||
|
||||
// 兼容没有 metadata 的情况,检查消息结构
|
||||
(data.messages.length > 0 && data.messages[0].sender?.uin !== undefined))
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
parse(content: string, _filename: string): ParseResult {
|
||||
let data: QQJsonExport
|
||||
try {
|
||||
data = JSON.parse(content)
|
||||
} catch (e) {
|
||||
throw new Error(`JSON 解析失败: ${e}`) as ParseError
|
||||
}
|
||||
|
||||
if (!data.chatInfo || !Array.isArray(data.messages)) {
|
||||
throw new Error('无效的 QQ JSON 格式:缺少 chatInfo 或 messages') as ParseError
|
||||
}
|
||||
|
||||
// 解析元信息
|
||||
const meta = {
|
||||
name: data.chatInfo.name,
|
||||
platform: ChatPlatform.QQ,
|
||||
type: data.chatInfo.type === 'group' ? ChatType.GROUP : ChatType.PRIVATE
|
||||
}
|
||||
|
||||
// 收集成员信息(使用 Map 去重,保留最新昵称)
|
||||
const memberMap = new Map<string, ParsedMember>()
|
||||
|
||||
// 解析消息
|
||||
const messages: ParsedMessage[] = []
|
||||
|
||||
for (const msg of data.messages) {
|
||||
const platformId = msg.sender.uin
|
||||
|
||||
// 更新成员信息(保留最新昵称)
|
||||
memberMap.set(platformId, {
|
||||
platformId,
|
||||
name: msg.sender.name || platformId
|
||||
})
|
||||
|
||||
// 转换时间戳(QQ 导出是毫秒,需要转为秒)
|
||||
const timestamp = Math.floor(msg.timestamp / 1000)
|
||||
|
||||
// 确定消息类型
|
||||
const type = msg.system ? MessageType.SYSTEM : convertMessageType(msg.type, msg.content)
|
||||
|
||||
// 提取文本内容
|
||||
let textContent = msg.content.text || ''
|
||||
|
||||
// 如果是撤回的消息,添加标记
|
||||
if (msg.recalled) {
|
||||
textContent = '[已撤回] ' + textContent
|
||||
}
|
||||
|
||||
messages.push({
|
||||
senderPlatformId: platformId,
|
||||
timestamp,
|
||||
type,
|
||||
content: textContent || null
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
meta,
|
||||
members: Array.from(memberMap.values()),
|
||||
messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
208
electron/main/parser/qqTxtParser.ts
Normal file
208
electron/main/parser/qqTxtParser.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* QQ 原生导出 TXT 格式解析器
|
||||
* 支持 QQ 客户端导出的文本格式聊天记录
|
||||
*/
|
||||
|
||||
import type { ChatParser } from './types'
|
||||
import {
|
||||
ChatPlatform,
|
||||
ChatType,
|
||||
MessageType,
|
||||
type ParseResult,
|
||||
type ParsedMember,
|
||||
type ParsedMessage
|
||||
} from '../../../src/types/chat'
|
||||
|
||||
/**
|
||||
* 消息行正则表达式
|
||||
* 格式: 2017-02-25 10:40:20 昵称(QQ号)
|
||||
* 或: 2017-02-25 10:40:20 (QQ号)
|
||||
* 或: 2017-02-25 10:40:20 【管理员】昵称(QQ号)
|
||||
* 或: 2017-02-25 10:40:20 昵称<邮箱>
|
||||
*/
|
||||
const MESSAGE_HEADER_REGEX =
|
||||
/^(\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}:\d{2})\s+(?:【[^】]+】)?(.+?)(?:\((\d+)\)|<([^>]+)>)\s*$/
|
||||
|
||||
/**
|
||||
* 群名提取正则
|
||||
* 格式: 消息对象:群名
|
||||
*/
|
||||
const GROUP_NAME_REGEX = /^消息对象[::](.+)$/
|
||||
|
||||
/**
|
||||
* 检测消息类型
|
||||
*/
|
||||
function detectMessageType(content: string): MessageType {
|
||||
const trimmed = content.trim()
|
||||
|
||||
// 图片
|
||||
if (trimmed === '[图片]' || trimmed.startsWith('[图片]')) {
|
||||
return MessageType.IMAGE
|
||||
}
|
||||
|
||||
// 表情
|
||||
if (/^\[.+\]$/.test(trimmed) || /^\[\[.+\]\]$/.test(trimmed)) {
|
||||
return MessageType.EMOJI
|
||||
}
|
||||
|
||||
// 语音
|
||||
if (trimmed === '[语音]' || trimmed.startsWith('[语音]')) {
|
||||
return MessageType.VOICE
|
||||
}
|
||||
|
||||
// 视频
|
||||
if (trimmed === '[视频]' || trimmed.startsWith('[视频]')) {
|
||||
return MessageType.VIDEO
|
||||
}
|
||||
|
||||
// 文件
|
||||
if (trimmed === '[文件]' || trimmed.startsWith('[文件]')) {
|
||||
return MessageType.FILE
|
||||
}
|
||||
|
||||
// 系统消息
|
||||
if (
|
||||
trimmed.includes('加入了群聊') ||
|
||||
trimmed.includes('退出了群聊') ||
|
||||
trimmed.includes('撤回了一条消息') ||
|
||||
trimmed.includes('被管理员') ||
|
||||
trimmed.includes('成为管理员')
|
||||
) {
|
||||
return MessageType.SYSTEM
|
||||
}
|
||||
|
||||
return MessageType.TEXT
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析日期时间字符串为时间戳(秒)
|
||||
*/
|
||||
function parseDateTime(dateTimeStr: string): number {
|
||||
// 格式: 2017-02-25 10:40:20
|
||||
const [datePart, timePart] = dateTimeStr.split(/\s+/)
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
const [hour, minute, second] = timePart.split(':').map(Number)
|
||||
|
||||
const date = new Date(year, month - 1, day, hour, minute, second)
|
||||
return Math.floor(date.getTime() / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* QQ TXT 格式解析器
|
||||
*/
|
||||
export const qqTxtParser: ChatParser = {
|
||||
name: 'QQ Native TXT Export',
|
||||
platform: 'qq',
|
||||
|
||||
detect(content: string, filename: string): boolean {
|
||||
// 检查文件扩展名
|
||||
if (!filename.toLowerCase().endsWith('.txt')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查文件头特征
|
||||
const lines = content.split('\n').slice(0, 20)
|
||||
const hasHeader = lines.some(
|
||||
(line) =>
|
||||
line.includes('消息记录') ||
|
||||
line.includes('消息分组') ||
|
||||
line.includes('消息对象') ||
|
||||
line.includes('================================================================')
|
||||
)
|
||||
|
||||
// 检查是否有符合格式的消息行
|
||||
const hasMessagePattern = lines.some((line) => MESSAGE_HEADER_REGEX.test(line.trim()))
|
||||
|
||||
return hasHeader || hasMessagePattern
|
||||
},
|
||||
|
||||
parse(content: string, _filename: string): ParseResult {
|
||||
const lines = content.split('\n')
|
||||
|
||||
// 提取群名
|
||||
let groupName = '未知对话'
|
||||
for (const line of lines.slice(0, 20)) {
|
||||
const match = line.trim().match(GROUP_NAME_REGEX)
|
||||
if (match) {
|
||||
groupName = match[1].trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 收集成员信息
|
||||
const memberMap = new Map<string, ParsedMember>()
|
||||
|
||||
// 解析消息
|
||||
const messages: ParsedMessage[] = []
|
||||
|
||||
let currentSender: { platformId: string; name: string } | null = null
|
||||
let currentTimestamp: number = 0
|
||||
let currentContent: string[] = []
|
||||
|
||||
// 处理当前累积的消息
|
||||
const flushMessage = (): void => {
|
||||
if (currentSender && currentContent.length > 0) {
|
||||
const content = currentContent.join('\n').trim()
|
||||
if (content) {
|
||||
messages.push({
|
||||
senderPlatformId: currentSender.platformId,
|
||||
timestamp: currentTimestamp,
|
||||
type: detectMessageType(content),
|
||||
content
|
||||
})
|
||||
}
|
||||
}
|
||||
currentContent = []
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim()
|
||||
|
||||
// 跳过空行和分隔线
|
||||
if (!trimmedLine || trimmedLine.startsWith('===') || trimmedLine.startsWith('消息')) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 尝试匹配消息头
|
||||
const headerMatch = trimmedLine.match(MESSAGE_HEADER_REGEX)
|
||||
|
||||
if (headerMatch) {
|
||||
// 先保存之前的消息
|
||||
flushMessage()
|
||||
|
||||
const dateTimeStr = headerMatch[1]
|
||||
const nameOrEmpty = headerMatch[2].trim()
|
||||
const qqNumber = headerMatch[3] || headerMatch[4] // QQ号或邮箱
|
||||
|
||||
// 处理只有QQ号没有昵称的情况
|
||||
const name = nameOrEmpty || qqNumber
|
||||
|
||||
// 更新成员信息(保留最新昵称)
|
||||
memberMap.set(qqNumber, {
|
||||
platformId: qqNumber,
|
||||
name
|
||||
})
|
||||
|
||||
currentSender = { platformId: qqNumber, name }
|
||||
currentTimestamp = parseDateTime(dateTimeStr)
|
||||
} else {
|
||||
// 这是消息内容行
|
||||
currentContent.push(trimmedLine)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理最后一条消息
|
||||
flushMessage()
|
||||
|
||||
return {
|
||||
meta: {
|
||||
name: groupName,
|
||||
platform: ChatPlatform.QQ,
|
||||
type: ChatType.GROUP // TXT 导出通常是群聊
|
||||
},
|
||||
members: Array.from(memberMap.values()),
|
||||
messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
electron/main/parser/types.ts
Normal file
44
electron/main/parser/types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Parser 接口定义
|
||||
*/
|
||||
|
||||
import type { ParseResult } from '../../../src/types/chat'
|
||||
|
||||
/**
|
||||
* 聊天记录解析器接口
|
||||
*/
|
||||
export interface ChatParser {
|
||||
/** 解析器名称 */
|
||||
name: string
|
||||
|
||||
/** 支持的平台 */
|
||||
platform: string
|
||||
|
||||
/**
|
||||
* 检测文件内容是否匹配该解析器
|
||||
* @param content 文件内容
|
||||
* @param filename 文件名
|
||||
*/
|
||||
detect(content: string, filename: string): boolean
|
||||
|
||||
/**
|
||||
* 解析文件内容
|
||||
* @param content 文件内容
|
||||
* @param filename 文件名
|
||||
*/
|
||||
parse(content: string, filename: string): ParseResult
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析错误
|
||||
*/
|
||||
export class ParseError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly parserName: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ParseError'
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user