1 parent
99dcb6e43b
commit
301cb5abce
48 files changed
+5444
-1015
No files matched your search
@@ -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') {
|
||||
|
||||
@@ -161,6 +161,7 @@ export class ChatLabFormatter {
|
||||
exportFiles: options.exportFiles,
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
exportConflictStrategy: options.exportConflictStrategy,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
dirCache: mediaDirCache,
|
||||
control
|
||||
|
||||
@@ -276,6 +276,7 @@ export class ExcelFormatter {
|
||||
exportFiles: options.exportFiles,
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
exportConflictStrategy: options.exportConflictStrategy,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
dirCache: mediaDirCache,
|
||||
control
|
||||
|
||||
@@ -141,6 +141,7 @@ export class HtmlFormatter {
|
||||
exportFiles: options.exportFiles,
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
exportConflictStrategy: options.exportConflictStrategy,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
includeVoiceWithTranscript: true,
|
||||
exportVideos: options.exportVideos,
|
||||
|
||||
@@ -137,6 +137,7 @@ export class JsonFormatter {
|
||||
exportFiles: options.exportFiles,
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
exportConflictStrategy: options.exportConflictStrategy,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
dirCache: mediaDirCache,
|
||||
control
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { resolveExportDisplayProfile } from '../../export/contacts/contactResolver';
|
||||
import { buildGroupNicknameIdCandidates } from '../../export/contacts/groupNickname';
|
||||
import { appendTransferDesc, isTransferExportContent, resolveTransferDesc } from '../../export/parsers/transferParser';
|
||||
import { formatTimestamp } from '../../export/utils/timestamp';
|
||||
import { parallelLimit } from '../../export/utils/parallelLimit';
|
||||
import { ExportDisplayProfile, MediaExportItem } from '../../export/types';
|
||||
import { wcdbService } from '../../wcdbService';
|
||||
|
||||
const escapeMarkdownText = (value: unknown): string => {
|
||||
return String(value ?? '')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/([`*_{}\[\]<>#+\-.!|])/g, '\\$1')
|
||||
}
|
||||
|
||||
const escapeMarkdownLinkText = (value: unknown): string => {
|
||||
return escapeMarkdownText(value).replace(/\r?\n/g, ' ')
|
||||
}
|
||||
|
||||
const toMarkdownUrl = (value: string): string => {
|
||||
return encodeURI(value)
|
||||
.replace(/\(/g, '%28')
|
||||
.replace(/\)/g, '%29')
|
||||
.replace(/</g, '%3C')
|
||||
.replace(/>/g, '%3E')
|
||||
}
|
||||
|
||||
const buildMarkdownBlockquote = (value: string): string => {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map(line => `> ${line ? escapeMarkdownText(line) : ''}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export class MarkdownFormatter {
|
||||
constructor(private exportService: any) {}
|
||||
|
||||
public async export(sessionId: any, outputPath: any, options: any, onProgress: any, control: any): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
this.exportService.throwIfStopRequested(control)
|
||||
const conn = await this.exportService.ensureConnected()
|
||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||
|
||||
const cleanedMyWxid = conn.cleanedWxid
|
||||
const isGroup = sessionId.includes('@chatroom')
|
||||
const rawMyWxid = this.exportService.getConfiguredMyWxid()
|
||||
const sessionInfo = await this.exportService.getContactInfo(sessionId)
|
||||
const myInfo = await this.exportService.getContactInfo(cleanedMyWxid)
|
||||
|
||||
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
|
||||
const getContactCached = async (username: string) => {
|
||||
if (contactCache.has(username)) return contactCache.get(username)!
|
||||
const result = await wcdbService.getContact(username)
|
||||
contactCache.set(username, result)
|
||||
return result
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
current: 0,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'preparing'
|
||||
})
|
||||
|
||||
const collectParams = this.exportService.resolveCollectParams(options)
|
||||
const collectProgressReporter = this.exportService.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5)
|
||||
const collected = await this.exportService.collectMessages(
|
||||
sessionId,
|
||||
cleanedMyWxid,
|
||||
options.dateRange,
|
||||
options.senderUsername,
|
||||
collectParams.mode,
|
||||
collectParams.targetMediaTypes,
|
||||
control,
|
||||
collectProgressReporter
|
||||
)
|
||||
const totalMessages = collected.rows.length
|
||||
|
||||
if (totalMessages === 0) {
|
||||
return { success: false, error: await this.exportService.buildNoMessagesError(sessionId, collected) }
|
||||
}
|
||||
await this.exportService.createWeliveRawOutputPlaceholder(outputPath, control)
|
||||
|
||||
await this.exportService.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
||||
await this.exportService.resolveQuotedMessagesForExport(collected.rows, sessionId)
|
||||
|
||||
const voiceMessages = options.exportVoiceAsText
|
||||
? collected.rows.filter((msg: any) => msg.localType === 34)
|
||||
: []
|
||||
|
||||
if (options.exportVoiceAsText && voiceMessages.length > 0) {
|
||||
await this.exportService.ensureVoiceModel(onProgress)
|
||||
}
|
||||
|
||||
const senderUsernames = new Set<string>()
|
||||
let senderScanIndex = 0
|
||||
for (const msg of collected.rows) {
|
||||
if ((senderScanIndex++ & 0x7f) === 0) {
|
||||
this.exportService.throwIfStopRequested(control)
|
||||
}
|
||||
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
||||
}
|
||||
senderUsernames.add(sessionId)
|
||||
await this.exportService.preloadContacts(senderUsernames, contactCache)
|
||||
|
||||
const groupNicknameCandidates = isGroup
|
||||
? buildGroupNicknameIdCandidates([
|
||||
...Array.from(senderUsernames.values()),
|
||||
...collected.rows.map((msg: any) => msg.senderUsername),
|
||||
cleanedMyWxid,
|
||||
rawMyWxid
|
||||
])
|
||||
: []
|
||||
const groupNicknamesMap = isGroup
|
||||
? await this.exportService.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
|
||||
: new Map<string, string>()
|
||||
|
||||
const sortedMessages = collected.rows
|
||||
|
||||
const { mediaRootDir, mediaRelativePrefix } = this.exportService.getMediaLayout(outputPath, options)
|
||||
const mediaMessages = this.exportService.collectMediaMessagesForExport(sortedMessages, options)
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
const mediaDirCache = new Set<string>()
|
||||
const beforeMediaDoneFiles = this.exportService.getMediaDoneFilesCount()
|
||||
|
||||
if (mediaMessages.length > 0) {
|
||||
await this.exportService.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||
exportImages: options.exportImages,
|
||||
exportVideos: options.exportVideos
|
||||
}, control)
|
||||
const voiceMediaMessages = mediaMessages.filter((msg: any) => msg.localType === 34)
|
||||
if (voiceMediaMessages.length > 0) {
|
||||
await this.exportService.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
current: 25,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting-media',
|
||||
phaseProgress: 0,
|
||||
phaseTotal: mediaMessages.length,
|
||||
phaseLabel: this.exportService.formatMediaPhaseLabel(0, mediaMessages.length, beforeMediaDoneFiles),
|
||||
...this.exportService.getMediaTelemetrySnapshot(),
|
||||
estimatedTotalMessages: totalMessages
|
||||
})
|
||||
|
||||
const mediaConcurrency = this.exportService.getClampedConcurrency(options.exportConcurrency)
|
||||
let mediaExported = 0
|
||||
await parallelLimit(mediaMessages, mediaConcurrency, async (msg: any) => {
|
||||
this.exportService.throwIfStopRequested(control)
|
||||
const mediaKey = this.exportService.getMediaCacheKey(msg)
|
||||
if (!mediaCache.has(mediaKey)) {
|
||||
const mediaItem = await this.exportService.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportVideos: options.exportVideos,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportFiles: options.exportFiles,
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
exportConflictStrategy: options.exportConflictStrategy,
|
||||
includeVoiceWithTranscript: true,
|
||||
dirCache: mediaDirCache,
|
||||
control
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
mediaExported++
|
||||
if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) {
|
||||
onProgress?.({
|
||||
current: 25,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting-media',
|
||||
phaseProgress: mediaExported,
|
||||
phaseTotal: mediaMessages.length,
|
||||
phaseLabel: this.exportService.formatMediaPhaseLabel(mediaExported, mediaMessages.length, beforeMediaDoneFiles),
|
||||
...this.exportService.getMediaTelemetrySnapshot()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
await this.exportService.preloadWeliveRawEmojiMedia(sortedMessages, mediaCache, mediaRootDir, mediaRelativePrefix, options, control, onProgress, sessionInfo.displayName, 20)
|
||||
const fileOnlyExportFailure = this.exportService.buildFileOnlyExportFailure(options, mediaMessages, beforeMediaDoneFiles)
|
||||
if (fileOnlyExportFailure) return fileOnlyExportFailure
|
||||
|
||||
const voiceTranscriptMap = new Map<string, string>()
|
||||
if (voiceMessages.length > 0) {
|
||||
await this.exportService.preloadVoiceWavCache(sessionId, voiceMessages, control)
|
||||
|
||||
onProgress?.({
|
||||
current: 45,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting-voice',
|
||||
phaseProgress: 0,
|
||||
phaseTotal: voiceMessages.length,
|
||||
phaseLabel: `语音转文字 0/${voiceMessages.length}`,
|
||||
estimatedTotalMessages: totalMessages
|
||||
})
|
||||
|
||||
const VOICE_CONCURRENCY = 4
|
||||
let voiceTranscribed = 0
|
||||
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg: any) => {
|
||||
this.exportService.throwIfStopRequested(control)
|
||||
const transcript = await this.exportService.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername)
|
||||
voiceTranscriptMap.set(this.exportService.getStableMessageKey(msg), transcript)
|
||||
voiceTranscribed++
|
||||
onProgress?.({
|
||||
current: 45,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting-voice',
|
||||
phaseProgress: voiceTranscribed,
|
||||
phaseTotal: voiceMessages.length,
|
||||
phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}`
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
current: 60,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'writing',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: 0
|
||||
})
|
||||
|
||||
await this.exportService.recordCreatedFileBeforeWrite(outputPath, control)
|
||||
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
|
||||
const writeChunk = async (chunk: string): Promise<void> => {
|
||||
await new Promise<void>((resolve, _reject) => {
|
||||
this.exportService.throwIfStopRequested(control)
|
||||
if (!stream.write(chunk)) stream.once('drain', resolve)
|
||||
else resolve()
|
||||
})
|
||||
}
|
||||
|
||||
const exportMeta = this.exportService.getExportMeta(sessionId, sessionInfo, isGroup)
|
||||
await writeChunk(`# ${escapeMarkdownText(sessionInfo.displayName || sessionId)}\n\n`)
|
||||
await writeChunk(`- 会话ID: \`${String(sessionId).replace(/`/g, '\\`')}\`\n`)
|
||||
await writeChunk(`- 会话类型: ${isGroup ? '群聊' : '私聊'}\n`)
|
||||
await writeChunk(`- 消息数量: ${totalMessages}\n`)
|
||||
await writeChunk(`- 导出时间: ${escapeMarkdownText(formatTimestamp(exportMeta.chatlab.exportedAt))}\n`)
|
||||
await writeChunk(`- 导出工具: WeFlow\n\n---\n\n`)
|
||||
|
||||
const WRITE_BATCH = 80
|
||||
let writeBuffer: string[] = []
|
||||
const flushWriteBuffer = async (): Promise<void> => {
|
||||
if (writeBuffer.length === 0) return
|
||||
await writeChunk(writeBuffer.join(''))
|
||||
writeBuffer = []
|
||||
}
|
||||
const senderProfileCache = new Map<string, ExportDisplayProfile>()
|
||||
|
||||
const getSenderRole = async (msg: any): Promise<string> => {
|
||||
if (isGroup) {
|
||||
const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}`
|
||||
let senderProfile = senderProfileCache.get(senderProfileKey)
|
||||
if (!senderProfile) {
|
||||
senderProfile = await resolveExportDisplayProfile(
|
||||
msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
|
||||
options.displayNamePreference,
|
||||
getContactCached,
|
||||
groupNicknamesMap,
|
||||
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''),
|
||||
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
|
||||
)
|
||||
senderProfileCache.set(senderProfileKey, senderProfile)
|
||||
}
|
||||
return senderProfile.displayName
|
||||
}
|
||||
|
||||
if (msg.isSend) return '我'
|
||||
|
||||
const contactDetail = await getContactCached(sessionId)
|
||||
if (contactDetail.success && contactDetail.contact) {
|
||||
return contactDetail.contact.remark || contactDetail.contact.nickName || sessionId
|
||||
}
|
||||
return sessionInfo.displayName || sessionId
|
||||
}
|
||||
|
||||
const getRawMediaRelativePath = (msg: any): string => {
|
||||
const rawPath = String(msg?.mediaPath || msg?.media_path || '').trim()
|
||||
if (!rawPath || /^(?:https?:|data:|blob:)/i.test(rawPath)) return rawPath
|
||||
const normalized = rawPath.replace(/\\/g, '/')
|
||||
if (!path.isAbsolute(rawPath)) return normalized.replace(/^\.\//, '')
|
||||
|
||||
const relativeFromMediaRoot = path.relative(mediaRootDir, rawPath)
|
||||
if (relativeFromMediaRoot && !relativeFromMediaRoot.startsWith('..') && !path.isAbsolute(relativeFromMediaRoot)) {
|
||||
return relativeFromMediaRoot.split(path.sep).join('/')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const getFallbackMediaKind = (msg: any): MediaExportItem['kind'] | null => {
|
||||
const localType = Number(msg?.localType || msg?.local_type || 0)
|
||||
const mediaType = String(msg?.mediaType || msg?.media_type || '').trim().toLowerCase()
|
||||
if (mediaType === 'image' || localType === 3) return 'image'
|
||||
if (mediaType === 'emoji' || localType === 47) return 'emoji'
|
||||
if (mediaType === 'voice' || localType === 34) return 'voice'
|
||||
if (mediaType === 'video' || localType === 43) return 'video'
|
||||
if (mediaType === 'file') return 'file'
|
||||
return null
|
||||
}
|
||||
|
||||
const buildMediaMarkdown = (mediaItem: MediaExportItem | null | undefined, typeName: string, fallbackRelativePath = '', fallbackKind: MediaExportItem['kind'] | null = null): string => {
|
||||
const relativePath = mediaItem?.relativePath || fallbackRelativePath
|
||||
if (!relativePath) return ''
|
||||
const mediaUrl = toMarkdownUrl(this.exportService.formatExportMediaPath(relativePath, { exportPathStyle: 'posix' }, 'url'))
|
||||
const fileName = path.basename(relativePath.replace(/\\/g, '/')) || typeName
|
||||
const kind = mediaItem?.kind || fallbackKind
|
||||
|
||||
if (kind === 'image' || kind === 'emoji') {
|
||||
return ``
|
||||
}
|
||||
if (kind === 'voice') {
|
||||
return `[${escapeMarkdownLinkText('语音文件')}](${mediaUrl})`
|
||||
}
|
||||
if (kind === 'video') {
|
||||
return `[${escapeMarkdownLinkText('视频文件')}](${mediaUrl})`
|
||||
}
|
||||
return `[${escapeMarkdownLinkText(fileName)}](${mediaUrl})`
|
||||
}
|
||||
|
||||
for (let i = 0; i < totalMessages; i++) {
|
||||
if ((i & 0x7f) === 0) this.exportService.throwIfStopRequested(control)
|
||||
|
||||
const msg = sortedMessages[i]
|
||||
const mediaKey = this.exportService.getMediaCacheKey(msg)
|
||||
const mediaItem = mediaCache.has(mediaKey)
|
||||
? mediaCache.get(mediaKey)
|
||||
: await this.exportService.resolveWeliveRawMediaItem(msg, mediaRootDir, mediaRelativePrefix, options, control)
|
||||
const senderRole = await getSenderRole(msg)
|
||||
const typeName = this.exportService.getMessageTypeName(msg.localType, msg.content)
|
||||
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
|
||||
const mediaMarkdown = buildMediaMarkdown(mediaItem, typeName, getRawMediaRelativePath(msg), getFallbackMediaKind(msg))
|
||||
|
||||
let contentValue = shouldUseTranscript
|
||||
? this.exportService.formatPlainExportContent(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)),
|
||||
cleanedMyWxid,
|
||||
msg.senderUsername,
|
||||
msg.isSend,
|
||||
msg.emojiCaption
|
||||
)
|
||||
: this.exportService.formatPlainExportContent(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(this.exportService.getStableMessageKey(msg)),
|
||||
cleanedMyWxid,
|
||||
msg.senderUsername,
|
||||
msg.isSend,
|
||||
msg.emojiCaption
|
||||
)
|
||||
|
||||
if (mediaMarkdown && !shouldUseTranscript && (msg.localType === 3 || msg.localType === 34 || msg.localType === 43 || msg.localType === 47)) {
|
||||
contentValue = ''
|
||||
}
|
||||
|
||||
if (isTransferExportContent(contentValue) && msg.content) {
|
||||
const transferDesc = await resolveTransferDesc(
|
||||
msg.content,
|
||||
cleanedMyWxid,
|
||||
groupNicknamesMap,
|
||||
async (username: string) => {
|
||||
const c = await getContactCached(username)
|
||||
if (c.success && c.contact) {
|
||||
return c.contact.remark || c.contact.nickName || c.contact.alias || username
|
||||
}
|
||||
return username
|
||||
}
|
||||
)
|
||||
if (transferDesc) {
|
||||
contentValue = appendTransferDesc(contentValue, transferDesc)
|
||||
}
|
||||
}
|
||||
|
||||
const quotedReplyDisplay = await this.exportService.resolveQuotedReplyDisplayWithNames({
|
||||
content: msg.content,
|
||||
isGroup,
|
||||
displayNamePreference: options.displayNamePreference,
|
||||
getContact: getContactCached,
|
||||
groupNicknamesMap,
|
||||
cleanedMyWxid,
|
||||
rawMyWxid,
|
||||
myDisplayName: myInfo.displayName || cleanedMyWxid
|
||||
})
|
||||
|
||||
const linkCard = quotedReplyDisplay ? null : this.exportService.extractHtmlLinkCard(msg.content, msg.localType)
|
||||
const parts: string[] = []
|
||||
let contentIsMarkdown = false
|
||||
if (quotedReplyDisplay) {
|
||||
const quotedLabel = quotedReplyDisplay.quotedSender
|
||||
? `${quotedReplyDisplay.quotedSender}: ${quotedReplyDisplay.quotedPreview}`
|
||||
: quotedReplyDisplay.quotedPreview
|
||||
parts.push(buildMarkdownBlockquote(quotedLabel))
|
||||
contentValue = quotedReplyDisplay.replyText || contentValue
|
||||
} else if (linkCard?.url) {
|
||||
contentValue = `[${escapeMarkdownLinkText(linkCard.title || linkCard.url)}](${toMarkdownUrl(linkCard.url)})`
|
||||
contentIsMarkdown = true
|
||||
}
|
||||
if (mediaMarkdown) parts.push(mediaMarkdown)
|
||||
if (contentValue) parts.push(contentIsMarkdown ? contentValue : escapeMarkdownText(contentValue))
|
||||
|
||||
const body = parts.length > 0 ? parts.join('\n\n') : escapeMarkdownText(`[${typeName}]`)
|
||||
writeBuffer.push(`## ${escapeMarkdownText(formatTimestamp(msg.createTime))} ${escapeMarkdownText(senderRole)}\n\n${body}\n\n`)
|
||||
if (writeBuffer.length >= WRITE_BATCH) {
|
||||
await flushWriteBuffer()
|
||||
}
|
||||
|
||||
if ((i + 1) % 200 === 0) {
|
||||
onProgress?.({
|
||||
current: 60 + Math.floor((i + 1) / sortedMessages.length * 30),
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'writing',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: i + 1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await flushWriteBuffer()
|
||||
|
||||
this.exportService.throwIfStopRequested(control)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.on('error', reject)
|
||||
stream.end(() => resolve())
|
||||
})
|
||||
|
||||
onProgress?.({
|
||||
current: 100,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'complete',
|
||||
estimatedTotalMessages: totalMessages,
|
||||
collectedMessages: totalMessages,
|
||||
exportedMessages: totalMessages,
|
||||
writtenFiles: 1
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
if (this.exportService.isStopError(e)) {
|
||||
return { success: false, error: '导出任务已停止' }
|
||||
}
|
||||
if (this.exportService.isPauseError(e)) {
|
||||
return { success: false, error: '导出任务已暂停' }
|
||||
}
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,7 @@ export class TxtFormatter {
|
||||
exportFiles: options.exportFiles,
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
exportConflictStrategy: options.exportConflictStrategy,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
dirCache: mediaDirCache,
|
||||
control
|
||||
|
||||
@@ -142,6 +142,7 @@ export class WeCloneFormatter {
|
||||
exportFiles: options.exportFiles,
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
exportConflictStrategy: options.exportConflictStrategy,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
dirCache: mediaDirCache,
|
||||
control
|
||||
|
||||
@@ -63,12 +63,13 @@ export interface ChatLabExport {
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'markdown' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
|
||||
dateRange?: { start: number; end: number } | null
|
||||
senderUsername?: string
|
||||
fileNameSuffix?: string
|
||||
fileNamingMode?: 'classic' | 'date-range'
|
||||
exportConflictStrategy?: 'incremental' | 'overwrite' | 'rename'
|
||||
exportMedia?: boolean
|
||||
exportAvatars?: boolean
|
||||
exportImages?: boolean
|
||||
|
||||
@@ -21,6 +21,13 @@ export function normalizeFileNamingMode(value: unknown): 'classic' | 'date-range
|
||||
return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic'
|
||||
}
|
||||
|
||||
export function normalizeExportConflictStrategy(value: unknown): 'incremental' | 'overwrite' | 'rename' {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
if (normalized === 'overwrite') return 'overwrite'
|
||||
if (normalized === 'rename') return 'rename'
|
||||
return 'incremental'
|
||||
}
|
||||
|
||||
export function formatDateTokenBySeconds(seconds?: number): string | null {
|
||||
const normalizedSeconds = normalizeTimestampSeconds(seconds)
|
||||
if (normalizedSeconds <= 0) return null
|
||||
|
||||
Reference in new issue
Block a user