This commit is contained in:
cc committed 2026-07-01 15:34:37 +08:00
1 parent 99dcb6e43b
commit 301cb5abce
48 files changed
+5444 -1015

No files matched your search

+209 -143
View File
@@ -20,7 +20,7 @@ import { EXPORT_HTML_STYLES } from '../../exportHtmlStyles'
import { LRUCache } from '../../../utils/LRUCache.js'
import { normalizeTimestampSeconds, formatTimestamp, formatIsoTimestamp, parseCompactDateTimeDigitsToSeconds, parseDateTimeTextToSeconds, normalizeExportDateRange, normalizeRowTimestampSeconds, getTimestampSecondsFromRow } from '../../export/utils/timestamp';
import { escapeHtml, escapeAttribute, renderMultilineText, decodeHtmlEntities } from '../../export/utils/htmlEscape';
import { sanitizeExportFileNamePart, resolveFileAttachmentExtensionDir, normalizeFileNamingMode, formatDateTokenBySeconds, buildDateRangeFileNamePart, buildSessionExportBaseName, reserveUniqueOutputPath } from '../../export/utils/fileNaming';
import { sanitizeExportFileNamePart, resolveFileAttachmentExtensionDir, normalizeFileNamingMode, normalizeExportConflictStrategy, formatDateTokenBySeconds, buildDateRangeFileNamePart, buildSessionExportBaseName, reserveUniqueOutputPath } from '../../export/utils/fileNaming';
import { extractXmlValue, extractXmlAttribute, extractAppMessageType, normalizeAppMessageContent } from '../../export/parsers/xmlExtractor';
import { decodeMessageContent, decodeMaybeCompressed, decodeBinaryContent, looksLikeHex, looksLikeBase64 } from '../../export/parsers/contentDecoder';
import { parseVoipMessage } from '../../export/parsers/voipParser';
@@ -177,17 +177,24 @@ export class ExportContext {
const normalizedDateRange = normalizeExportDateRange(options.dateRange);
const normalizedMaxFileSizeMb = this.normalizeMaxFileSizeMb(options.maxFileSizeMb);
const normalizedWriteLayout = this.resolveExportWriteLayout(options);
const exportMedia = options.exportMedia === true;
const mediaContentType = this.getMediaContentType(options);
const exportMedia = options.exportMedia === true || this.isMediaOptionSelected(options) || mediaContentType !== null;
const resolveMediaOption = (key: keyof Pick<ExportOptions, 'exportImages' | 'exportVoices' | 'exportVideos' | 'exportEmojis' | 'exportFiles'>, type: MediaContentType): boolean | undefined => {
if (mediaContentType) return mediaContentType === type
return exportMedia ? options[key] !== false : options[key]
}
return {
...options,
dateRange: normalizedDateRange,
maxFileSizeMb: normalizedMaxFileSizeMb,
exportConflictStrategy: this.resolveExportConflictStrategy(options),
exportWriteLayout: normalizedWriteLayout,
exportImages: exportMedia ? options.exportImages !== false : options.exportImages,
exportVoices: exportMedia ? options.exportVoices !== false : options.exportVoices,
exportVideos: exportMedia ? options.exportVideos !== false : options.exportVideos,
exportEmojis: exportMedia ? options.exportEmojis !== false : options.exportEmojis,
exportFiles: exportMedia ? options.exportFiles !== false : options.exportFiles
exportMedia,
exportImages: resolveMediaOption('exportImages', 'image'),
exportVoices: resolveMediaOption('exportVoices', 'voice'),
exportVideos: resolveMediaOption('exportVideos', 'video'),
exportEmojis: resolveMediaOption('exportEmojis', 'emoji'),
exportFiles: resolveMediaOption('exportFiles', 'file')
}
}
@@ -200,6 +207,14 @@ export class ExportContext {
: 'B'
}
public resolveExportConflictStrategy(options?: Pick<ExportOptions, 'exportConflictStrategy'> | null): 'incremental' | 'overwrite' | 'rename' {
return normalizeExportConflictStrategy(options?.exportConflictStrategy)
}
public shouldReuseExistingExportFile(options?: Pick<ExportOptions, 'exportConflictStrategy'> | null): boolean {
return this.resolveExportConflictStrategy(options) === 'incremental'
}
public resolveExportPathStyle(options?: Pick<ExportOptions, 'exportPathStyle'> | null): 'posix' | 'windows' {
const style = options?.exportPathStyle
if (style === 'posix') return 'posix'
@@ -521,8 +536,15 @@ export class ExportContext {
return code === 'EXDEV' || code === 'EPERM' || code === 'EACCES' || code === 'EINVAL' || code === 'ENOSYS' || code === 'ENOTSUP'
}
private async copyMediaWithCacheAndDedup(kind: 'image' | 'video' | 'emoji', sourcePath: string, destPath: string, control?: ExportTaskControl): Promise<{ success: boolean; code?: string }> {
private async copyMediaWithCacheAndDedup(kind: 'image' | 'video' | 'emoji', sourcePath: string, destPath: string, control?: ExportTaskControl, options?: Pick<ExportOptions, 'exportConflictStrategy'>): Promise<{ success: boolean; code?: string }> {
const existedBeforeCopy = await pathExists(destPath);
if (existedBeforeCopy && this.shouldReuseExistingExportFile(options)) {
this.noteMediaTelemetry({
doneFiles: 1,
dedupReuseFiles: 1
})
return { success: true }
}
const resolved = await this.resolvePreferredMediaSource(kind, sourcePath);
if (resolved.cacheHit) {
this.noteMediaTelemetry({ cacheHitFiles: 1 })
@@ -649,9 +671,12 @@ export class ExportContext {
}
}
private isMediaExportEnabled(options: ExportOptions): boolean {
return options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles)
private isMediaOptionSelected(options: Pick<ExportOptions, 'exportImages' | 'exportVoices' | 'exportVideos' | 'exportEmojis' | 'exportFiles'>): boolean {
return Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles)
}
public isMediaExportEnabled(options: ExportOptions): boolean {
return this.getMediaContentType(options) !== null || this.isMediaOptionSelected(options)
}
public isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean {
@@ -2706,6 +2731,46 @@ export class ExportContext {
/**
* 导出媒体文件到指定目录
*/
private resolveWeliveMediaPath(msg: any): string {
const nestedMedia = msg?.media && typeof msg.media === 'object' ? msg.media : null
const candidates = [
msg?.mediaPath,
msg?.media_path,
msg?.videoPath,
msg?.video_path,
msg?.imagePath,
msg?.image_path,
msg?.voicePath,
msg?.voice_path,
msg?.filePath,
msg?.file_path,
msg?.localPath,
msg?.local_path,
msg?.sourcePath,
msg?.source_path,
msg?.fullPath,
msg?.full_path,
msg?.absolutePath,
msg?.absolute_path,
nestedMedia?.path,
nestedMedia?.mediaPath,
nestedMedia?.media_path,
nestedMedia?.videoPath,
nestedMedia?.video_path,
nestedMedia?.localPath,
nestedMedia?.local_path,
nestedMedia?.filePath,
nestedMedia?.file_path
]
for (const candidate of candidates) {
const value = String(candidate || '').trim()
if (value) return value
}
return ''
}
public async exportMediaForMessage(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, options: {
exportImages?: boolean
exportVoices?: boolean
@@ -2714,13 +2779,14 @@ export class ExportContext {
exportFiles?: boolean
maxFileSizeMb?: number
exportVoiceAsText?: boolean
exportConflictStrategy?: ExportOptions['exportConflictStrategy']
includeVideoPoster?: boolean
includeVoiceWithTranscript?: boolean
dirCache?: Set<string>
control?: ExportTaskControl
}): Promise<MediaExportItem | null> {
const localType = msg.localType;
const weliveMediaPath = String(msg?.mediaPath || msg?.media_path || '').trim()
const weliveMediaPath = this.resolveWeliveMediaPath(msg)
if (weliveMediaPath) {
const adopted = await this.adoptWeliveExportedMedia(msg, weliveMediaPath, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
@@ -2729,6 +2795,7 @@ export class ExportContext {
exportEmojis: options.exportEmojis,
exportFiles: options.exportFiles,
maxFileSizeMb: options.maxFileSizeMb,
exportConflictStrategy: options.exportConflictStrategy,
dirCache: options.dirCache,
control: options.control
})
@@ -2742,7 +2809,8 @@ export class ExportContext {
mediaRootDir,
mediaRelativePrefix,
options.dirCache,
options.control
options.control,
options
)
if (result) {
}
@@ -2751,7 +2819,7 @@ export class ExportContext {
if (localType === 34) {
if (options.exportVoices) {
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control)
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control, options)
}
if (options.exportVoiceAsText) {
return null
@@ -2759,7 +2827,7 @@ export class ExportContext {
}
if (localType === 47 && options.exportEmojis) {
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control)
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control, options)
if (result) {
}
return result
@@ -2773,7 +2841,8 @@ export class ExportContext {
mediaRelativePrefix,
options.dirCache,
options.includeVideoPoster === true,
options.control
options.control,
options
)
}
@@ -2784,7 +2853,8 @@ export class ExportContext {
mediaRelativePrefix,
options.maxFileSizeMb,
options.dirCache,
options.control
options.control,
options
)
}
@@ -2798,6 +2868,7 @@ export class ExportContext {
exportEmojis?: boolean
exportFiles?: boolean
maxFileSizeMb?: number
exportConflictStrategy?: ExportOptions['exportConflictStrategy']
dirCache?: Set<string>
control?: ExportTaskControl
}): Promise<MediaExportItem | null> {
@@ -2846,14 +2917,18 @@ export class ExportContext {
const destPath = path.join(targetDir, destFileName)
if (path.resolve(sourcePath) !== path.resolve(destPath)) {
if (kind === 'image' || kind === 'emoji' || kind === 'video') {
const copied = await this.copyMediaWithCacheAndDedup(kind, sourcePath, destPath, options.control)
const copied = await this.copyMediaWithCacheAndDedup(kind, sourcePath, destPath, options.control, options)
if (!copied.success) return null
} else {
const existedBeforeCopy = await pathExists(destPath)
const copied = await copyFileOptimized(sourcePath, destPath)
if (!copied.success) return null
if (!existedBeforeCopy) options.control?.recordCreatedFile?.(destPath)
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
if (existedBeforeCopy && this.shouldReuseExistingExportFile(options)) {
this.noteMediaTelemetry({ doneFiles: 1, dedupReuseFiles: 1 })
} else {
const copied = await copyFileOptimized(sourcePath, destPath)
if (!copied.success) return null
if (!existedBeforeCopy) options.control?.recordCreatedFile?.(destPath)
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
}
}
}
@@ -2869,7 +2944,7 @@ export class ExportContext {
/**
* 导出图片文件
*/
private async exportImage(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl): Promise<MediaExportItem | null> {
private async exportImage(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl, options?: Pick<ExportOptions, 'exportConflictStrategy'>): Promise<MediaExportItem | null> {
try {
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
await ensureExportDir(imagesDir, control, dirCache)
@@ -2992,6 +3067,14 @@ export class ExportContext {
const fileName = `${messageId}_${imageKey}${ext}`
const destPath = path.join(imagesDir, fileName)
if (await pathExists(destPath) && this.shouldReuseExistingExportFile(options)) {
this.noteMediaTelemetry({ doneFiles: 1, dedupReuseFiles: 1 })
return {
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
kind: 'image'
}
}
const buffer = Buffer.from(base64Data, 'base64')
await this.recordCreatedFileBeforeWrite(destPath, control)
await fs.promises.writeFile(destPath, buffer)
@@ -3013,7 +3096,7 @@ export class ExportContext {
const ext = path.extname(sourcePath) || '.jpg'
const fileName = `${messageId}_${imageKey}${ext}`
const destPath = path.join(imagesDir, fileName)
const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath, control)
const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath, control, options)
if (!copied.success) {
if (copied.code === 'ENOENT') {
console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
@@ -3033,10 +3116,19 @@ export class ExportContext {
}
}
public async preloadMediaLookupCaches(_sessionId: string, messages: any[], options: { exportImages?: boolean; exportVideos?: boolean }, control?: ExportTaskControl): Promise<void> {
public async preloadMediaLookupCaches(sessionId: string, messages: any[], options: { exportImages?: boolean; exportVideos?: boolean }, control?: ExportTaskControl): Promise<void> {
if (!Array.isArray(messages) || messages.length === 0) return
const md5Pattern = /^[a-f0-9]{32}$/i;
const imageMd5Set = new Set<string>();
const videoTokenSet = new Set<string>();
if (options.exportVideos) {
const videoMessages = messages.filter((msg) => Number(msg?.localType || 0) === 43)
if (videoMessages.length > 0) {
await this.backfillMediaFieldsFromMessageDetail(sessionId, videoMessages, new Set([43]), control)
}
}
let scanIndex = 0;
for (const msg of messages) {
if ((scanIndex++ & 0x7f) === 0) {
@@ -3054,12 +3146,27 @@ export class ExportContext {
}
}
if (options.exportVideos && msg?.localType === 43) {
const videoMd5 = String(msg?.videoMd5 || '').trim().toLowerCase()
if (videoMd5) {
videoTokenSet.add(videoMd5)
continue
}
const parsedVideoMd5 = String(videoService.parseVideoMd5(String(msg?.content || '')) || '').trim().toLowerCase()
if (parsedVideoMd5) {
videoTokenSet.add(parsedVideoMd5)
}
}
}
const preloadTasks: Array<Promise<void>> = [];
if (imageMd5Set.size > 0) {
preloadTasks.push(imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set)))
}
if (videoTokenSet.size > 0) {
preloadTasks.push(videoService.getVideoInfoBatch(Array.from(videoTokenSet), { includePoster: false }).then(() => undefined))
}
if (preloadTasks.length === 0) return
await Promise.all(preloadTasks.map((task) => task.catch(() => { })))
@@ -3112,7 +3219,7 @@ export class ExportContext {
/**
* 导出语音文件
*/
private async exportVoice(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl): Promise<MediaExportItem | null> {
private async exportVoice(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl, options?: Pick<ExportOptions, 'exportConflictStrategy'>): Promise<MediaExportItem | null> {
try {
const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices')
await ensureExportDir(voicesDir, control, dirCache)
@@ -3125,8 +3232,8 @@ export class ExportContext {
const fileName = `voice_${safeSession}_${stableKey || msgId}.wav`
const destPath = path.join(voicesDir, fileName)
// 如果已存在则跳过
if (await pathExists(destPath)) {
if (await pathExists(destPath) && this.shouldReuseExistingExportFile(options)) {
this.noteMediaTelemetry({ doneFiles: 1, dedupReuseFiles: 1 })
return {
relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
kind: 'voice'
@@ -3181,7 +3288,7 @@ export class ExportContext {
/**
* 导出表情文件
*/
private async exportEmoji(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl): Promise<MediaExportItem | null> {
private async exportEmoji(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, control?: ExportTaskControl, options?: Pick<ExportOptions, 'exportConflictStrategy'>): Promise<MediaExportItem | null> {
try {
const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis')
await ensureExportDir(emojisDir, control, dirCache)
@@ -3211,7 +3318,7 @@ export class ExportContext {
const key = emojiMd5 || crypto.createHash('md5').update(emojiUrl || localPath || String(msg.localId || '')).digest('hex')
const fileName = `${key}${ext}`
const destPath = path.join(emojisDir, fileName)
const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath, control)
const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath, control, options)
if (!copied.success) return null
return {
@@ -3227,9 +3334,34 @@ export class ExportContext {
/**
* 导出视频文件
*/
private async exportVideo(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, includePoster = false, control?: ExportTaskControl): Promise<MediaExportItem | null> {
private async exportVideo(msg: any, sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, dirCache?: Set<string>, includePoster = false, control?: ExportTaskControl, options?: Pick<ExportOptions, 'exportConflictStrategy'>): Promise<MediaExportItem | null> {
try {
let videoMd5 = String(msg.videoMd5 || '').trim().toLowerCase()
const collectLookupTokens = () => {
const contents = [
msg?.content,
msg?.messageContent,
msg?.message_content,
msg?.rawContent,
msg?.raw_content,
msg?.parsedContent,
msg?.parsed_content
].map((item) => String(item || '')).filter(Boolean)
const tokens = [
msg?.videoMd5,
msg?.video_md5,
msg?.rawMd5,
msg?.raw_md5,
this.extractVideoFileNameFromRow(msg, contents[0] || ''),
this.normalizeVideoFileToken(this.resolveWeliveMediaPath(msg))
]
for (const content of contents) {
tokens.push(videoService.parseVideoMd5(content))
tokens.push(this.extractVideoMd5(content))
}
return Array.from(new Set(tokens
.map((token) => this.normalizeVideoFileToken(token))
.filter((token): token is string => Boolean(token))))
}
const resolveVideoInfo = async (token: string) => {
if (!token) return null
const videoInfo = await videoService.getVideoInfo(token, { includePoster })
@@ -3237,13 +3369,19 @@ export class ExportContext {
return videoInfo
}
let videoInfo = await resolveVideoInfo(videoMd5)
let videoInfo = null
for (const token of collectLookupTokens()) {
videoInfo = await resolveVideoInfo(token)
if (videoInfo) break
}
if (!videoInfo) {
const localId = Number(msg?.localId || 0)
if (Number.isFinite(localId) && localId > 0) {
await this.backfillMediaFieldsFromMessageDetail(sessionId, [msg], new Set([43]), undefined, { force: true })
videoMd5 = String(msg.videoMd5 || '').trim().toLowerCase()
videoInfo = await resolveVideoInfo(videoMd5)
for (const token of collectLookupTokens()) {
videoInfo = await resolveVideoInfo(token)
if (videoInfo) break
}
}
}
if (!videoInfo) return null
@@ -3256,7 +3394,7 @@ export class ExportContext {
const fileName = path.basename(sourcePath)
const destPath = path.join(videosDir, fileName)
const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath, control)
const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath, control, options)
if (!copied.success) return null
return {
@@ -3398,12 +3536,19 @@ export class ExportContext {
private extractVideoMd5(content: string): string | undefined {
if (!content) return undefined
const attrMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content);
if (attrMatch) {
return attrMatch[1].toLowerCase()
const attrPatterns = [
/<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i,
/<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i,
/<videomsg[^>]*\snewmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i,
/<videomsg[^>]*\soriginsourcemd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i,
/(?:^|\s)(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/i
]
for (const pattern of attrPatterns) {
const match = pattern.exec(content)
if (match?.[1]) return match[1].toLowerCase()
}
const tagMatch = /<md5>([^<]+)<\/md5>/i.exec(content);
const tagMatch = /<(?:md5|rawmd5|newmd5|originsourcemd5)>([a-fA-F0-9]+)<\/(?:md5|rawmd5|newmd5|originsourcemd5)>/i.exec(content);
return tagMatch?.[1]?.toLowerCase()
}
@@ -3752,7 +3897,7 @@ export class ExportContext {
return candidates
}
private async exportFileAttachment(msg: any, mediaRootDir: string, mediaRelativePrefix: string, maxFileSizeMb?: number, dirCache?: Set<string>, control?: ExportTaskControl): Promise<MediaExportItem | null> {
private async exportFileAttachment(msg: any, mediaRootDir: string, mediaRelativePrefix: string, maxFileSizeMb?: number, dirCache?: Set<string>, control?: ExportTaskControl, options?: Pick<ExportOptions, 'exportConflictStrategy'>): Promise<MediaExportItem | null> {
try {
const fileNameRaw = String(msg?.fileName || '').trim()
if (!fileNameRaw) return null
@@ -3804,6 +3949,14 @@ export class ExportContext {
const destFileName = `${messageId}_${safeBaseName}`
const destPath = path.join(fileDir, destFileName)
const existedBeforeCopy = await pathExists(destPath)
if (existedBeforeCopy && this.shouldReuseExistingExportFile(options)) {
this.noteMediaTelemetry({ doneFiles: 1, dedupReuseFiles: 1 })
return {
relativePath: path.posix.join(mediaRelativePrefix, 'file', fileExtDir, destFileName),
kind: 'file'
}
}
const copied = await copyFileOptimized(selected.sourcePath, destPath)
if (!copied.success) {
this.recordFileAttachmentMiss(msg, '附件复制失败', {
@@ -3882,8 +4035,7 @@ export class ExportContext {
mediaRootDir: string
mediaRelativePrefix: string
} {
const exportMediaEnabled = options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles);
const exportMediaEnabled = this.isMediaExportEnabled(options);
const outputDir = path.dirname(outputPath);
const writeLayout = this.resolveExportWriteLayout(options);
if (writeLayout === 'A' && path.basename(outputDir) === 'texts') {
@@ -3904,7 +4056,7 @@ export class ExportContext {
public async resolveWeliveRawMediaItem(msg: any, mediaRootDir: string, mediaRelativePrefix: string, options: ExportOptions, control?: ExportTaskControl): Promise<MediaExportItem | null> {
if (!this.isWeliveRawExportMode()) return null
const sourcePathRaw = String(msg?.mediaPath || msg?.media_path || '').trim()
const sourcePathRaw = this.resolveWeliveMediaPath(msg)
const localType = Number(msg?.localType || msg?.local_type || 0)
const mediaType = String(msg?.mediaType || msg?.media_type || '').trim().toLowerCase()
@@ -3922,26 +4074,9 @@ export class ExportContext {
}
if (!kind) return null
if (!sourcePathRaw) {
const emojiUrl = kind === 'emoji'
? String(msg?.emojiCdnUrl || msg?.emoji_cdn_url || '').trim()
: ''
if (emojiUrl) {
return await this.exportEmoji({
...msg,
emojiCdnUrl: emojiUrl,
emojiMd5: this.normalizeEmojiMd5(msg?.emojiMd5 || msg?.emoji_md5)
}, String(msg?.sessionId || msg?.session_id || ''), mediaRootDir, mediaRelativePrefix, undefined, control)
}
return null
}
if (/^https?:\/\//i.test(sourcePathRaw)) {
if (kind === 'emoji') {
return await this.exportEmoji({
...msg,
emojiCdnUrl: sourcePathRaw,
emojiMd5: this.normalizeEmojiMd5(msg?.emojiMd5 || msg?.emoji_md5)
}, String(msg?.sessionId || msg?.session_id || ''), mediaRootDir, mediaRelativePrefix, undefined, control)
}
return { relativePath: sourcePathRaw, kind }
}
@@ -3954,88 +4089,17 @@ export class ExportContext {
}
public async preloadWeliveRawEmojiMedia(
messages: any[],
mediaCache: Map<string, MediaExportItem | null>,
mediaRootDir: string,
mediaRelativePrefix: string,
options: ExportOptions,
control?: ExportTaskControl,
onProgress?: (progress: ExportProgress) => void,
sessionName = '',
progressCurrent = 25
_messages: any[],
_mediaCache: Map<string, MediaExportItem | null>,
_mediaRootDir: string,
_mediaRelativePrefix: string,
_options: ExportOptions,
_control?: ExportTaskControl,
_onProgress?: (progress: ExportProgress) => void,
_sessionName = '',
_progressCurrent = 25
): Promise<void> {
if (!this.isWeliveRawExportMode() || options.exportEmojis !== true || !Array.isArray(messages) || messages.length === 0) return
const groups = new Map<string, { msg: any; mediaKeys: string[] }>()
for (const msg of messages) {
const localType = Number(msg?.localType || msg?.local_type || 0)
const mediaType = String(msg?.mediaType || msg?.media_type || '').trim().toLowerCase()
if (localType !== 47 && mediaType !== 'emoji') continue
const mediaKey = this.getMediaCacheKey(msg)
if (mediaCache.has(mediaKey)) continue
const sourcePathRaw = String(msg?.mediaPath || msg?.media_path || '').trim()
const emojiUrl = /^https?:\/\//i.test(sourcePathRaw)
? sourcePathRaw
: this.normalizeEmojiCdnUrl(msg?.emojiCdnUrl || msg?.emoji_cdn_url || this.extractEmojiUrl(String(msg?.content || '')))
if (!emojiUrl) continue
const emojiMd5 = this.normalizeEmojiMd5(msg?.emojiMd5 || msg?.emoji_md5)
const groupKey = emojiMd5 || emojiUrl
const existing = groups.get(groupKey)
if (existing) {
existing.mediaKeys.push(mediaKey)
} else {
groups.set(groupKey, {
msg: {
...msg,
mediaPath: /^https?:\/\//i.test(sourcePathRaw) ? sourcePathRaw : msg?.mediaPath,
media_path: /^https?:\/\//i.test(sourcePathRaw) ? sourcePathRaw : msg?.media_path,
emojiCdnUrl: emojiUrl,
emojiMd5: emojiMd5 || msg?.emojiMd5
},
mediaKeys: [mediaKey]
})
}
}
const workItems = Array.from(groups.values())
if (workItems.length === 0) return
const concurrencySeed = typeof options.exportConcurrency === 'number'
? options.exportConcurrency * 4
: undefined
const concurrency = this.getClampedConcurrency(concurrencySeed, 12, 24)
let completed = 0
let lastReportAt = 0
const report = (force = false) => {
if (!onProgress) return
const now = Date.now()
if (!force && now - lastReportAt < 350) return
lastReportAt = now
onProgress({
current: progressCurrent,
total: 100,
currentSession: sessionName,
phase: 'exporting-media',
phaseProgress: completed,
phaseTotal: workItems.length,
phaseLabel: `下载表情包 ${completed.toLocaleString()}/${workItems.length.toLocaleString()}`,
...this.getMediaTelemetrySnapshot()
})
}
report(true)
await parallelLimit(workItems, concurrency, async (item) => {
this.throwIfStopRequested(control)
const mediaItem = await this.resolveWeliveRawMediaItem(item.msg, mediaRootDir, mediaRelativePrefix, options, control)
for (const mediaKey of item.mediaKeys) {
mediaCache.set(mediaKey, mediaItem)
}
completed++
report(completed === workItems.length || completed % 10 === 0)
})
// WeLive raw export owns media resolution. Weflow must not scan/copy original media here.
}
public collectMediaMessagesForExport(messages: any[], options: ExportOptions): any[] {
@@ -4262,6 +4326,8 @@ export class ExportContext {
senderSet.add(actualSender)
rows.push({
sessionId,
session_id: sessionId,
localId: this.getIntFromRow(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0),
serverId: this.getIntFromRow(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId', 'WCDB_CT_server_id'], 0),
serverIdRaw: this.normalizeUnsignedIntToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId', 'WCDB_CT_server_id'])) || undefined,
@@ -4280,7 +4346,7 @@ export class ExportContext {
fileSize,
fileExt,
fileMd5,
mediaPath: String(row.media_path || row.mediaPath || '').trim() || undefined,
mediaPath: this.resolveWeliveMediaPath(row) || undefined,
mediaType: String(row.media_type || row.mediaType || '').trim() || undefined,
mediaError: String(row.media_error || row.mediaError || '').trim() || undefined,
locationLat,
@@ -18,7 +18,7 @@ import { EXPORT_HTML_STYLES } from '../../exportHtmlStyles'
import { LRUCache } from '../../../utils/LRUCache.js'
import { normalizeTimestampSeconds, formatTimestamp, formatIsoTimestamp, parseCompactDateTimeDigitsToSeconds, parseDateTimeTextToSeconds, normalizeExportDateRange, normalizeRowTimestampSeconds, getTimestampSecondsFromRow } from '../../export/utils/timestamp';
import { escapeHtml, escapeAttribute, renderMultilineText, decodeHtmlEntities } from '../../export/utils/htmlEscape';
import { sanitizeExportFileNamePart, resolveFileAttachmentExtensionDir, normalizeFileNamingMode, formatDateTokenBySeconds, buildDateRangeFileNamePart, buildSessionExportBaseName, reserveUniqueOutputPath } from '../../export/utils/fileNaming';
import { sanitizeExportFileNamePart, resolveFileAttachmentExtensionDir, normalizeExportConflictStrategy, formatDateTokenBySeconds, buildDateRangeFileNamePart, buildSessionExportBaseName, reserveUniqueOutputPath } from '../../export/utils/fileNaming';
import { extractXmlValue, extractXmlAttribute, extractAppMessageType, normalizeAppMessageContent } from '../../export/parsers/xmlExtractor';
import { decodeMessageContent, decodeMaybeCompressed, decodeBinaryContent, looksLikeHex, looksLikeBase64 } from '../../export/parsers/contentDecoder';
import { parseVoipMessage } from '../../export/parsers/voipParser';
@@ -37,6 +37,7 @@ import { ChatLabFormatter } from '../formatters/ChatLabFormatter';
import { ExcelFormatter } from '../formatters/ExcelFormatter';
import { HtmlFormatter } from '../formatters/HtmlFormatter';
import { JsonFormatter } from '../formatters/JsonFormatter';
import { MarkdownFormatter } from '../formatters/MarkdownFormatter';
import { TxtFormatter } from '../formatters/TxtFormatter';
import { WeCloneFormatter } from '../formatters/WeCloneFormatter';
@@ -92,6 +93,14 @@ export class ExportOrchestrator {
return formatter.export(sessionId, outputPath, options, onProgress, control);
}
/**
* 导出单个会话为 Markdown 格式
*/
async exportSessionToMarkdown(sessionId: string, outputPath: string, options: ExportOptions, onProgress?: (progress: ExportProgress) => void, control?: ExportTaskControl): Promise<{ success: boolean; error?: string }> {
const formatter = new MarkdownFormatter(this.context);
return formatter.export(sessionId, outputPath, options, onProgress, control);
}
/**
* 批量导出多个会话
*/
@@ -133,9 +142,9 @@ export class ExportOrchestrator {
const effectiveOptions: ExportOptions = this.context.isMediaContentBatchExport(normalizedOptions)
? { ...normalizedOptions, exportVoiceAsText: false }
: normalizedOptions
const conflictStrategy = normalizeExportConflictStrategy(effectiveOptions.exportConflictStrategy)
const exportMediaEnabled = effectiveOptions.exportMedia === true &&
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles)
const exportMediaEnabled = this.context.isMediaExportEnabled(effectiveOptions)
attachMediaTelemetry = exportMediaEnabled
if (exportMediaEnabled) {
this.context.triggerMediaFileCacheCleanup()
@@ -193,7 +202,7 @@ export class ExportOrchestrator {
this.context.isUnboundedDateRange(effectiveOptions.dateRange) &&
!String(effectiveOptions.senderUsername || '').trim()
const canFastSkipEmptySessions = false
const canTrySkipUnchangedTextSessions = canUseSessionSnapshotHints
const canTrySkipUnchangedTextSessions = canUseSessionSnapshotHints && conflictStrategy === 'incremental'
const precheckSessionIds = canFastSkipEmptySessions
? sessionIds.filter((sessionId) => !sessionMessageCountHints.has(sessionId))
: []
@@ -349,7 +358,6 @@ export class ExportOrchestrator {
phaseLabel: '准备导出'
})
const fileNamingMode = normalizeFileNamingMode(effectiveOptions.fileNamingMode)
const safeName = buildSessionExportBaseName(sessionId, sessionInfo.displayName, effectiveOptions)
const sessionNameWithTypePrefix = effectiveOptions.sessionNameWithTypePrefix !== false
const sessionTypePrefix = sessionNameWithTypePrefix ? await this.context.getSessionFilePrefix(sessionId) : ''
@@ -366,6 +374,7 @@ export class ExportOrchestrator {
if (effectiveOptions.format === 'chatlab-jsonl') ext = '.jsonl'
else if (effectiveOptions.format === 'excel') ext = '.xlsx'
else if (effectiveOptions.format === 'txt') ext = '.txt'
else if (effectiveOptions.format === 'markdown') ext = '.md'
else if (effectiveOptions.format === 'weclone') ext = '.csv'
else if (effectiveOptions.format === 'html') ext = '.html'
const preferredOutputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
@@ -402,7 +411,7 @@ export class ExportOrchestrator {
}
}
const outputPath = fileNamingMode === 'date-range'
const outputPath = conflictStrategy === 'rename'
? await reserveUniqueOutputPath(preferredOutputPath, reservedOutputPaths)
: preferredOutputPath
@@ -415,6 +424,8 @@ export class ExportOrchestrator {
result = await this.exportSessionToExcel(sessionId, outputPath, effectiveOptions, sessionProgress, control)
} else if (effectiveOptions.format === 'txt') {
result = await this.exportSessionToTxt(sessionId, outputPath, effectiveOptions, sessionProgress, control)
} else if (effectiveOptions.format === 'markdown') {
result = await this.exportSessionToMarkdown(sessionId, outputPath, effectiveOptions, sessionProgress, control)
} else if (effectiveOptions.format === 'weclone') {
result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, effectiveOptions, sessionProgress, control)
} else if (effectiveOptions.format === 'html') {