mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-28 15:09:19 +00:00
Compare commits
5 Commits
nightly-pr
...
v4.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
440c1f166a | ||
|
|
7469337aeb | ||
|
|
338d0e2f20 | ||
|
|
a86a51c30c | ||
|
|
043332d297 |
@@ -1,14 +1,18 @@
|
|||||||
import { parentPort, workerData } from 'worker_threads'
|
import { parentPort, workerData } from 'worker_threads'
|
||||||
import type { ExportOptions } from './services/exportService'
|
|
||||||
|
|
||||||
interface ExportWorkerConfig {
|
interface ExportWorkerConfig {
|
||||||
sessionIds: string[]
|
mode?: 'sessions' | 'single' | 'contacts'
|
||||||
outputDir: string
|
sessionIds?: string[]
|
||||||
options: ExportOptions
|
sessionId?: string
|
||||||
|
outputDir?: string
|
||||||
|
outputPath?: string
|
||||||
|
options?: any
|
||||||
taskId?: string
|
taskId?: string
|
||||||
dbPath?: string
|
dbPath?: string
|
||||||
decryptKey?: string
|
decryptKey?: string
|
||||||
myWxid?: string
|
myWxid?: string
|
||||||
|
imageXorKey?: unknown
|
||||||
|
imageAesKey?: string
|
||||||
resourcesPath?: string
|
resourcesPath?: string
|
||||||
userDataPath?: string
|
userDataPath?: string
|
||||||
logEnabled?: boolean
|
logEnabled?: boolean
|
||||||
@@ -20,6 +24,93 @@ const controlState = {
|
|||||||
stopRequested: false
|
stopRequested: false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CREATED_PATH_FLUSH_INTERVAL_MS = 200
|
||||||
|
const CREATED_PATH_BATCH_LIMIT = 256
|
||||||
|
const PROGRESS_POST_INTERVAL_MS = 180
|
||||||
|
let queuedCreatedFiles: string[] = []
|
||||||
|
let queuedCreatedDirs: string[] = []
|
||||||
|
let createdPathFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let pendingProgress: any = null
|
||||||
|
let progressPostTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let lastProgressPostedAt = 0
|
||||||
|
|
||||||
|
function flushCreatedPaths() {
|
||||||
|
if (createdPathFlushTimer) {
|
||||||
|
clearTimeout(createdPathFlushTimer)
|
||||||
|
createdPathFlushTimer = null
|
||||||
|
}
|
||||||
|
const filePaths = queuedCreatedFiles
|
||||||
|
const dirPaths = queuedCreatedDirs
|
||||||
|
queuedCreatedFiles = []
|
||||||
|
queuedCreatedDirs = []
|
||||||
|
if (!parentPort) return
|
||||||
|
if (filePaths.length > 0) {
|
||||||
|
parentPort.postMessage({ type: 'export:createdFiles', filePaths })
|
||||||
|
}
|
||||||
|
if (dirPaths.length > 0) {
|
||||||
|
parentPort.postMessage({ type: 'export:createdDirs', dirPaths })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleCreatedPathFlush() {
|
||||||
|
if (createdPathFlushTimer) return
|
||||||
|
createdPathFlushTimer = setTimeout(flushCreatedPaths, CREATED_PATH_FLUSH_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueCreatedFile(filePath: string) {
|
||||||
|
const normalized = String(filePath || '').trim()
|
||||||
|
if (!normalized) return
|
||||||
|
queuedCreatedFiles.push(normalized)
|
||||||
|
if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) {
|
||||||
|
flushCreatedPaths()
|
||||||
|
} else {
|
||||||
|
scheduleCreatedPathFlush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueCreatedDir(dirPath: string) {
|
||||||
|
const normalized = String(dirPath || '').trim()
|
||||||
|
if (!normalized) return
|
||||||
|
queuedCreatedDirs.push(normalized)
|
||||||
|
if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) {
|
||||||
|
flushCreatedPaths()
|
||||||
|
} else {
|
||||||
|
scheduleCreatedPathFlush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushProgress() {
|
||||||
|
if (!pendingProgress) return
|
||||||
|
if (progressPostTimer) {
|
||||||
|
clearTimeout(progressPostTimer)
|
||||||
|
progressPostTimer = null
|
||||||
|
}
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'export:progress',
|
||||||
|
data: pendingProgress
|
||||||
|
})
|
||||||
|
pendingProgress = null
|
||||||
|
lastProgressPostedAt = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueProgress(progress: any) {
|
||||||
|
pendingProgress = progress
|
||||||
|
if (progress?.phase === 'complete') {
|
||||||
|
flushProgress()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const elapsed = now - lastProgressPostedAt
|
||||||
|
if (elapsed >= PROGRESS_POST_INTERVAL_MS) {
|
||||||
|
flushProgress()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressPostTimer) return
|
||||||
|
progressPostTimer = setTimeout(flushProgress, PROGRESS_POST_INTERVAL_MS - elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
parentPort?.on('message', (message: any) => {
|
parentPort?.on('message', (message: any) => {
|
||||||
if (!message || typeof message.type !== 'string') return
|
if (!message || typeof message.type !== 'string') return
|
||||||
if (message.type === 'export:pause') {
|
if (message.type === 'export:pause') {
|
||||||
@@ -57,32 +148,49 @@ async function run() {
|
|||||||
exportService.setRuntimeConfig({
|
exportService.setRuntimeConfig({
|
||||||
dbPath: config.dbPath,
|
dbPath: config.dbPath,
|
||||||
decryptKey: config.decryptKey,
|
decryptKey: config.decryptKey,
|
||||||
myWxid: config.myWxid
|
myWxid: config.myWxid,
|
||||||
|
imageXorKey: config.imageXorKey,
|
||||||
|
imageAesKey: config.imageAesKey
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await exportService.exportSessions(
|
const onProgress = (progress: any) => queueProgress(progress)
|
||||||
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
|
||||||
String(config.outputDir || ''),
|
const taskControl = config.taskId
|
||||||
config.options || { format: 'json' },
|
|
||||||
(progress) => {
|
|
||||||
parentPort?.postMessage({
|
|
||||||
type: 'export:progress',
|
|
||||||
data: progress
|
|
||||||
})
|
|
||||||
},
|
|
||||||
config.taskId
|
|
||||||
? {
|
? {
|
||||||
shouldPause: () => controlState.pauseRequested,
|
shouldPause: () => controlState.pauseRequested,
|
||||||
shouldStop: () => controlState.stopRequested,
|
shouldStop: () => controlState.stopRequested,
|
||||||
recordCreatedFile: (filePath: string) => {
|
recordCreatedFile: queueCreatedFile,
|
||||||
parentPort?.postMessage({ type: 'export:createdFile', filePath })
|
recordCreatedDir: queueCreatedDir
|
||||||
},
|
|
||||||
recordCreatedDir: (dirPath: string) => {
|
|
||||||
parentPort?.postMessage({ type: 'export:createdDir', dirPath })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
let result: any
|
||||||
|
if (config.mode === 'contacts') {
|
||||||
|
const { contactExportService } = await import('./services/contactExportService')
|
||||||
|
result = await contactExportService.exportContacts(
|
||||||
|
String(config.outputDir || ''),
|
||||||
|
config.options || {}
|
||||||
)
|
)
|
||||||
|
} else if (config.mode === 'single') {
|
||||||
|
result = await exportService.exportSessionToChatLab(
|
||||||
|
String(config.sessionId || '').trim(),
|
||||||
|
String(config.outputPath || '').trim(),
|
||||||
|
config.options || { format: 'chatlab' },
|
||||||
|
onProgress,
|
||||||
|
taskControl
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
result = await exportService.exportSessions(
|
||||||
|
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
||||||
|
String(config.outputDir || ''),
|
||||||
|
config.options || { format: 'json' },
|
||||||
|
onProgress,
|
||||||
|
taskControl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
flushProgress()
|
||||||
|
flushCreatedPaths()
|
||||||
|
|
||||||
parentPort?.postMessage({
|
parentPort?.postMessage({
|
||||||
type: 'export:result',
|
type: 'export:result',
|
||||||
@@ -91,6 +199,8 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
run().catch((error) => {
|
run().catch((error) => {
|
||||||
|
flushProgress()
|
||||||
|
flushCreatedPaths()
|
||||||
parentPort?.postMessage({
|
parentPort?.postMessage({
|
||||||
type: 'export:error',
|
type: 'export:error',
|
||||||
error: String(error)
|
error: String(error)
|
||||||
|
|||||||
171
electron/main.ts
171
electron/main.ts
@@ -23,7 +23,6 @@ import { KeyServiceMac } from './services/keyServiceMac'
|
|||||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||||
import { videoService } from './services/videoService'
|
import { videoService } from './services/videoService'
|
||||||
import { snsService, isVideoUrl } from './services/snsService'
|
import { snsService, isVideoUrl } from './services/snsService'
|
||||||
import { contactExportService } from './services/contactExportService'
|
|
||||||
import { windowsHelloService } from './services/windowsHelloService'
|
import { windowsHelloService } from './services/windowsHelloService'
|
||||||
import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService'
|
import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService'
|
||||||
import { cloudControlService } from './services/cloudControlService'
|
import { cloudControlService } from './services/cloudControlService'
|
||||||
@@ -3046,7 +3045,7 @@ function registerIpcHandlers() {
|
|||||||
|
|
||||||
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions, controlOptions?: { taskId?: string }) => {
|
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions, controlOptions?: { taskId?: string }) => {
|
||||||
const taskId = normalizeExportTaskId(controlOptions?.taskId)
|
const taskId = normalizeExportTaskId(controlOptions?.taskId)
|
||||||
const taskControl = taskId ? exportTaskControlService.createControl(taskId, outputDir) : undefined
|
if (taskId) exportTaskControlService.createControl(taskId, outputDir)
|
||||||
if (taskId) activeExportTasks.add(taskId)
|
if (taskId) activeExportTasks.add(taskId)
|
||||||
const PROGRESS_FORWARD_INTERVAL_MS = 180
|
const PROGRESS_FORWARD_INTERVAL_MS = 180
|
||||||
let pendingProgress: ExportProgress | null = null
|
let pendingProgress: ExportProgress | null = null
|
||||||
@@ -3091,17 +3090,13 @@ function registerIpcHandlers() {
|
|||||||
queueProgress(progress)
|
queueProgress(progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
const runMainFallback = async (reason: string) => {
|
|
||||||
console.warn(`[fallback-export-main] ${reason}`)
|
|
||||||
return exportService.exportSessions(sessionIds, outputDir, options, onProgress, taskControl)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cfg = configService || new ConfigService()
|
const cfg = configService || new ConfigService()
|
||||||
configService = cfg
|
configService = cfg
|
||||||
const logEnabled = cfg.get('logEnabled')
|
const logEnabled = cfg.get('logEnabled')
|
||||||
const dbPath = String(cfg.get('dbPath') || '').trim()
|
const dbPath = String(cfg.get('dbPath') || '').trim()
|
||||||
const decryptKey = String(cfg.get('decryptKey') || '').trim()
|
const decryptKey = String(cfg.get('decryptKey') || '').trim()
|
||||||
const myWxid = String(cfg.get('myWxid') || '').trim()
|
const myWxid = String(cfg.get('myWxid') || '').trim()
|
||||||
|
const imageKeys = cfg.getImageKeysForCurrentWxid()
|
||||||
const resourcesPath = app.isPackaged
|
const resourcesPath = app.isPackaged
|
||||||
? join(process.resourcesPath, 'resources')
|
? join(process.resourcesPath, 'resources')
|
||||||
: join(app.getAppPath(), 'resources')
|
: join(app.getAppPath(), 'resources')
|
||||||
@@ -3119,6 +3114,8 @@ function registerIpcHandlers() {
|
|||||||
dbPath,
|
dbPath,
|
||||||
decryptKey,
|
decryptKey,
|
||||||
myWxid,
|
myWxid,
|
||||||
|
imageXorKey: imageKeys.xorKey,
|
||||||
|
imageAesKey: imageKeys.aesKey,
|
||||||
resourcesPath,
|
resourcesPath,
|
||||||
userDataPath,
|
userDataPath,
|
||||||
logEnabled
|
logEnabled
|
||||||
@@ -3155,6 +3152,20 @@ function registerIpcHandlers() {
|
|||||||
onProgress(msg.data as ExportProgress)
|
onProgress(msg.data as ExportProgress)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (msg && msg.type === 'export:createdFiles' && taskId) {
|
||||||
|
const filePaths = Array.isArray(msg.filePaths) ? msg.filePaths : []
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
exportTaskControlService.recordCreatedFile(taskId, String(filePath || ''))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg && msg.type === 'export:createdDirs' && taskId) {
|
||||||
|
const dirPaths = Array.isArray(msg.dirPaths) ? msg.dirPaths : []
|
||||||
|
for (const dirPath of dirPaths) {
|
||||||
|
exportTaskControlService.recordCreatedDir(taskId, String(dirPath || ''))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (msg && msg.type === 'export:createdFile' && taskId) {
|
if (msg && msg.type === 'export:createdFile' && taskId) {
|
||||||
exportTaskControlService.recordCreatedFile(taskId, String(msg.filePath || ''))
|
exportTaskControlService.recordCreatedFile(taskId, String(msg.filePath || ''))
|
||||||
return
|
return
|
||||||
@@ -3191,7 +3202,21 @@ function registerIpcHandlers() {
|
|||||||
const result = await runWorker()
|
const result = await runWorker()
|
||||||
return await finalizeExportTaskControlResult(taskId, result)
|
return await finalizeExportTaskControlResult(taskId, result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const result = await runMainFallback(error instanceof Error ? error.message : String(error))
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
console.error(`[export-worker] ${errorMessage}`)
|
||||||
|
const normalizedSessionIds = Array.isArray(sessionIds) ? sessionIds : []
|
||||||
|
const failedSessionErrors: Record<string, string> = {}
|
||||||
|
for (const sessionId of normalizedSessionIds) {
|
||||||
|
failedSessionErrors[sessionId] = errorMessage
|
||||||
|
}
|
||||||
|
const result = {
|
||||||
|
success: false,
|
||||||
|
successCount: 0,
|
||||||
|
failCount: normalizedSessionIds.length,
|
||||||
|
failedSessionIds: normalizedSessionIds,
|
||||||
|
failedSessionErrors,
|
||||||
|
error: `导出 Worker 执行失败: ${errorMessage}`
|
||||||
|
}
|
||||||
return await finalizeExportTaskControlResult(taskId, result)
|
return await finalizeExportTaskControlResult(taskId, result)
|
||||||
} finally {
|
} finally {
|
||||||
if (taskId) activeExportTasks.delete(taskId)
|
if (taskId) activeExportTasks.delete(taskId)
|
||||||
@@ -3203,12 +3228,136 @@ function registerIpcHandlers() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
|
ipcMain.handle('export:exportSession', async (event, sessionId: string, outputPath: string, options: ExportOptions) => {
|
||||||
return exportService.exportSessionToChatLab(sessionId, outputPath, options)
|
const cfg = configService || new ConfigService()
|
||||||
|
configService = cfg
|
||||||
|
const imageKeys = cfg.getImageKeysForCurrentWxid()
|
||||||
|
const workerPath = join(__dirname, 'exportWorker.js')
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await new Promise<any>((resolve) => {
|
||||||
|
const worker = new Worker(workerPath, {
|
||||||
|
workerData: {
|
||||||
|
mode: 'single',
|
||||||
|
sessionId,
|
||||||
|
outputPath,
|
||||||
|
options,
|
||||||
|
dbPath: String(cfg.get('dbPath') || '').trim(),
|
||||||
|
decryptKey: String(cfg.get('decryptKey') || '').trim(),
|
||||||
|
myWxid: String(cfg.get('myWxid') || '').trim(),
|
||||||
|
imageXorKey: imageKeys.xorKey,
|
||||||
|
imageAesKey: imageKeys.aesKey,
|
||||||
|
resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'),
|
||||||
|
userDataPath: app.getPath('userData'),
|
||||||
|
logEnabled: cfg.get('logEnabled')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let settled = false
|
||||||
|
const finalize = (value: any) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
worker.removeAllListeners()
|
||||||
|
void worker.terminate()
|
||||||
|
resolve(value)
|
||||||
|
}
|
||||||
|
const fail = (error: unknown) => {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
console.error(`[export-worker-single] ${errorMessage}`)
|
||||||
|
finalize({ success: false, error: `导出 Worker 执行失败: ${errorMessage}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.on('message', (msg: any) => {
|
||||||
|
if (msg && msg.type === 'export:progress') {
|
||||||
|
if (!event.sender.isDestroyed()) {
|
||||||
|
event.sender.send('export:progress', msg.data)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg && msg.type === 'export:result') {
|
||||||
|
finalize(msg.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg && msg.type === 'export:error') {
|
||||||
|
fail(String(msg.error || '导出 Worker 执行失败'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
worker.on('error', fail)
|
||||||
|
worker.on('exit', (code) => {
|
||||||
|
if (settled) return
|
||||||
|
if (code === 0) {
|
||||||
|
finalize({ success: false, error: '导出 Worker 未返回结果' })
|
||||||
|
} else {
|
||||||
|
fail(`导出 Worker 异常退出: ${code}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
console.error(`[export-worker-single] ${errorMessage}`)
|
||||||
|
return { success: false, error: `导出 Worker 启动失败: ${errorMessage}` }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('export:exportContacts', async (_, outputDir: string, options: any) => {
|
ipcMain.handle('export:exportContacts', async (_, outputDir: string, options: any) => {
|
||||||
return contactExportService.exportContacts(outputDir, options)
|
const cfg = configService || new ConfigService()
|
||||||
|
configService = cfg
|
||||||
|
const workerPath = join(__dirname, 'exportWorker.js')
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await new Promise<any>((resolve) => {
|
||||||
|
const worker = new Worker(workerPath, {
|
||||||
|
workerData: {
|
||||||
|
mode: 'contacts',
|
||||||
|
outputDir,
|
||||||
|
options,
|
||||||
|
dbPath: String(cfg.get('dbPath') || '').trim(),
|
||||||
|
decryptKey: String(cfg.get('decryptKey') || '').trim(),
|
||||||
|
myWxid: String(cfg.get('myWxid') || '').trim(),
|
||||||
|
resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'),
|
||||||
|
userDataPath: app.getPath('userData'),
|
||||||
|
logEnabled: cfg.get('logEnabled')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let settled = false
|
||||||
|
const finalize = (value: any) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
worker.removeAllListeners()
|
||||||
|
void worker.terminate()
|
||||||
|
resolve(value)
|
||||||
|
}
|
||||||
|
const fail = (error: unknown) => {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
console.error(`[export-worker-contacts] ${errorMessage}`)
|
||||||
|
finalize({ success: false, error: `导出 Worker 执行失败: ${errorMessage}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.on('message', (msg: any) => {
|
||||||
|
if (msg && msg.type === 'export:result') {
|
||||||
|
finalize(msg.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg && msg.type === 'export:error') {
|
||||||
|
fail(String(msg.error || '导出 Worker 执行失败'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
worker.on('error', fail)
|
||||||
|
worker.on('exit', (code) => {
|
||||||
|
if (settled) return
|
||||||
|
if (code === 0) {
|
||||||
|
finalize({ success: false, error: '导出 Worker 未返回结果' })
|
||||||
|
} else {
|
||||||
|
fail(`导出 Worker 异常退出: ${code}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
console.error(`[export-worker-contacts] ${errorMessage}`)
|
||||||
|
return { success: false, error: `导出 Worker 启动失败: ${errorMessage}` }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 数据分析相关
|
// 数据分析相关
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import * as https from 'https'
|
|||||||
import * as http from 'http'
|
import * as http from 'http'
|
||||||
import * as fzstd from 'fzstd'
|
import * as fzstd from 'fzstd'
|
||||||
import * as crypto from 'crypto'
|
import * as crypto from 'crypto'
|
||||||
import { app, BrowserWindow, dialog } from 'electron'
|
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { MessageCacheService } from './messageCacheService'
|
import { MessageCacheService } from './messageCacheService'
|
||||||
@@ -18,6 +17,7 @@ import { voiceTranscribeService } from './voiceTranscribeService'
|
|||||||
import { ImageDecryptService } from './imageDecryptService'
|
import { ImageDecryptService } from './imageDecryptService'
|
||||||
import { CONTACT_REGION_LOOKUP_DATA } from './contactRegionLookupData'
|
import { CONTACT_REGION_LOOKUP_DATA } from './contactRegionLookupData'
|
||||||
import { LRUCache } from '../utils/LRUCache.js'
|
import { LRUCache } from '../utils/LRUCache.js'
|
||||||
|
import { getAppPathFallback, getElectronBrowserWindow, getElectronDialog, getPathFallback, isElectronAppPackaged } from './electronRuntime'
|
||||||
|
|
||||||
export interface ChatSession {
|
export interface ChatSession {
|
||||||
username: string
|
username: string
|
||||||
@@ -498,7 +498,7 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async maybeShowInitFailureDialog(errorMessage: string): Promise<void> {
|
private async maybeShowInitFailureDialog(errorMessage: string): Promise<void> {
|
||||||
if (!app.isPackaged) return
|
if (!isElectronAppPackaged()) return
|
||||||
if (this.initFailureDialogShown) return
|
if (this.initFailureDialogShown) return
|
||||||
|
|
||||||
const code = this.extractErrorCode(errorMessage)
|
const code = this.extractErrorCode(errorMessage)
|
||||||
@@ -519,6 +519,8 @@ class ChatService {
|
|||||||
].join('\n')
|
].join('\n')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const dialog = getElectronDialog()
|
||||||
|
if (!dialog?.showMessageBox) return
|
||||||
await dialog.showMessageBox({
|
await dialog.showMessageBox({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
title: 'WeFlow 启动失败',
|
title: 'WeFlow 启动失败',
|
||||||
@@ -600,7 +602,7 @@ class ChatService {
|
|||||||
console.error('[ChatService] 数据库监听回调失败:', error)
|
console.error('[ChatService] 数据库监听回调失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const windows = BrowserWindow.getAllWindows()
|
const windows = getElectronBrowserWindow()?.getAllWindows?.() || []
|
||||||
// 广播给所有渲染进程窗口
|
// 广播给所有渲染进程窗口
|
||||||
windows.forEach((win) => {
|
windows.forEach((win) => {
|
||||||
if (!win.isDestroyed()) {
|
if (!win.isDestroyed()) {
|
||||||
@@ -7180,7 +7182,7 @@ class ChatService {
|
|||||||
return join(cachePath, 'Voices')
|
return join(cachePath, 'Voices')
|
||||||
}
|
}
|
||||||
// 回退到默认目录
|
// 回退到默认目录
|
||||||
const documentsPath = app.getPath('documents')
|
const documentsPath = getPathFallback('documents')
|
||||||
return join(documentsPath, 'WeFlow', 'Voices')
|
return join(documentsPath, 'WeFlow', 'Voices')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7190,7 +7192,7 @@ class ChatService {
|
|||||||
return join(cachePath, 'Emojis')
|
return join(cachePath, 'Emojis')
|
||||||
}
|
}
|
||||||
// 回退到默认目录
|
// 回退到默认目录
|
||||||
const documentsPath = app.getPath('documents')
|
const documentsPath = getPathFallback('documents')
|
||||||
return join(documentsPath, 'WeFlow', 'Emojis')
|
return join(documentsPath, 'WeFlow', 'Emojis')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8435,13 +8437,13 @@ class ChatService {
|
|||||||
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
|
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
|
||||||
try {
|
try {
|
||||||
let wasmPath: string
|
let wasmPath: string
|
||||||
if (app.isPackaged) {
|
if (isElectronAppPackaged()) {
|
||||||
wasmPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
wasmPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||||
if (!existsSync(wasmPath)) {
|
if (!existsSync(wasmPath)) {
|
||||||
wasmPath = join(process.resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
wasmPath = join(process.resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
wasmPath = join(app.getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
wasmPath = join(getAppPathFallback(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existsSync(wasmPath)) {
|
if (!existsSync(wasmPath)) {
|
||||||
@@ -8629,7 +8631,7 @@ class ChatService {
|
|||||||
/** 获取持久化转写缓存文件路径 */
|
/** 获取持久化转写缓存文件路径 */
|
||||||
private getTranscriptCachePath(): string {
|
private getTranscriptCachePath(): string {
|
||||||
const cachePath = this.configService.get('cachePath')
|
const cachePath = this.configService.get('cachePath')
|
||||||
const base = cachePath || join(app.getPath('documents'), 'WeFlow')
|
const base = cachePath || join(getPathFallback('documents'), 'WeFlow')
|
||||||
return join(base, 'Voices', 'transcripts.json')
|
return join(base, 'Voices', 'transcripts.json')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { join } from 'path'
|
import { dirname, join } from 'path'
|
||||||
import { app, safeStorage } from 'electron'
|
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import Store from 'electron-store'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||||
import { expandHomePath } from '../utils/pathUtils'
|
import { expandHomePath } from '../utils/pathUtils'
|
||||||
|
import { getElectronSafeStorage, getPathFallback, isWorkerRuntime } from './electronRuntime'
|
||||||
|
|
||||||
// 加密前缀标记
|
// 加密前缀标记
|
||||||
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
||||||
const isSafeStorageAvailable = (): boolean => {
|
const isSafeStorageAvailable = (): boolean => {
|
||||||
try {
|
try {
|
||||||
|
const safeStorage = getElectronSafeStorage()
|
||||||
return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable()
|
return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable()
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
@@ -112,6 +113,68 @@ interface ConfigSchema {
|
|||||||
aiInsightDebugLogEnabled: boolean
|
aiInsightDebugLogEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ConfigStoreLike<T extends Record<string, any>> {
|
||||||
|
get<K extends keyof T>(key: K): T[K]
|
||||||
|
set<K extends keyof T>(key: K, value: T[K]): void
|
||||||
|
clear(): void
|
||||||
|
store: T
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneJson<T>(value: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
class JsonConfigStore<T extends Record<string, any>> implements ConfigStoreLike<T> {
|
||||||
|
private readonly filePath: string
|
||||||
|
private readonly defaults: T
|
||||||
|
private data: T
|
||||||
|
|
||||||
|
constructor(options: { name: string; defaults: T; cwd?: string }) {
|
||||||
|
const baseDir = options.cwd || getPathFallback('userData')
|
||||||
|
mkdirSync(baseDir, { recursive: true })
|
||||||
|
this.filePath = join(baseDir, `${options.name}.json`)
|
||||||
|
this.defaults = cloneJson(options.defaults)
|
||||||
|
this.data = cloneJson(options.defaults)
|
||||||
|
this.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
get store(): T {
|
||||||
|
return this.data
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
try {
|
||||||
|
if (!existsSync(this.filePath)) return
|
||||||
|
const raw = readFileSync(this.filePath, 'utf8')
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
this.data = { ...cloneJson(this.defaults), ...parsed }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.data = cloneJson(this.defaults)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(): void {
|
||||||
|
mkdirSync(dirname(this.filePath), { recursive: true })
|
||||||
|
writeFileSync(this.filePath, JSON.stringify(this.data), 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
get<K extends keyof T>(key: K): T[K] {
|
||||||
|
return this.data[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
set<K extends keyof T>(key: K, value: T[K]): void {
|
||||||
|
this.data[key] = value
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.data = cloneJson(this.defaults)
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 需要 safeStorage 加密的字段(普通模式)
|
// 需要 safeStorage 加密的字段(普通模式)
|
||||||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set([
|
const ENCRYPTED_STRING_KEYS: Set<string> = new Set([
|
||||||
'decryptKey',
|
'decryptKey',
|
||||||
@@ -131,7 +194,7 @@ const LOCKABLE_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
|||||||
|
|
||||||
export class ConfigService {
|
export class ConfigService {
|
||||||
private static instance: ConfigService
|
private static instance: ConfigService
|
||||||
private store!: Store<ConfigSchema>
|
private store!: ConfigStoreLike<ConfigSchema>
|
||||||
|
|
||||||
// 锁定模式运行时状态
|
// 锁定模式运行时状态
|
||||||
private unlockedKeys: Map<string, any> = new Map()
|
private unlockedKeys: Map<string, any> = new Map()
|
||||||
@@ -225,37 +288,18 @@ export class ConfigService {
|
|||||||
aiInsightDebugLogEnabled: false
|
aiInsightDebugLogEnabled: false
|
||||||
}
|
}
|
||||||
|
|
||||||
const storeOptions: any = {
|
const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim()
|
||||||
|
this.store = new JsonConfigStore<ConfigSchema>({
|
||||||
name: 'WeFlow-config',
|
name: 'WeFlow-config',
|
||||||
defaults,
|
defaults,
|
||||||
projectName: String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow'
|
cwd: cwd || undefined
|
||||||
}
|
})
|
||||||
const runningInWorker = process.env.WEFLOW_WORKER === '1'
|
|
||||||
if (runningInWorker) {
|
|
||||||
const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim()
|
|
||||||
if (cwd) {
|
|
||||||
storeOptions.cwd = cwd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
if (!isWorkerRuntime()) {
|
||||||
this.store = new Store<ConfigSchema>(storeOptions)
|
|
||||||
} catch (error) {
|
|
||||||
const message = String((error as Error)?.message || error || '')
|
|
||||||
if (message.includes('projectName')) {
|
|
||||||
const fallbackOptions = {
|
|
||||||
...storeOptions,
|
|
||||||
projectName: 'WeFlow',
|
|
||||||
cwd: storeOptions.cwd || process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || process.cwd()
|
|
||||||
}
|
|
||||||
this.store = new Store<ConfigSchema>(fallbackOptions)
|
|
||||||
} else {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.migrateAuthFields()
|
this.migrateAuthFields()
|
||||||
this.migrateAiConfig()
|
this.migrateAiConfig()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// === 状态查询 ===
|
// === 状态查询 ===
|
||||||
|
|
||||||
@@ -356,6 +400,8 @@ export class ConfigService {
|
|||||||
if (!plaintext) return ''
|
if (!plaintext) return ''
|
||||||
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
|
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
|
||||||
if (!isSafeStorageAvailable()) return plaintext
|
if (!isSafeStorageAvailable()) return plaintext
|
||||||
|
const safeStorage = getElectronSafeStorage()
|
||||||
|
if (!safeStorage) return plaintext
|
||||||
const encrypted = safeStorage.encryptString(plaintext)
|
const encrypted = safeStorage.encryptString(plaintext)
|
||||||
return SAFE_PREFIX + encrypted.toString('base64')
|
return SAFE_PREFIX + encrypted.toString('base64')
|
||||||
}
|
}
|
||||||
@@ -364,6 +410,8 @@ export class ConfigService {
|
|||||||
if (!stored) return ''
|
if (!stored) return ''
|
||||||
if (!stored.startsWith(SAFE_PREFIX)) return stored
|
if (!stored.startsWith(SAFE_PREFIX)) return stored
|
||||||
if (!isSafeStorageAvailable()) return ''
|
if (!isSafeStorageAvailable()) return ''
|
||||||
|
const safeStorage = getElectronSafeStorage()
|
||||||
|
if (!safeStorage) return ''
|
||||||
try {
|
try {
|
||||||
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
|
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
|
||||||
return safeStorage.decryptString(buf)
|
return safeStorage.decryptString(buf)
|
||||||
@@ -831,7 +879,7 @@ export class ConfigService {
|
|||||||
if (workerUserDataPath) {
|
if (workerUserDataPath) {
|
||||||
return workerUserDataPath
|
return workerUserDataPath
|
||||||
}
|
}
|
||||||
return app?.getPath?.('userData') || process.cwd()
|
return getPathFallback('userData')
|
||||||
}
|
}
|
||||||
|
|
||||||
getCacheBasePath(): string {
|
getCacheBasePath(): string {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
import { app } from 'electron'
|
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
export interface ContactCacheEntry {
|
export interface ContactCacheEntry {
|
||||||
|
|||||||
96
electron/services/electronRuntime.ts
Normal file
96
electron/services/electronRuntime.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { homedir, tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
type RuntimeRequire = (id: string) => any
|
||||||
|
|
||||||
|
let cachedElectron: any | null | false = null
|
||||||
|
|
||||||
|
export function isWorkerRuntime(): boolean {
|
||||||
|
return process.env.WEFLOW_WORKER === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getElectronModule(): any | null {
|
||||||
|
if (isWorkerRuntime()) return null
|
||||||
|
if (cachedElectron !== null) return cachedElectron || null
|
||||||
|
try {
|
||||||
|
const runtimeRequire = (0, eval)('require') as RuntimeRequire
|
||||||
|
cachedElectron = runtimeRequire('electron')
|
||||||
|
} catch {
|
||||||
|
cachedElectron = false
|
||||||
|
}
|
||||||
|
return cachedElectron || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getElectronApp(): any | null {
|
||||||
|
return getElectronModule()?.app || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getElectronBrowserWindow(): any | null {
|
||||||
|
return getElectronModule()?.BrowserWindow || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getElectronDialog(): any | null {
|
||||||
|
return getElectronModule()?.dialog || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getElectronSafeStorage(): any | null {
|
||||||
|
return getElectronModule()?.safeStorage || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getElectronPath(name: string): string | null {
|
||||||
|
try {
|
||||||
|
const getter = getElectronApp()?.getPath
|
||||||
|
if (typeof getter === 'function') {
|
||||||
|
return getter(name)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to caller fallback
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppPathFallback(): string {
|
||||||
|
try {
|
||||||
|
const getter = getElectronApp()?.getAppPath
|
||||||
|
if (typeof getter === 'function') {
|
||||||
|
return getter()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return process.cwd()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPathFallback(name: string): string {
|
||||||
|
const fromElectron = getElectronPath(name)
|
||||||
|
if (fromElectron) return fromElectron
|
||||||
|
|
||||||
|
const home = homedir()
|
||||||
|
switch (name) {
|
||||||
|
case 'userData': {
|
||||||
|
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
||||||
|
if (workerUserDataPath) return workerUserDataPath
|
||||||
|
if (process.platform === 'win32' && process.env.APPDATA) return join(process.env.APPDATA, 'WeFlow')
|
||||||
|
if (process.platform === 'darwin') return join(home, 'Library', 'Application Support', 'WeFlow')
|
||||||
|
return join(process.env.XDG_CONFIG_HOME || join(home, '.config'), 'WeFlow')
|
||||||
|
}
|
||||||
|
case 'documents':
|
||||||
|
return join(home, 'Documents')
|
||||||
|
case 'desktop':
|
||||||
|
return join(home, 'Desktop')
|
||||||
|
case 'downloads':
|
||||||
|
return join(home, 'Downloads')
|
||||||
|
case 'temp':
|
||||||
|
return tmpdir()
|
||||||
|
case 'appData':
|
||||||
|
return process.platform === 'win32' && process.env.APPDATA ? process.env.APPDATA : join(home, '.config')
|
||||||
|
default:
|
||||||
|
return process.cwd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isElectronAppPackaged(): boolean {
|
||||||
|
const app = getElectronApp()
|
||||||
|
if (typeof app?.isPackaged === 'boolean') return app.isPackaged
|
||||||
|
return Boolean((process as any).resourcesPath && process.env.NODE_ENV !== 'development')
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { app } from 'electron'
|
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { getPathFallback } from './electronRuntime'
|
||||||
|
|
||||||
export interface ExportRecord {
|
export interface ExportRecord {
|
||||||
exportTime: number
|
exportTime: number
|
||||||
@@ -20,7 +20,7 @@ class ExportRecordService {
|
|||||||
private resolveFilePath(): string {
|
private resolveFilePath(): string {
|
||||||
if (this.filePath) return this.filePath
|
if (this.filePath) return this.filePath
|
||||||
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
||||||
const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd()
|
const userDataPath = workerUserDataPath || getPathFallback('userData')
|
||||||
fs.mkdirSync(userDataPath, { recursive: true })
|
fs.mkdirSync(userDataPath, { recursive: true })
|
||||||
this.filePath = path.join(userDataPath, 'weflow-export-records.json')
|
this.filePath = path.join(userDataPath, 'weflow-export-records.json')
|
||||||
return this.filePath
|
return this.filePath
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export interface ExportOptions {
|
|||||||
excelCompactColumns?: boolean
|
excelCompactColumns?: boolean
|
||||||
txtColumns?: string[]
|
txtColumns?: string[]
|
||||||
sessionLayout?: 'shared' | 'per-session'
|
sessionLayout?: 'shared' | 'per-session'
|
||||||
|
exportWriteLayout?: 'A' | 'B' | 'C'
|
||||||
sessionNameWithTypePrefix?: boolean
|
sessionNameWithTypePrefix?: boolean
|
||||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||||
exportConcurrency?: number
|
exportConcurrency?: number
|
||||||
@@ -271,7 +272,7 @@ async function parallelLimit<T, R>(
|
|||||||
|
|
||||||
class ExportService {
|
class ExportService {
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
private runtimeConfig: { dbPath?: string; decryptKey?: string; myWxid?: string } | null = null
|
private runtimeConfig: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null = null
|
||||||
private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }>
|
private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }>
|
||||||
private inlineEmojiCache: LRUCache<string, string>
|
private inlineEmojiCache: LRUCache<string, string>
|
||||||
private htmlStyleCache: string | null = null
|
private htmlStyleCache: string | null = null
|
||||||
@@ -287,6 +288,8 @@ class ExportService {
|
|||||||
private mediaExportTelemetry: MediaExportTelemetry | null = null
|
private mediaExportTelemetry: MediaExportTelemetry | null = null
|
||||||
private mediaRunSourceDedupMap = new Map<string, string>()
|
private mediaRunSourceDedupMap = new Map<string, string>()
|
||||||
private mediaRunMissingImageKeys = new Set<string>()
|
private mediaRunMissingImageKeys = new Set<string>()
|
||||||
|
private activeChatImagePipelineCount = 0
|
||||||
|
private chatImagePipelineWaiters: Array<() => void> = []
|
||||||
private mediaFileCacheCleanupPending: Promise<void> | null = null
|
private mediaFileCacheCleanupPending: Promise<void> | null = null
|
||||||
private mediaFileCacheLastCleanupAt = 0
|
private mediaFileCacheLastCleanupAt = 0
|
||||||
private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000
|
private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000
|
||||||
@@ -320,8 +323,22 @@ class ExportService {
|
|||||||
return error
|
return error
|
||||||
}
|
}
|
||||||
|
|
||||||
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string } | null): void {
|
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void {
|
||||||
this.runtimeConfig = config
|
this.runtimeConfig = config
|
||||||
|
imageDecryptService.setRuntimeConfig({
|
||||||
|
dbPath: config?.dbPath,
|
||||||
|
myWxid: config?.myWxid,
|
||||||
|
imageXorKey: config?.imageXorKey,
|
||||||
|
imageAesKey: config?.imageAesKey
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConfiguredDbPath(): string {
|
||||||
|
return String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConfiguredMyWxid(): string {
|
||||||
|
return String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeSessionIds(sessionIds: string[]): string[] {
|
private normalizeSessionIds(sessionIds: string[]): string[] {
|
||||||
@@ -354,6 +371,33 @@ class ExportService {
|
|||||||
return { start, end }
|
return { start, end }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeMaxFileSizeMb(value: unknown): number | undefined {
|
||||||
|
const raw = Number(value)
|
||||||
|
if (!Number.isFinite(raw) || raw <= 0) return undefined
|
||||||
|
return Math.floor(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeExportOptionsForRun(options: ExportOptions): ExportOptions {
|
||||||
|
const normalizedDateRange = this.normalizeExportDateRange(options.dateRange)
|
||||||
|
const normalizedMaxFileSizeMb = this.normalizeMaxFileSizeMb(options.maxFileSizeMb)
|
||||||
|
const normalizedWriteLayout = this.resolveExportWriteLayout(options)
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
dateRange: normalizedDateRange,
|
||||||
|
maxFileSizeMb: normalizedMaxFileSizeMb,
|
||||||
|
exportWriteLayout: normalizedWriteLayout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveExportWriteLayout(options?: Pick<ExportOptions, 'exportWriteLayout'> | null): 'A' | 'B' | 'C' {
|
||||||
|
const optionLayout = options?.exportWriteLayout
|
||||||
|
if (optionLayout === 'A' || optionLayout === 'B' || optionLayout === 'C') return optionLayout
|
||||||
|
const rawWriteLayout = this.configService.get('exportWriteLayout')
|
||||||
|
return rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
|
||||||
|
? rawWriteLayout
|
||||||
|
: 'B'
|
||||||
|
}
|
||||||
|
|
||||||
private getExportStatsDateRangeToken(dateRange?: { start: number; end: number } | null): string {
|
private getExportStatsDateRangeToken(dateRange?: { start: number; end: number } | null): string {
|
||||||
const normalized = this.normalizeExportDateRange(dateRange)
|
const normalized = this.normalizeExportDateRange(dateRange)
|
||||||
if (!normalized) return 'all'
|
if (!normalized) return 'all'
|
||||||
@@ -370,8 +414,8 @@ class ExportService {
|
|||||||
const normalizedIds = this.normalizeSessionIds(sessionIds).sort()
|
const normalizedIds = this.normalizeSessionIds(sessionIds).sort()
|
||||||
const senderToken = String(options.senderUsername || '').trim()
|
const senderToken = String(options.senderUsername || '').trim()
|
||||||
const dateToken = this.getExportStatsDateRangeToken(options.dateRange)
|
const dateToken = this.getExportStatsDateRangeToken(options.dateRange)
|
||||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
const dbPath = this.getConfiguredDbPath()
|
||||||
const wxidToken = String(cleanedWxid || this.cleanAccountDirName(String(this.configService.get('myWxid') || '')) || '').trim()
|
const wxidToken = String(cleanedWxid || this.cleanAccountDirName(this.getConfiguredMyWxid()) || '').trim()
|
||||||
return `${dbPath}::${wxidToken}::${dateToken}::${senderToken}::${normalizedIds.join('\u001f')}`
|
return `${dbPath}::${wxidToken}::${dateToken}::${senderToken}::${normalizedIds.join('\u001f')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,6 +756,20 @@ class ExportService {
|
|||||||
this.mediaRunMissingImageKeys.clear()
|
this.mediaRunMissingImageKeys.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async runWithChatImagePipelineLimit<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
while (this.activeChatImagePipelineCount >= 2) {
|
||||||
|
await new Promise<void>((resolve) => this.chatImagePipelineWaiters.push(resolve))
|
||||||
|
}
|
||||||
|
this.activeChatImagePipelineCount += 1
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} finally {
|
||||||
|
this.activeChatImagePipelineCount = Math.max(0, this.activeChatImagePipelineCount - 1)
|
||||||
|
const next = this.chatImagePipelineWaiters.shift()
|
||||||
|
if (next) next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getMediaTelemetrySnapshot(): Partial<ExportProgress> {
|
private getMediaTelemetrySnapshot(): Partial<ExportProgress> {
|
||||||
const stats = this.mediaExportTelemetry
|
const stats = this.mediaExportTelemetry
|
||||||
if (!stats) return {}
|
if (!stats) return {}
|
||||||
@@ -1577,8 +1635,8 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveStrictEmoticonDbPath(): string | null {
|
private resolveStrictEmoticonDbPath(): string | null {
|
||||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
const dbPath = this.getConfiguredDbPath()
|
||||||
const rawWxid = String(this.configService.get('myWxid') || '').trim()
|
const rawWxid = this.getConfiguredMyWxid()
|
||||||
const cleanedWxid = this.cleanAccountDirName(rawWxid)
|
const cleanedWxid = this.cleanAccountDirName(rawWxid)
|
||||||
const token = `${dbPath}::${rawWxid}::${cleanedWxid}`
|
const token = `${dbPath}::${rawWxid}::${cleanedWxid}`
|
||||||
if (token === this.emoticonDbPathCacheToken) {
|
if (token === this.emoticonDbPathCacheToken) {
|
||||||
@@ -1823,8 +1881,8 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
|
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
|
||||||
const wxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
|
const wxid = this.getConfiguredMyWxid()
|
||||||
const dbPath = String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
|
const dbPath = this.getConfiguredDbPath()
|
||||||
const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim()
|
const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim()
|
||||||
if (!wxid) return { success: false, error: '请先在设置页面配置微信ID' }
|
if (!wxid) return { success: false, error: '请先在设置页面配置微信ID' }
|
||||||
if (!dbPath) return { success: false, error: '请先在设置页面配置数据库路径' }
|
if (!dbPath) return { success: false, error: '请先在设置页面配置数据库路径' }
|
||||||
@@ -4092,21 +4150,54 @@ class ExportService {
|
|||||||
|
|
||||||
const tryResolveImagePath = async (imageMd5?: string, imageDatName?: string): Promise<string | null> => {
|
const tryResolveImagePath = async (imageMd5?: string, imageDatName?: string): Promise<string | null> => {
|
||||||
if (!imageMd5 && !imageDatName) return null
|
if (!imageMd5 && !imageDatName) return null
|
||||||
|
return this.runWithChatImagePipelineLimit(async () => {
|
||||||
|
const pickResolvedImagePath = (result: any): string | null => {
|
||||||
|
if (!result?.success) return null
|
||||||
|
const resolved = String(result.localPath || '').trim()
|
||||||
|
return resolved || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveCachedPath = async (candidateMd5?: string, candidateDatName?: string): Promise<string | null> => {
|
||||||
|
const cachedResult = await imageDecryptService.resolveCachedImage({
|
||||||
|
sessionId,
|
||||||
|
imageMd5: candidateMd5,
|
||||||
|
imageDatName: candidateDatName,
|
||||||
|
createTime: msg.createTime,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
disableUpdateCheck: true,
|
||||||
|
allowCacheIndex: true,
|
||||||
|
suppressEvents: true
|
||||||
|
})
|
||||||
|
return pickResolvedImagePath(cachedResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedPath = await resolveCachedPath(imageMd5, imageDatName)
|
||||||
|
if (cachedPath) {
|
||||||
|
return cachedPath
|
||||||
|
}
|
||||||
|
|
||||||
const decryptResult = await imageDecryptService.decryptImage({
|
const decryptResult = await imageDecryptService.decryptImage({
|
||||||
sessionId,
|
sessionId,
|
||||||
imageMd5,
|
imageMd5,
|
||||||
imageDatName,
|
imageDatName,
|
||||||
createTime: msg.createTime,
|
createTime: msg.createTime,
|
||||||
force: true, // 导出优先高清,失败再回退缩略图
|
force: false,
|
||||||
preferFilePath: true,
|
preferFilePath: true,
|
||||||
hardlinkOnly: true,
|
hardlinkOnly: true,
|
||||||
disableUpdateCheck: true,
|
allowCacheIndex: true
|
||||||
allowCacheIndex: !imageMd5,
|
|
||||||
suppressEvents: true
|
|
||||||
})
|
})
|
||||||
if (decryptResult.success && decryptResult.localPath) {
|
const decryptedPath = pickResolvedImagePath(decryptResult)
|
||||||
return decryptResult.localPath
|
if (decryptedPath) return decryptedPath
|
||||||
|
|
||||||
|
const localId = Number(msg?.localId || 0)
|
||||||
|
if (Number.isFinite(localId) && localId > 0) {
|
||||||
|
const fallback = await chatService.getImageData(sessionId, String(localId))
|
||||||
|
if (fallback.success && fallback.data) {
|
||||||
|
const buffer = Buffer.from(fallback.data, 'base64')
|
||||||
|
const mime = this.detectMimeType(buffer) || 'image/jpeg'
|
||||||
|
return `data:${mime};base64,${fallback.data}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decryptResult.failureKind === 'decrypt_failed') {
|
if (decryptResult.failureKind === 'decrypt_failed') {
|
||||||
@@ -4121,8 +4212,9 @@ class ExportService {
|
|||||||
imageDatName,
|
imageDatName,
|
||||||
createTime: msg.createTime,
|
createTime: msg.createTime,
|
||||||
preferFilePath: true,
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
disableUpdateCheck: true,
|
disableUpdateCheck: true,
|
||||||
allowCacheIndex: !imageMd5,
|
allowCacheIndex: true,
|
||||||
suppressEvents: true
|
suppressEvents: true
|
||||||
})
|
})
|
||||||
if (thumbResult.success && thumbResult.localPath) {
|
if (thumbResult.success && thumbResult.localPath) {
|
||||||
@@ -4130,6 +4222,7 @@ class ExportService {
|
|||||||
return thumbResult.localPath
|
return thumbResult.localPath
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用消息对象中已提取的字段,先尝试快速导出。
|
// 使用消息对象中已提取的字段,先尝试快速导出。
|
||||||
@@ -4235,13 +4328,12 @@ class ExportService {
|
|||||||
const imageMd5 = String(msg?.imageMd5 || '').trim().toLowerCase()
|
const imageMd5 = String(msg?.imageMd5 || '').trim().toLowerCase()
|
||||||
if (imageMd5) {
|
if (imageMd5) {
|
||||||
imageMd5Set.add(imageMd5)
|
imageMd5Set.add(imageMd5)
|
||||||
} else {
|
}
|
||||||
const imageDatName = String(msg?.imageDatName || '').trim().toLowerCase()
|
const imageDatName = String(msg?.imageDatName || '').trim().toLowerCase()
|
||||||
if (md5Pattern.test(imageDatName)) {
|
if (md5Pattern.test(imageDatName)) {
|
||||||
imageMd5Set.add(imageDatName)
|
imageMd5Set.add(imageDatName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4487,16 +4579,89 @@ class ExportService {
|
|||||||
*/
|
*/
|
||||||
private extractImageDatName(content: string): string | undefined {
|
private extractImageDatName(content: string): string | undefined {
|
||||||
if (!content) return undefined
|
if (!content) return undefined
|
||||||
// 尝试从 cdnthumburl 或其他字段提取
|
const candidate =
|
||||||
const urlMatch = /cdnthumburl[^>]*>([^<]+)/i.exec(content)
|
this.extractXmlValue(content, 'imgname') ||
|
||||||
if (urlMatch) {
|
this.extractXmlValue(content, 'cdnmidimgurl') ||
|
||||||
const urlParts = urlMatch[1].split('/')
|
this.extractXmlValue(content, 'cdnthumburl') ||
|
||||||
const last = urlParts[urlParts.length - 1]
|
this.extractXmlAttribute(content, 'img', 'imgname') ||
|
||||||
if (last && last.includes('_')) {
|
this.extractXmlAttribute(content, 'img', 'cdnmidimgurl') ||
|
||||||
return last.split('_')[0]
|
this.extractXmlAttribute(content, 'img', 'cdnthumburl')
|
||||||
|
return this.normalizeImageDatNameToken(candidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeImageDatNameToken(value: unknown): string | undefined {
|
||||||
|
let text = String(value ?? '').trim()
|
||||||
|
if (!text) return undefined
|
||||||
|
text = text.replace(/&/g, '&')
|
||||||
|
try {
|
||||||
|
if (text.includes('%')) text = decodeURIComponent(text)
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(text)
|
||||||
|
if (datLike?.[1]) return datLike[1].toLowerCase()
|
||||||
|
|
||||||
|
const base = text
|
||||||
|
.split(/[?#]/, 1)[0]
|
||||||
|
.replace(/^.*[\\/]/, '')
|
||||||
|
.replace(/\.(?:t\.)?dat$/i, '')
|
||||||
|
.trim()
|
||||||
|
if (!base) return undefined
|
||||||
|
|
||||||
|
const cdnToken = base.includes('_') ? base.split('_')[0] : base
|
||||||
|
const exact = /^([a-fA-F0-9]{16,64})$/.exec(cdnToken)
|
||||||
|
if (exact?.[1]) return exact[1].toLowerCase()
|
||||||
|
|
||||||
|
const preferred32 = /([a-fA-F0-9]{32})(?![a-fA-F0-9])/i.exec(cdnToken)
|
||||||
|
if (preferred32?.[1]) return preferred32[1].toLowerCase()
|
||||||
|
const fallback = /([a-fA-F0-9]{16,64})(?![a-fA-F0-9])/i.exec(cdnToken)
|
||||||
|
return fallback?.[1]?.toLowerCase()
|
||||||
}
|
}
|
||||||
return undefined
|
|
||||||
|
private extractImageDatNameFromPackedRaw(raw: unknown): string | undefined {
|
||||||
|
const buffer = this.decodePackedInfoBuffer(raw)
|
||||||
|
if (!buffer || buffer.length === 0) return undefined
|
||||||
|
const printable: number[] = []
|
||||||
|
for (const byte of buffer) {
|
||||||
|
printable.push(byte >= 0x20 && byte <= 0x7e ? byte : 0x20)
|
||||||
|
}
|
||||||
|
const text = Buffer.from(printable).toString('utf-8')
|
||||||
|
const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(text)
|
||||||
|
if (datLike?.[1]) return datLike[1].toLowerCase()
|
||||||
|
const fallback = /([0-9a-fA-F]{16,})/.exec(text)
|
||||||
|
return fallback?.[1]?.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractImageDatNameFromRow(row: Record<string, any>, content?: string): string | undefined {
|
||||||
|
const byColumn = this.normalizeImageDatNameToken(this.getRowField(row, [
|
||||||
|
'image_path',
|
||||||
|
'imagePath',
|
||||||
|
'image_dat_name',
|
||||||
|
'imageDatName',
|
||||||
|
'img_path',
|
||||||
|
'imgPath',
|
||||||
|
'img_name',
|
||||||
|
'imgName'
|
||||||
|
]))
|
||||||
|
if (byColumn) return byColumn
|
||||||
|
|
||||||
|
const packedRaw = this.getRowField(row, [
|
||||||
|
'packed_info_data',
|
||||||
|
'packedInfoData',
|
||||||
|
'packed_info_blob',
|
||||||
|
'packedInfoBlob',
|
||||||
|
'packed_info',
|
||||||
|
'packedInfo',
|
||||||
|
'BytesExtra',
|
||||||
|
'bytes_extra',
|
||||||
|
'WCDB_CT_packed_info',
|
||||||
|
'reserved0',
|
||||||
|
'Reserved0',
|
||||||
|
'WCDB_CT_Reserved0'
|
||||||
|
])
|
||||||
|
const byPacked = this.extractImageDatNameFromPackedRaw(packedRaw)
|
||||||
|
if (byPacked) return byPacked
|
||||||
|
|
||||||
|
return this.extractImageDatName(content || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4699,8 +4864,8 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveFileAttachmentSearchRoots(): FileAttachmentSearchRoot[] {
|
private resolveFileAttachmentSearchRoots(): FileAttachmentSearchRoot[] {
|
||||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
const dbPath = this.getConfiguredDbPath()
|
||||||
const rawWxid = String(this.configService.get('myWxid') || '').trim()
|
const rawWxid = this.getConfiguredMyWxid()
|
||||||
const cleanedWxid = this.cleanAccountDirName(rawWxid)
|
const cleanedWxid = this.cleanAccountDirName(rawWxid)
|
||||||
if (!dbPath) return []
|
if (!dbPath) return []
|
||||||
|
|
||||||
@@ -5050,10 +5215,7 @@ class ExportService {
|
|||||||
const exportMediaEnabled = options.exportMedia === true &&
|
const exportMediaEnabled = options.exportMedia === true &&
|
||||||
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles)
|
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles)
|
||||||
const outputDir = path.dirname(outputPath)
|
const outputDir = path.dirname(outputPath)
|
||||||
const rawWriteLayout = this.configService.get('exportWriteLayout')
|
const writeLayout = this.resolveExportWriteLayout(options)
|
||||||
const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
|
|
||||||
? rawWriteLayout
|
|
||||||
: 'A'
|
|
||||||
// A: type-first layout, text exports are placed under `texts/`, media is placed at sibling type directories.
|
// A: type-first layout, text exports are placed under `texts/`, media is placed at sibling type directories.
|
||||||
if (writeLayout === 'A' && path.basename(outputDir) === 'texts') {
|
if (writeLayout === 'A' && path.basename(outputDir) === 'texts') {
|
||||||
return {
|
return {
|
||||||
@@ -5229,7 +5391,7 @@ class ExportService {
|
|||||||
: await wcdbService.openMessageCursor(
|
: await wcdbService.openMessageCursor(
|
||||||
sessionId,
|
sessionId,
|
||||||
batchSize,
|
batchSize,
|
||||||
true,
|
false,
|
||||||
beginTime,
|
beginTime,
|
||||||
endTime
|
endTime
|
||||||
)
|
)
|
||||||
@@ -5417,7 +5579,7 @@ class ExportService {
|
|||||||
if (collectMode === 'full' || collectMode === 'media-fast') {
|
if (collectMode === 'full' || collectMode === 'media-fast') {
|
||||||
// 优先复用游标返回的字段,缺失时再回退到 XML 解析。
|
// 优先复用游标返回的字段,缺失时再回退到 XML 解析。
|
||||||
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
|
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
|
||||||
imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined
|
imageDatName = localType === 3 ? this.extractImageDatNameFromRow(row, content) : undefined
|
||||||
videoMd5 = this.extractVideoFileNameFromRow(row, content)
|
videoMd5 = this.extractVideoFileNameFromRow(row, content)
|
||||||
xmlType = rowFileHints.xmlType
|
xmlType = rowFileHints.xmlType
|
||||||
fileName = rowFileHints.fileName
|
fileName = rowFileHints.fileName
|
||||||
@@ -5439,7 +5601,7 @@ class ExportService {
|
|||||||
if (localType === 3 && content) {
|
if (localType === 3 && content) {
|
||||||
// 图片消息
|
// 图片消息
|
||||||
imageMd5 = imageMd5 || this.extractImageMd5(content)
|
imageMd5 = imageMd5 || this.extractImageMd5(content)
|
||||||
imageDatName = imageDatName || this.extractImageDatName(content)
|
imageDatName = imageDatName || this.extractImageDatNameFromRow(row, content)
|
||||||
} else if (localType === 43 && content) {
|
} else if (localType === 43 && content) {
|
||||||
// 视频消息
|
// 视频消息
|
||||||
videoMd5 = videoMd5 || this.extractVideoFileNameFromRow(row, content)
|
videoMd5 = videoMd5 || this.extractVideoFileNameFromRow(row, content)
|
||||||
@@ -5587,9 +5749,51 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rows.length > 1) {
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const timeDelta = (a.createTime || 0) - (b.createTime || 0)
|
||||||
|
if (timeDelta !== 0) return timeDelta
|
||||||
|
return (a.localId || 0) - (b.localId || 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return { rows, memberSet, firstTime, lastTime }
|
return { rows, memberSet, firstTime, lastTime }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getRecentWcdbCursorLogSummary(sessionId: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const logResult = await wcdbService.getLogs()
|
||||||
|
if (!logResult.success || !Array.isArray(logResult.logs)) return undefined
|
||||||
|
const sid = String(sessionId || '').trim()
|
||||||
|
const interesting = logResult.logs
|
||||||
|
.filter((line) => {
|
||||||
|
const text = String(line || '')
|
||||||
|
if (sid && text.includes(sid)) return true
|
||||||
|
return text.includes('QueryMessageBatch') ||
|
||||||
|
text.includes('InitExportCursorHeap') ||
|
||||||
|
text.includes('cursor_init') ||
|
||||||
|
text.includes('fetch_message_batch') ||
|
||||||
|
text.includes('open_message_cursor')
|
||||||
|
})
|
||||||
|
.slice(-8)
|
||||||
|
if (interesting.length === 0) return undefined
|
||||||
|
return interesting.join(' | ')
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildNoMessagesError(
|
||||||
|
sessionId: string,
|
||||||
|
collected: { error?: string },
|
||||||
|
fallback = '该会话在指定时间范围内没有消息'
|
||||||
|
): Promise<string> {
|
||||||
|
if (collected.error) return collected.error
|
||||||
|
const nativeLogSummary = await this.getRecentWcdbCursorLogSummary(sessionId)
|
||||||
|
if (!nativeLogSummary) return fallback
|
||||||
|
return `${fallback};WCDB日志:${nativeLogSummary}`
|
||||||
|
}
|
||||||
|
|
||||||
private async backfillMediaFieldsFromMessageDetail(
|
private async backfillMediaFieldsFromMessageDetail(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
rows: any[],
|
rows: any[],
|
||||||
@@ -5608,7 +5812,7 @@ class ExportService {
|
|||||||
return !msg.xmlType || !msg.fileName || !msg.fileMd5 || !msg.fileSize || !msg.fileExt
|
return !msg.xmlType || !msg.fileName || !msg.fileMd5 || !msg.fileSize || !msg.fileExt
|
||||||
}
|
}
|
||||||
if (!targetMediaTypes.has(msg.localType)) return false
|
if (!targetMediaTypes.has(msg.localType)) return false
|
||||||
if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName
|
if (msg.localType === 3) return !msg.imageMd5 || !msg.imageDatName
|
||||||
if (msg.localType === 47) return !msg.emojiMd5
|
if (msg.localType === 47) return !msg.emojiMd5
|
||||||
if (msg.localType === 43) return !msg.videoMd5
|
if (msg.localType === 43) return !msg.videoMd5
|
||||||
return false
|
return false
|
||||||
@@ -5639,7 +5843,7 @@ class ExportService {
|
|||||||
|
|
||||||
if (msg.localType === 3) {
|
if (msg.localType === 3) {
|
||||||
const imageMd5 = (String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content) || '').toLowerCase()
|
const imageMd5 = (String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content) || '').toLowerCase()
|
||||||
const imageDatName = (String(row.image_dat_name || row.imageDatName || '').trim() || this.extractImageDatName(content) || '').toLowerCase()
|
const imageDatName = this.extractImageDatNameFromRow(row, content) || ''
|
||||||
if (imageMd5) msg.imageMd5 = imageMd5
|
if (imageMd5) msg.imageMd5 = imageMd5
|
||||||
if (imageDatName) msg.imageDatName = imageDatName
|
if (imageDatName) msg.imageDatName = imageDatName
|
||||||
return
|
return
|
||||||
@@ -6111,7 +6315,7 @@ class ExportService {
|
|||||||
|
|
||||||
const cleanedMyWxid = conn.cleanedWxid
|
const cleanedMyWxid = conn.cleanedWxid
|
||||||
const isGroup = sessionId.includes('@chatroom')
|
const isGroup = sessionId.includes('@chatroom')
|
||||||
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
const rawMyWxid = this.getConfiguredMyWxid()
|
||||||
|
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||||
@@ -6149,7 +6353,7 @@ class ExportService {
|
|||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
if (totalMessages === 0) {
|
if (totalMessages === 0) {
|
||||||
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' }
|
return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.hydrateEmojiCaptionsForMessages(sessionId, allMessages, control)
|
await this.hydrateEmojiCaptionsForMessages(sessionId, allMessages, control)
|
||||||
@@ -6649,7 +6853,7 @@ class ExportService {
|
|||||||
|
|
||||||
const cleanedMyWxid = conn.cleanedWxid
|
const cleanedMyWxid = conn.cleanedWxid
|
||||||
const isGroup = sessionId.includes('@chatroom')
|
const isGroup = sessionId.includes('@chatroom')
|
||||||
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
const rawMyWxid = this.getConfiguredMyWxid()
|
||||||
|
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||||
@@ -6687,7 +6891,7 @@ class ExportService {
|
|||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
if (totalMessages === 0) {
|
if (totalMessages === 0) {
|
||||||
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' }
|
return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
||||||
@@ -7380,7 +7584,7 @@ class ExportService {
|
|||||||
|
|
||||||
const cleanedMyWxid = conn.cleanedWxid
|
const cleanedMyWxid = conn.cleanedWxid
|
||||||
const isGroup = sessionId.includes('@chatroom')
|
const isGroup = sessionId.includes('@chatroom')
|
||||||
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
const rawMyWxid = this.getConfiguredMyWxid()
|
||||||
|
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||||
@@ -7423,7 +7627,7 @@ class ExportService {
|
|||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
if (totalMessages === 0) {
|
if (totalMessages === 0) {
|
||||||
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' }
|
return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
||||||
@@ -8264,7 +8468,7 @@ class ExportService {
|
|||||||
|
|
||||||
const cleanedMyWxid = conn.cleanedWxid
|
const cleanedMyWxid = conn.cleanedWxid
|
||||||
const isGroup = sessionId.includes('@chatroom')
|
const isGroup = sessionId.includes('@chatroom')
|
||||||
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
const rawMyWxid = this.getConfiguredMyWxid()
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||||
|
|
||||||
@@ -8301,7 +8505,7 @@ class ExportService {
|
|||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
if (totalMessages === 0) {
|
if (totalMessages === 0) {
|
||||||
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' }
|
return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
||||||
@@ -8662,7 +8866,7 @@ class ExportService {
|
|||||||
|
|
||||||
const cleanedMyWxid = conn.cleanedWxid
|
const cleanedMyWxid = conn.cleanedWxid
|
||||||
const isGroup = sessionId.includes('@chatroom')
|
const isGroup = sessionId.includes('@chatroom')
|
||||||
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
const rawMyWxid = this.getConfiguredMyWxid()
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||||
|
|
||||||
@@ -8697,7 +8901,7 @@ class ExportService {
|
|||||||
)
|
)
|
||||||
let totalMessages = collected.rows.length
|
let totalMessages = collected.rows.length
|
||||||
if (totalMessages === 0) {
|
if (totalMessages === 0) {
|
||||||
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' }
|
return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
||||||
@@ -9113,7 +9317,7 @@ class ExportService {
|
|||||||
|
|
||||||
const cleanedMyWxid = conn.cleanedWxid
|
const cleanedMyWxid = conn.cleanedWxid
|
||||||
const isGroup = sessionId.includes('@chatroom')
|
const isGroup = sessionId.includes('@chatroom')
|
||||||
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
const rawMyWxid = this.getConfiguredMyWxid()
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||||
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
|
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
|
||||||
@@ -9152,7 +9356,7 @@ class ExportService {
|
|||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
if (collected.rows.length === 0) {
|
if (collected.rows.length === 0) {
|
||||||
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' }
|
return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
|
||||||
}
|
}
|
||||||
const totalMessages = collected.rows.length
|
const totalMessages = collected.rows.length
|
||||||
|
|
||||||
@@ -9948,6 +10152,7 @@ class ExportService {
|
|||||||
pendingSessionIds?: string[]
|
pendingSessionIds?: string[]
|
||||||
successSessionIds?: string[]
|
successSessionIds?: string[]
|
||||||
failedSessionIds?: string[]
|
failedSessionIds?: string[]
|
||||||
|
failedSessionErrors?: Record<string, string>
|
||||||
sessionOutputPaths?: Record<string, string>
|
sessionOutputPaths?: Record<string, string>
|
||||||
error?: string
|
error?: string
|
||||||
}> {
|
}> {
|
||||||
@@ -9955,6 +10160,7 @@ class ExportService {
|
|||||||
let failCount = 0
|
let failCount = 0
|
||||||
const successSessionIds: string[] = []
|
const successSessionIds: string[] = []
|
||||||
const failedSessionIds: string[] = []
|
const failedSessionIds: string[] = []
|
||||||
|
const failedSessionErrors: Record<string, string> = {}
|
||||||
const sessionOutputPaths: Record<string, string> = {}
|
const sessionOutputPaths: Record<string, string> = {}
|
||||||
const progressEmitter = this.createProgressEmitter(onProgress)
|
const progressEmitter = this.createProgressEmitter(onProgress)
|
||||||
let attachMediaTelemetry = false
|
let attachMediaTelemetry = false
|
||||||
@@ -9972,9 +10178,10 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.resetMediaRuntimeState()
|
this.resetMediaRuntimeState()
|
||||||
const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(options)
|
const normalizedOptions = this.normalizeExportOptionsForRun(options)
|
||||||
? { ...options, exportVoiceAsText: false }
|
const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(normalizedOptions)
|
||||||
: options
|
? { ...normalizedOptions, exportVoiceAsText: false }
|
||||||
|
: normalizedOptions
|
||||||
|
|
||||||
const exportMediaEnabled = effectiveOptions.exportMedia === true &&
|
const exportMediaEnabled = effectiveOptions.exportMedia === true &&
|
||||||
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles)
|
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles)
|
||||||
@@ -9982,10 +10189,7 @@ class ExportService {
|
|||||||
if (exportMediaEnabled) {
|
if (exportMediaEnabled) {
|
||||||
this.triggerMediaFileCacheCleanup()
|
this.triggerMediaFileCacheCleanup()
|
||||||
}
|
}
|
||||||
const rawWriteLayout = this.configService.get('exportWriteLayout')
|
const writeLayout = this.resolveExportWriteLayout(effectiveOptions)
|
||||||
const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
|
|
||||||
? rawWriteLayout
|
|
||||||
: 'A'
|
|
||||||
const exportBaseDir = writeLayout === 'A'
|
const exportBaseDir = writeLayout === 'A'
|
||||||
? path.join(outputDir, 'texts')
|
? path.join(outputDir, 'texts')
|
||||||
: outputDir
|
: outputDir
|
||||||
@@ -10020,7 +10224,6 @@ class ExportService {
|
|||||||
const queue = [...sessionIds]
|
const queue = [...sessionIds]
|
||||||
let pauseRequested = false
|
let pauseRequested = false
|
||||||
let stopRequested = false
|
let stopRequested = false
|
||||||
const emptySessionIds = new Set<string>()
|
|
||||||
const sessionMessageCountHints = new Map<string, number>()
|
const sessionMessageCountHints = new Map<string, number>()
|
||||||
const sessionLatestTimestampHints = new Map<string, number>()
|
const sessionLatestTimestampHints = new Map<string, number>()
|
||||||
const exportStatsCacheKey = this.buildExportStatsCacheKey(sessionIds, effectiveOptions, conn.cleanedWxid)
|
const exportStatsCacheKey = this.buildExportStatsCacheKey(sessionIds, effectiveOptions, conn.cleanedWxid)
|
||||||
@@ -10033,17 +10236,12 @@ class ExportService {
|
|||||||
if (Number.isFinite(snapshot.lastTimestamp) && Number(snapshot.lastTimestamp) > 0) {
|
if (Number.isFinite(snapshot.lastTimestamp) && Number(snapshot.lastTimestamp) > 0) {
|
||||||
sessionLatestTimestampHints.set(sessionId, Math.floor(Number(snapshot.lastTimestamp)))
|
sessionLatestTimestampHints.set(sessionId, Math.floor(Number(snapshot.lastTimestamp)))
|
||||||
}
|
}
|
||||||
if (snapshot.totalCount <= 0) {
|
|
||||||
emptySessionIds.add(sessionId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const canUseSessionSnapshotHints = isTextContentBatchExport &&
|
const canUseSessionSnapshotHints = isTextContentBatchExport &&
|
||||||
this.isUnboundedDateRange(effectiveOptions.dateRange) &&
|
this.isUnboundedDateRange(effectiveOptions.dateRange) &&
|
||||||
!String(effectiveOptions.senderUsername || '').trim()
|
!String(effectiveOptions.senderUsername || '').trim()
|
||||||
const canFastSkipEmptySessions = !isTextContentBatchExport &&
|
const canFastSkipEmptySessions = false
|
||||||
this.isUnboundedDateRange(effectiveOptions.dateRange) &&
|
|
||||||
!String(effectiveOptions.senderUsername || '').trim()
|
|
||||||
const canTrySkipUnchangedTextSessions = canUseSessionSnapshotHints
|
const canTrySkipUnchangedTextSessions = canUseSessionSnapshotHints
|
||||||
const precheckSessionIds = canFastSkipEmptySessions
|
const precheckSessionIds = canFastSkipEmptySessions
|
||||||
? sessionIds.filter((sessionId) => !sessionMessageCountHints.has(sessionId))
|
? sessionIds.filter((sessionId) => !sessionMessageCountHints.has(sessionId))
|
||||||
@@ -10082,9 +10280,6 @@ class ExportService {
|
|||||||
if (typeof count === 'number' && Number.isFinite(count) && count >= 0) {
|
if (typeof count === 'number' && Number.isFinite(count) && count >= 0) {
|
||||||
sessionMessageCountHints.set(batchSessionId, Math.max(0, Math.floor(count)))
|
sessionMessageCountHints.set(batchSessionId, Math.max(0, Math.floor(count)))
|
||||||
}
|
}
|
||||||
if (typeof count === 'number' && Number.isFinite(count) && count <= 0) {
|
|
||||||
emptySessionIds.add(batchSessionId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10154,6 +10349,7 @@ class ExportService {
|
|||||||
pendingSessionIds: [...queue],
|
pendingSessionIds: [...queue],
|
||||||
successSessionIds,
|
successSessionIds,
|
||||||
failedSessionIds,
|
failedSessionIds,
|
||||||
|
failedSessionErrors,
|
||||||
sessionOutputPaths
|
sessionOutputPaths
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10166,6 +10362,7 @@ class ExportService {
|
|||||||
pendingSessionIds: [...queue],
|
pendingSessionIds: [...queue],
|
||||||
successSessionIds,
|
successSessionIds,
|
||||||
failedSessionIds,
|
failedSessionIds,
|
||||||
|
failedSessionErrors,
|
||||||
sessionOutputPaths
|
sessionOutputPaths
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10177,46 +10374,6 @@ class ExportService {
|
|||||||
const messageCountHint = sessionMessageCountHints.get(sessionId)
|
const messageCountHint = sessionMessageCountHints.get(sessionId)
|
||||||
const latestTimestampHint = sessionLatestTimestampHints.get(sessionId)
|
const latestTimestampHint = sessionLatestTimestampHints.get(sessionId)
|
||||||
|
|
||||||
if (
|
|
||||||
isTextContentBatchExport &&
|
|
||||||
typeof messageCountHint === 'number' &&
|
|
||||||
messageCountHint <= 0
|
|
||||||
) {
|
|
||||||
successCount++
|
|
||||||
successSessionIds.push(sessionId)
|
|
||||||
activeSessionRatios.delete(sessionId)
|
|
||||||
completedCount++
|
|
||||||
emitProgress({
|
|
||||||
current: computeAggregateCurrent(),
|
|
||||||
total: sessionIds.length,
|
|
||||||
currentSession: sessionInfo.displayName,
|
|
||||||
currentSessionId: sessionId,
|
|
||||||
phase: 'complete',
|
|
||||||
phaseLabel: '该会话没有消息,已跳过',
|
|
||||||
estimatedTotalMessages: 0,
|
|
||||||
exportedMessages: 0
|
|
||||||
}, { force: true })
|
|
||||||
return 'done'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emptySessionIds.has(sessionId)) {
|
|
||||||
successCount++
|
|
||||||
successSessionIds.push(sessionId)
|
|
||||||
activeSessionRatios.delete(sessionId)
|
|
||||||
completedCount++
|
|
||||||
emitProgress({
|
|
||||||
current: computeAggregateCurrent(),
|
|
||||||
total: sessionIds.length,
|
|
||||||
currentSession: sessionInfo.displayName,
|
|
||||||
currentSessionId: sessionId,
|
|
||||||
phase: 'complete',
|
|
||||||
phaseLabel: '该会话没有消息,已跳过',
|
|
||||||
estimatedTotalMessages: 0,
|
|
||||||
exportedMessages: 0
|
|
||||||
}, { force: true })
|
|
||||||
return 'done'
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionProgress = (progress: ExportProgress) => {
|
const sessionProgress = (progress: ExportProgress) => {
|
||||||
const phaseTotal = Number.isFinite(progress.total) && progress.total > 0 ? progress.total : 100
|
const phaseTotal = Number.isFinite(progress.total) && progress.total > 0 ? progress.total : 100
|
||||||
const phaseCurrent = Number.isFinite(progress.current) ? progress.current : 0
|
const phaseCurrent = Number.isFinite(progress.current) ? progress.current : 0
|
||||||
@@ -10339,6 +10496,7 @@ class ExportService {
|
|||||||
} else {
|
} else {
|
||||||
failCount++
|
failCount++
|
||||||
failedSessionIds.push(sessionId)
|
failedSessionIds.push(sessionId)
|
||||||
|
failedSessionErrors[sessionId] = result.error || '导出失败'
|
||||||
console.error(`导出 ${sessionId} 失败:`, result.error)
|
console.error(`导出 ${sessionId} 失败:`, result.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10433,6 +10591,7 @@ class ExportService {
|
|||||||
pendingSessionIds,
|
pendingSessionIds,
|
||||||
successSessionIds,
|
successSessionIds,
|
||||||
failedSessionIds,
|
failedSessionIds,
|
||||||
|
failedSessionErrors,
|
||||||
sessionOutputPaths
|
sessionOutputPaths
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10445,6 +10604,7 @@ class ExportService {
|
|||||||
pendingSessionIds,
|
pendingSessionIds,
|
||||||
successSessionIds,
|
successSessionIds,
|
||||||
failedSessionIds,
|
failedSessionIds,
|
||||||
|
failedSessionErrors,
|
||||||
sessionOutputPaths
|
sessionOutputPaths
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10458,7 +10618,20 @@ class ExportService {
|
|||||||
}, { force: true })
|
}, { force: true })
|
||||||
progressEmitter.flush()
|
progressEmitter.flush()
|
||||||
|
|
||||||
return { success: true, successCount, failCount, successSessionIds, failedSessionIds, sessionOutputPaths }
|
const allFailed = successCount === 0 && failCount > 0
|
||||||
|
const failureSummary = allFailed
|
||||||
|
? Object.values(failedSessionErrors).slice(0, 3).join(';') || '所有会话导出失败'
|
||||||
|
: undefined
|
||||||
|
return {
|
||||||
|
success: !allFailed,
|
||||||
|
successCount,
|
||||||
|
failCount,
|
||||||
|
successSessionIds,
|
||||||
|
failedSessionIds,
|
||||||
|
failedSessionErrors,
|
||||||
|
sessionOutputPaths,
|
||||||
|
error: failureSummary
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
progressEmitter.flush()
|
progressEmitter.flush()
|
||||||
return { success: false, successCount, failCount, error: String(e) }
|
return { success: false, successCount, failCount, error: String(e) }
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { app, BrowserWindow } from 'electron'
|
|
||||||
import { basename, dirname, extname, join } from 'path'
|
import { basename, dirname, extname, join } from 'path'
|
||||||
import { pathToFileURL } from 'url'
|
import { pathToFileURL } from 'url'
|
||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
|
||||||
@@ -8,6 +7,7 @@ import crypto from 'crypto'
|
|||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { decryptDatViaNative, nativeAddonLocation } from './nativeImageDecrypt'
|
import { decryptDatViaNative, nativeAddonLocation } from './nativeImageDecrypt'
|
||||||
|
import { getElectronBrowserWindow, getPathFallback, isElectronAppPackaged } from './electronRuntime'
|
||||||
|
|
||||||
// 获取 ffmpeg-static 的路径
|
// 获取 ffmpeg-static 的路径
|
||||||
function getStaticFfmpegPath(): string | null {
|
function getStaticFfmpegPath(): string | null {
|
||||||
@@ -35,7 +35,7 @@ function getStaticFfmpegPath(): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 方法3: 打包后的路径
|
// 方法3: 打包后的路径
|
||||||
if (app?.isPackaged) {
|
if (isElectronAppPackaged()) {
|
||||||
const resourcesPath = process.resourcesPath
|
const resourcesPath = process.resourcesPath
|
||||||
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||||
if (existsSync(packedPath)) {
|
if (existsSync(packedPath)) {
|
||||||
@@ -81,6 +81,7 @@ export class ImageDecryptService {
|
|||||||
private pending = new Map<string, Promise<DecryptResult>>()
|
private pending = new Map<string, Promise<DecryptResult>>()
|
||||||
private updateFlags = new Map<string, boolean>()
|
private updateFlags = new Map<string, boolean>()
|
||||||
private nativeLogged = false
|
private nativeLogged = false
|
||||||
|
private runtimeConfig: { dbPath?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null = null
|
||||||
private datNameScanMissAt = new Map<string, number>()
|
private datNameScanMissAt = new Map<string, number>()
|
||||||
private readonly datNameScanMissTtlMs = 1200
|
private readonly datNameScanMissTtlMs = 1200
|
||||||
private readonly accountDirCache = new Map<string, string>()
|
private readonly accountDirCache = new Map<string, string>()
|
||||||
@@ -99,6 +100,32 @@ export class ImageDecryptService {
|
|||||||
return this.shouldEmitImageEvents(payload)
|
return this.shouldEmitImageEvents(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setRuntimeConfig(config: { dbPath?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void {
|
||||||
|
this.runtimeConfig = config
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConfiguredDbPath(): string {
|
||||||
|
return String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConfiguredMyWxid(): string {
|
||||||
|
return String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConfiguredImageKeys(): { xorKey: unknown; aesKey: string } {
|
||||||
|
const runtimeImageXorKey = this.runtimeConfig?.imageXorKey
|
||||||
|
const hasRuntimeXorKey = runtimeImageXorKey !== undefined && runtimeImageXorKey !== null && String(runtimeImageXorKey).trim() !== ''
|
||||||
|
const runtimeAesKey = String(this.runtimeConfig?.imageAesKey || '').trim()
|
||||||
|
if (hasRuntimeXorKey || runtimeAesKey) {
|
||||||
|
const fallback = this.configService.getImageKeysForCurrentWxid()
|
||||||
|
return {
|
||||||
|
xorKey: hasRuntimeXorKey ? runtimeImageXorKey : fallback.xorKey,
|
||||||
|
aesKey: runtimeAesKey || fallback.aesKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.configService.getImageKeysForCurrentWxid()
|
||||||
|
}
|
||||||
|
|
||||||
private logInfo(message: string, meta?: Record<string, unknown>): void {
|
private logInfo(message: string, meta?: Record<string, unknown>): void {
|
||||||
if (!this.configService.get('logEnabled')) return
|
if (!this.configService.get('logEnabled')) return
|
||||||
const timestamp = new Date().toISOString()
|
const timestamp = new Date().toISOString()
|
||||||
@@ -266,8 +293,8 @@ export class ImageDecryptService {
|
|||||||
)
|
)
|
||||||
if (normalizedList.length === 0) return
|
if (normalizedList.length === 0) return
|
||||||
|
|
||||||
const wxid = this.configService.get('myWxid')
|
const wxid = this.getConfiguredMyWxid()
|
||||||
const dbPath = this.configService.get('dbPath')
|
const dbPath = this.getConfiguredDbPath()
|
||||||
if (!wxid || !dbPath) return
|
if (!wxid || !dbPath) return
|
||||||
|
|
||||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||||
@@ -294,8 +321,8 @@ export class ImageDecryptService {
|
|||||||
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true })
|
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true })
|
||||||
this.emitDecryptProgress(payload, cacheKey, 'locating', 14, 'running')
|
this.emitDecryptProgress(payload, cacheKey, 'locating', 14, 'running')
|
||||||
try {
|
try {
|
||||||
const wxid = this.configService.get('myWxid')
|
const wxid = this.getConfiguredMyWxid()
|
||||||
const dbPath = this.configService.get('dbPath')
|
const dbPath = this.getConfiguredDbPath()
|
||||||
if (!wxid || !dbPath) {
|
if (!wxid || !dbPath) {
|
||||||
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
|
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
|
||||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失')
|
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失')
|
||||||
@@ -404,7 +431,7 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 优先使用当前 wxid 对应的密钥,找不到则回退到全局配置
|
// 优先使用当前 wxid 对应的密钥,找不到则回退到全局配置
|
||||||
const imageKeys = this.configService.getImageKeysForCurrentWxid()
|
const imageKeys = this.getConfiguredImageKeys()
|
||||||
const xorKeyRaw = imageKeys.xorKey
|
const xorKeyRaw = imageKeys.xorKey
|
||||||
// 支持十六进制格式(如 0x53)和十进制格式
|
// 支持十六进制格式(如 0x53)和十进制格式
|
||||||
let xorKey: number
|
let xorKey: number
|
||||||
@@ -427,7 +454,7 @@ export class ImageDecryptService {
|
|||||||
const aesKeyText = typeof aesKeyRaw === 'string' ? aesKeyRaw.trim() : ''
|
const aesKeyText = typeof aesKeyRaw === 'string' ? aesKeyRaw.trim() : ''
|
||||||
const aesKeyForNative = aesKeyText || undefined
|
const aesKeyForNative = aesKeyText || undefined
|
||||||
|
|
||||||
this.logInfo('开始解密DAT文件(仅Rust原生)', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) })
|
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) })
|
||||||
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running')
|
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running')
|
||||||
const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative)
|
const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative)
|
||||||
if (!nativeResult) {
|
if (!nativeResult) {
|
||||||
@@ -527,8 +554,8 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveCurrentAccountDir(): string | null {
|
private resolveCurrentAccountDir(): string | null {
|
||||||
const wxid = this.configService.get('myWxid')
|
const wxid = this.getConfiguredMyWxid()
|
||||||
const dbPath = this.configService.get('dbPath')
|
const dbPath = this.getConfiguredDbPath()
|
||||||
if (!wxid || !dbPath) return null
|
if (!wxid || !dbPath) return null
|
||||||
return this.resolveAccountDir(dbPath, wxid)
|
return this.resolveAccountDir(dbPath, wxid)
|
||||||
}
|
}
|
||||||
@@ -1448,7 +1475,7 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
private getActiveWindowsSafely(): Array<{ isDestroyed: () => boolean; webContents: { send: (channel: string, payload: unknown) => void } }> {
|
private getActiveWindowsSafely(): Array<{ isDestroyed: () => boolean; webContents: { send: (channel: string, payload: unknown) => void } }> {
|
||||||
try {
|
try {
|
||||||
const getter = (BrowserWindow as unknown as { getAllWindows?: () => any[] } | undefined)?.getAllWindows
|
const getter = (getElectronBrowserWindow() as { getAllWindows?: () => any[] } | undefined)?.getAllWindows
|
||||||
if (typeof getter !== 'function') return []
|
if (typeof getter !== 'function') return []
|
||||||
const windows = getter()
|
const windows = getter()
|
||||||
if (!Array.isArray(windows)) return []
|
if (!Array.isArray(windows)) return []
|
||||||
@@ -1551,7 +1578,117 @@ export class ImageDecryptService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
if (result) return result
|
||||||
|
const fallback = this.tryDecryptDatWithJs(datPath, xorKey, aesKey)
|
||||||
|
if (fallback) {
|
||||||
|
this.logInfo('JS DAT 解密 fallback 已启用', { datPath, ext: fallback.ext })
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryDecryptDatWithJs(
|
||||||
|
datPath: string,
|
||||||
|
xorKey: number,
|
||||||
|
aesKey?: string
|
||||||
|
): { data: Buffer; ext: string; isWxgf: boolean } | null {
|
||||||
|
try {
|
||||||
|
const encrypted = readFileSync(datPath)
|
||||||
|
const directExt = this.detectImageExtension(encrypted)
|
||||||
|
if (directExt) return { data: encrypted, ext: directExt, isWxgf: false }
|
||||||
|
|
||||||
|
const candidates: Buffer[] = []
|
||||||
|
const aesKeyText = String(aesKey || '').trim()
|
||||||
|
const datVersion = this.getDatVersion(encrypted)
|
||||||
|
if (datVersion === 2 && aesKeyText.length >= 16) {
|
||||||
|
try {
|
||||||
|
candidates.push(this.decryptDatV4WithJs(encrypted, xorKey, Buffer.from(aesKeyText, 'ascii').subarray(0, 16)))
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
if (datVersion !== 2) {
|
||||||
|
candidates.push(this.decryptDatV3WithJs(encrypted, xorKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const ext = this.detectImageExtension(candidate)
|
||||||
|
if (ext) return { data: candidate, ext, isWxgf: false }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logError('JS DAT 解密 fallback 失败', error, { datPath })
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private decryptDatV3WithJs(data: Buffer, xorKey: number): Buffer {
|
||||||
|
const output = Buffer.allocUnsafe(data.length)
|
||||||
|
for (let i = 0; i < data.length; i += 1) {
|
||||||
|
output[i] = data[i] ^ xorKey
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
private decryptDatV4WithJs(data: Buffer, xorKey: number, aesKey: Buffer): Buffer {
|
||||||
|
if (data.length < 0x0f) {
|
||||||
|
throw new Error('dat file too small')
|
||||||
|
}
|
||||||
|
const header = data.subarray(0, 0x0f)
|
||||||
|
const payload = data.subarray(0x0f)
|
||||||
|
const aesSize = this.readInt32LeSafe(header, 6)
|
||||||
|
const xorSize = this.readInt32LeSafe(header, 10)
|
||||||
|
const remainder = ((aesSize % 16) + 16) % 16
|
||||||
|
const alignedAesSize = aesSize + (16 - remainder)
|
||||||
|
if (alignedAesSize > payload.length) throw new Error('invalid aes size')
|
||||||
|
|
||||||
|
const aesData = payload.subarray(0, alignedAesSize)
|
||||||
|
|
||||||
|
let plainAes = Buffer.alloc(0)
|
||||||
|
if (aesData.length > 0) {
|
||||||
|
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, Buffer.alloc(0))
|
||||||
|
decipher.setAutoPadding(false)
|
||||||
|
plainAes = this.strictRemovePkcs7Padding(Buffer.concat([decipher.update(aesData), decipher.final()]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = payload.subarray(alignedAesSize)
|
||||||
|
if (xorSize < 0 || xorSize > remaining.length) throw new Error('invalid xor size')
|
||||||
|
|
||||||
|
let rawData = Buffer.alloc(0)
|
||||||
|
let decodedXor = Buffer.alloc(0)
|
||||||
|
if (xorSize > 0) {
|
||||||
|
const rawLength = remaining.length - xorSize
|
||||||
|
if (rawLength < 0) throw new Error('invalid raw size')
|
||||||
|
rawData = remaining.subarray(0, rawLength)
|
||||||
|
const xorData = remaining.subarray(rawLength)
|
||||||
|
decodedXor = Buffer.allocUnsafe(xorData.length)
|
||||||
|
for (let i = 0; i < xorData.length; i += 1) {
|
||||||
|
decodedXor[i] = xorData[i] ^ xorKey
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rawData = remaining
|
||||||
|
}
|
||||||
|
return Buffer.concat([plainAes, rawData, decodedXor])
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDatVersion(data: Buffer): number {
|
||||||
|
if (data.length < 6) return 0
|
||||||
|
const sigV1 = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07])
|
||||||
|
const sigV2 = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||||
|
if (data.subarray(0, 6).equals(sigV1)) return 1
|
||||||
|
if (data.subarray(0, 6).equals(sigV2)) return 2
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private readInt32LeSafe(buffer: Buffer, offset: number): number {
|
||||||
|
if (offset < 0 || offset + 4 > buffer.length) throw new Error('invalid int32 offset')
|
||||||
|
return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
private strictRemovePkcs7Padding(data: Buffer): Buffer {
|
||||||
|
if (data.length === 0) throw new Error('empty decrypted data')
|
||||||
|
const pad = data[data.length - 1]
|
||||||
|
if (pad <= 0 || pad > 16 || pad > data.length) throw new Error('invalid pkcs7 padding')
|
||||||
|
for (let i = data.length - pad; i < data.length; i += 1) {
|
||||||
|
if (data[i] !== pad) throw new Error('invalid pkcs7 padding')
|
||||||
|
}
|
||||||
|
return data.subarray(0, data.length - pad)
|
||||||
}
|
}
|
||||||
|
|
||||||
private detectImageExtension(buffer: Buffer): string | null {
|
private detectImageExtension(buffer: Buffer): string | null {
|
||||||
@@ -2054,14 +2191,7 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getElectronPath(name: 'userData' | 'documents' | 'temp'): string | null {
|
private getElectronPath(name: 'userData' | 'documents' | 'temp'): string | null {
|
||||||
try {
|
return getPathFallback(name)
|
||||||
const getter = (app as unknown as { getPath?: (n: string) => string } | undefined)?.getPath
|
|
||||||
if (typeof getter !== 'function') return null
|
|
||||||
const value = getter(name)
|
|
||||||
return typeof value === 'string' && value.trim() ? value : null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getUserDataPath(): string {
|
private getUserDataPath(): string {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
import { app } from 'electron'
|
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
export interface SessionMessageCacheEntry {
|
export interface SessionMessageCacheEntry {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
|
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
|
||||||
import { pathToFileURL } from 'url'
|
import { pathToFileURL } from 'url'
|
||||||
import { app } from 'electron'
|
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
import { getPathFallback } from './electronRuntime'
|
||||||
|
|
||||||
export interface VideoInfo {
|
export interface VideoInfo {
|
||||||
videoUrl?: string // 视频文件路径(用于 readFile)
|
videoUrl?: string // 视频文件路径(用于 readFile)
|
||||||
@@ -45,7 +45,7 @@ class VideoService {
|
|||||||
try {
|
try {
|
||||||
const timestamp = new Date().toISOString()
|
const timestamp = new Date().toISOString()
|
||||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||||
const logDir = join(app.getPath('userData'), 'logs')
|
const logDir = join(getPathFallback('userData'), 'logs')
|
||||||
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
|
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
|
||||||
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
|
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { app } from 'electron'
|
|
||||||
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream, openSync, writeSync, closeSync } from 'fs'
|
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream, openSync, writeSync, closeSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import * as https from 'https'
|
import * as https from 'https'
|
||||||
import * as http from 'http'
|
import * as http from 'http'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
|
import { getPathFallback } from './electronRuntime'
|
||||||
|
|
||||||
// Sherpa-onnx 类型定义
|
// Sherpa-onnx 类型定义
|
||||||
type OfflineRecognizer = any
|
type OfflineRecognizer = any
|
||||||
@@ -91,7 +91,7 @@ export class VoiceTranscribeService {
|
|||||||
private resolveModelDir(): string {
|
private resolveModelDir(): string {
|
||||||
const configured = this.configService.get('whisperModelDir') as string | undefined
|
const configured = this.configService.get('whisperModelDir') as string | undefined
|
||||||
if (configured) return configured
|
if (configured) return configured
|
||||||
return join(app.getPath('documents'), 'WeFlow', 'models', 'sensevoice')
|
return join(getPathFallback('documents'), 'WeFlow', 'models', 'sensevoice')
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveModelPath(fileName: string): string {
|
private resolveModelPath(fileName: string): string {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5192,6 +5192,7 @@ function ExportPage() {
|
|||||||
exportConcurrency: sourceOptions.exportConcurrency,
|
exportConcurrency: sourceOptions.exportConcurrency,
|
||||||
fileNamingMode: exportDefaultFileNamingMode,
|
fileNamingMode: exportDefaultFileNamingMode,
|
||||||
sessionLayout,
|
sessionLayout,
|
||||||
|
exportWriteLayout: writeLayout,
|
||||||
sessionNameWithTypePrefix,
|
sessionNameWithTypePrefix,
|
||||||
dateRange: sourceOptions.useAllTime
|
dateRange: sourceOptions.useAllTime
|
||||||
? null
|
? null
|
||||||
@@ -6008,9 +6009,10 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...task.template.optionTemplate,
|
...task.template.optionTemplate,
|
||||||
|
exportWriteLayout: task.template.optionTemplate.exportWriteLayout || writeLayout,
|
||||||
dateRange
|
dateRange
|
||||||
}
|
}
|
||||||
}, [])
|
}, [writeLayout])
|
||||||
|
|
||||||
const enqueueAutomationTask = useCallback((
|
const enqueueAutomationTask = useCallback((
|
||||||
task: ExportAutomationTask,
|
task: ExportAutomationTask,
|
||||||
|
|||||||
2
src/types/electron.d.ts
vendored
2
src/types/electron.d.ts
vendored
@@ -1101,6 +1101,7 @@ export interface ElectronAPI {
|
|||||||
pendingSessionIds?: string[]
|
pendingSessionIds?: string[]
|
||||||
successSessionIds?: string[]
|
successSessionIds?: string[]
|
||||||
failedSessionIds?: string[]
|
failedSessionIds?: string[]
|
||||||
|
failedSessionErrors?: Record<string, string>
|
||||||
sessionOutputPaths?: Record<string, string>
|
sessionOutputPaths?: Record<string, string>
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
@@ -1269,6 +1270,7 @@ export interface ExportOptions {
|
|||||||
txtColumns?: string[]
|
txtColumns?: string[]
|
||||||
fileNamingMode?: 'classic' | 'date-range'
|
fileNamingMode?: 'classic' | 'date-range'
|
||||||
sessionLayout?: 'shared' | 'per-session'
|
sessionLayout?: 'shared' | 'per-session'
|
||||||
|
exportWriteLayout?: 'A' | 'B' | 'C'
|
||||||
sessionNameWithTypePrefix?: boolean
|
sessionNameWithTypePrefix?: boolean
|
||||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||||
exportConcurrency?: number
|
exportConcurrency?: number
|
||||||
|
|||||||
Reference in New Issue
Block a user