mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
Merge branch 'main' into dev
This commit is contained in:
@@ -34,6 +34,7 @@ interface ConfigSchema {
|
|||||||
autoTranscribeVoice: boolean
|
autoTranscribeVoice: boolean
|
||||||
transcribeLanguages: string[]
|
transcribeLanguages: string[]
|
||||||
exportDefaultConcurrency: number
|
exportDefaultConcurrency: number
|
||||||
|
exportDefaultImageDeepSearchOnMiss: boolean
|
||||||
analyticsExcludedUsernames: string[]
|
analyticsExcludedUsernames: string[]
|
||||||
|
|
||||||
// 安全相关
|
// 安全相关
|
||||||
@@ -106,6 +107,7 @@ export class ConfigService {
|
|||||||
autoTranscribeVoice: false,
|
autoTranscribeVoice: false,
|
||||||
transcribeLanguages: ['zh'],
|
transcribeLanguages: ['zh'],
|
||||||
exportDefaultConcurrency: 4,
|
exportDefaultConcurrency: 4,
|
||||||
|
exportDefaultImageDeepSearchOnMiss: true,
|
||||||
analyticsExcludedUsernames: [],
|
analyticsExcludedUsernames: [],
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
authPassword: '',
|
authPassword: '',
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export interface ExportOptions {
|
|||||||
sessionNameWithTypePrefix?: boolean
|
sessionNameWithTypePrefix?: boolean
|
||||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||||
exportConcurrency?: number
|
exportConcurrency?: number
|
||||||
|
imageDeepSearchOnMiss?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
|
const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
|
||||||
@@ -259,6 +260,7 @@ class ExportService {
|
|||||||
private mediaFileCacheReadyDirs = new Set<string>()
|
private mediaFileCacheReadyDirs = new Set<string>()
|
||||||
private mediaExportTelemetry: MediaExportTelemetry | null = null
|
private mediaExportTelemetry: MediaExportTelemetry | null = null
|
||||||
private mediaRunSourceDedupMap = new Map<string, string>()
|
private mediaRunSourceDedupMap = new Map<string, string>()
|
||||||
|
private mediaRunMissingImageKeys = new Set<string>()
|
||||||
private mediaFileCacheCleanupPending: Promise<void> | null = null
|
private mediaFileCacheCleanupPending: Promise<void> | null = null
|
||||||
private mediaFileCacheLastCleanupAt = 0
|
private mediaFileCacheLastCleanupAt = 0
|
||||||
private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000
|
private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000
|
||||||
@@ -524,11 +526,13 @@ class ExportService {
|
|||||||
private resetMediaRuntimeState(): void {
|
private resetMediaRuntimeState(): void {
|
||||||
this.mediaExportTelemetry = this.createEmptyMediaTelemetry()
|
this.mediaExportTelemetry = this.createEmptyMediaTelemetry()
|
||||||
this.mediaRunSourceDedupMap.clear()
|
this.mediaRunSourceDedupMap.clear()
|
||||||
|
this.mediaRunMissingImageKeys.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearMediaRuntimeState(): void {
|
private clearMediaRuntimeState(): void {
|
||||||
this.mediaExportTelemetry = null
|
this.mediaExportTelemetry = null
|
||||||
this.mediaRunSourceDedupMap.clear()
|
this.mediaRunSourceDedupMap.clear()
|
||||||
|
this.mediaRunMissingImageKeys.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMediaTelemetrySnapshot(): Partial<ExportProgress> {
|
private getMediaTelemetrySnapshot(): Partial<ExportProgress> {
|
||||||
@@ -3325,6 +3329,7 @@ class ExportService {
|
|||||||
exportVoiceAsText?: boolean
|
exportVoiceAsText?: boolean
|
||||||
includeVideoPoster?: boolean
|
includeVideoPoster?: boolean
|
||||||
includeVoiceWithTranscript?: boolean
|
includeVoiceWithTranscript?: boolean
|
||||||
|
imageDeepSearchOnMiss?: boolean
|
||||||
dirCache?: Set<string>
|
dirCache?: Set<string>
|
||||||
}
|
}
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
@@ -3332,7 +3337,14 @@ class ExportService {
|
|||||||
|
|
||||||
// 图片消息
|
// 图片消息
|
||||||
if (localType === 3 && options.exportImages) {
|
if (localType === 3 && options.exportImages) {
|
||||||
const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache)
|
const result = await this.exportImage(
|
||||||
|
msg,
|
||||||
|
sessionId,
|
||||||
|
mediaRootDir,
|
||||||
|
mediaRelativePrefix,
|
||||||
|
options.dirCache,
|
||||||
|
options.imageDeepSearchOnMiss !== false
|
||||||
|
)
|
||||||
if (result) {
|
if (result) {
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -3378,7 +3390,8 @@ class ExportService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
mediaRootDir: string,
|
mediaRootDir: string,
|
||||||
mediaRelativePrefix: string,
|
mediaRelativePrefix: string,
|
||||||
dirCache?: Set<string>
|
dirCache?: Set<string>,
|
||||||
|
imageDeepSearchOnMiss = true
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
try {
|
try {
|
||||||
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
|
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
|
||||||
@@ -3395,16 +3408,34 @@ class ExportService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const missingRunCacheKey = this.getImageMissingRunCacheKey(
|
||||||
|
sessionId,
|
||||||
|
imageMd5,
|
||||||
|
imageDatName,
|
||||||
|
imageDeepSearchOnMiss
|
||||||
|
)
|
||||||
|
if (missingRunCacheKey && this.mediaRunMissingImageKeys.has(missingRunCacheKey)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const result = await imageDecryptService.decryptImage({
|
const result = await imageDecryptService.decryptImage({
|
||||||
sessionId,
|
sessionId,
|
||||||
imageMd5,
|
imageMd5,
|
||||||
imageDatName,
|
imageDatName,
|
||||||
force: true, // 导出优先高清,失败再回退缩略图
|
force: true, // 导出优先高清,失败再回退缩略图
|
||||||
preferFilePath: true
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: !imageDeepSearchOnMiss
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!result.success || !result.localPath) {
|
if (!result.success || !result.localPath) {
|
||||||
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
|
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
|
||||||
|
if (!imageDeepSearchOnMiss) {
|
||||||
|
console.log(`[Export] 未命中 hardlink(已关闭缺图深度搜索)→ 将显示 [图片] 占位符`)
|
||||||
|
if (missingRunCacheKey) {
|
||||||
|
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
// 尝试获取缩略图
|
// 尝试获取缩略图
|
||||||
const thumbResult = await imageDecryptService.resolveCachedImage({
|
const thumbResult = await imageDecryptService.resolveCachedImage({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -3425,6 +3456,9 @@ class ExportService {
|
|||||||
result.localPath = cachedThumb
|
result.localPath = cachedThumb
|
||||||
} else {
|
} else {
|
||||||
console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`)
|
console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`)
|
||||||
|
if (missingRunCacheKey) {
|
||||||
|
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4851,6 +4885,7 @@ class ExportService {
|
|||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
|
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
@@ -5353,6 +5388,7 @@ class ExportService {
|
|||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
|
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
@@ -6205,6 +6241,7 @@ class ExportService {
|
|||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
|
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
@@ -6912,6 +6949,7 @@ class ExportService {
|
|||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
|
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
@@ -7281,6 +7319,7 @@ class ExportService {
|
|||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
|
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
@@ -7702,6 +7741,7 @@ class ExportService {
|
|||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
includeVoiceWithTranscript: true,
|
includeVoiceWithTranscript: true,
|
||||||
exportVideos: options.exportVideos,
|
exportVideos: options.exportVideos,
|
||||||
|
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ type CachedImagePayload = {
|
|||||||
|
|
||||||
type DecryptImagePayload = CachedImagePayload & {
|
type DecryptImagePayload = CachedImagePayload & {
|
||||||
force?: boolean
|
force?: boolean
|
||||||
|
hardlinkOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ImageDecryptService {
|
export class ImageDecryptService {
|
||||||
@@ -158,7 +159,9 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> {
|
async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> {
|
||||||
await this.ensureCacheIndexed()
|
if (!payload.hardlinkOnly) {
|
||||||
|
await this.ensureCacheIndexed()
|
||||||
|
}
|
||||||
const cacheKeys = this.getCacheKeys(payload)
|
const cacheKeys = this.getCacheKeys(payload)
|
||||||
const cacheKey = cacheKeys[0]
|
const cacheKey = cacheKeys[0]
|
||||||
if (!cacheKey) {
|
if (!cacheKey) {
|
||||||
@@ -180,14 +183,16 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key of cacheKeys) {
|
if (!payload.hardlinkOnly) {
|
||||||
const existingHd = this.findCachedOutput(key, true, payload.sessionId)
|
for (const key of cacheKeys) {
|
||||||
if (!existingHd || this.isThumbnailPath(existingHd)) continue
|
const existingHd = this.findCachedOutput(key, true, payload.sessionId)
|
||||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
|
if (!existingHd || this.isThumbnailPath(existingHd)) continue
|
||||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
|
||||||
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
|
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
|
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
|
||||||
return { success: true, localPath }
|
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
|
||||||
|
return { success: true, localPath }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +260,7 @@ export class ImageDecryptService {
|
|||||||
payload: DecryptImagePayload,
|
payload: DecryptImagePayload,
|
||||||
cacheKey: string
|
cacheKey: string
|
||||||
): Promise<DecryptResult> {
|
): Promise<DecryptResult> {
|
||||||
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force })
|
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true })
|
||||||
try {
|
try {
|
||||||
const wxid = this.configService.get('myWxid')
|
const wxid = this.configService.get('myWxid')
|
||||||
const dbPath = this.configService.get('dbPath')
|
const dbPath = this.configService.get('dbPath')
|
||||||
@@ -275,7 +280,11 @@ export class ImageDecryptService {
|
|||||||
payload.imageMd5,
|
payload.imageMd5,
|
||||||
payload.imageDatName,
|
payload.imageDatName,
|
||||||
payload.sessionId,
|
payload.sessionId,
|
||||||
{ allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) }
|
{
|
||||||
|
allowThumbnail: !payload.force,
|
||||||
|
skipResolvedCache: Boolean(payload.force),
|
||||||
|
hardlinkOnly: payload.hardlinkOnly === true
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 如果要求高清图但没找到,直接返回提示
|
// 如果要求高清图但没找到,直接返回提示
|
||||||
@@ -298,18 +307,20 @@ export class ImageDecryptService {
|
|||||||
return { success: true, localPath, isThumb }
|
return { success: true, localPath, isThumb }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找已缓存的解密文件
|
// 查找已缓存的解密文件(hardlink-only 模式下跳过全缓存目录扫描)
|
||||||
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
|
if (!payload.hardlinkOnly) {
|
||||||
if (existing) {
|
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
|
||||||
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
|
if (existing) {
|
||||||
const isHd = this.isHdPath(existing)
|
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
|
||||||
// 如果要求高清但找到的是缩略图,继续解密高清图
|
const isHd = this.isHdPath(existing)
|
||||||
if (!(payload.force && !isHd)) {
|
// 如果要求高清但找到的是缩略图,继续解密高清图
|
||||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
|
if (!(payload.force && !isHd)) {
|
||||||
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
|
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
|
||||||
const isThumb = this.isThumbnailPath(existing)
|
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
|
||||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
|
const isThumb = this.isThumbnailPath(existing)
|
||||||
return { success: true, localPath, isThumb }
|
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
|
||||||
|
return { success: true, localPath, isThumb }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,15 +478,17 @@ export class ImageDecryptService {
|
|||||||
imageMd5?: string,
|
imageMd5?: string,
|
||||||
imageDatName?: string,
|
imageDatName?: string,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean }
|
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean }
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const allowThumbnail = options?.allowThumbnail ?? true
|
const allowThumbnail = options?.allowThumbnail ?? true
|
||||||
const skipResolvedCache = options?.skipResolvedCache ?? false
|
const skipResolvedCache = options?.skipResolvedCache ?? false
|
||||||
|
const hardlinkOnly = options?.hardlinkOnly ?? false
|
||||||
this.logInfo('[ImageDecrypt] resolveDatPath', {
|
this.logInfo('[ImageDecrypt] resolveDatPath', {
|
||||||
imageMd5,
|
imageMd5,
|
||||||
imageDatName,
|
imageDatName,
|
||||||
allowThumbnail,
|
allowThumbnail,
|
||||||
skipResolvedCache
|
skipResolvedCache,
|
||||||
|
hardlinkOnly
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!skipResolvedCache) {
|
if (!skipResolvedCache) {
|
||||||
@@ -500,7 +513,7 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. 通过 MD5 快速定位 (MsgAttach 目录)
|
// 1. 通过 MD5 快速定位 (MsgAttach 目录)
|
||||||
if (imageMd5) {
|
if (!hardlinkOnly && allowThumbnail && imageMd5) {
|
||||||
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
|
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
|
||||||
if (res) return res
|
if (res) return res
|
||||||
if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) {
|
if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) {
|
||||||
@@ -510,7 +523,7 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位
|
// 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位
|
||||||
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
|
if (!hardlinkOnly && allowThumbnail && !imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
|
||||||
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
|
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
|
||||||
if (res) return res
|
if (res) return res
|
||||||
}
|
}
|
||||||
@@ -587,6 +600,11 @@ export class ImageDecryptService {
|
|||||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hardlinkOnly) {
|
||||||
|
this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink-only)', { imageMd5, imageDatName })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
|
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
|
||||||
if (!allowThumbnail) {
|
if (!allowThumbnail) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ interface ExportOptions {
|
|||||||
txtColumns: string[]
|
txtColumns: string[]
|
||||||
displayNamePreference: DisplayNamePreference
|
displayNamePreference: DisplayNamePreference
|
||||||
exportConcurrency: number
|
exportConcurrency: number
|
||||||
|
imageDeepSearchOnMiss: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionRow extends AppChatSession {
|
interface SessionRow extends AppChatSession {
|
||||||
@@ -1593,6 +1594,7 @@ function ExportPage() {
|
|||||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||||
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
|
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
|
||||||
|
const [exportDefaultImageDeepSearchOnMiss, setExportDefaultImageDeepSearchOnMiss] = useState(true)
|
||||||
|
|
||||||
const [options, setOptions] = useState<ExportOptions>({
|
const [options, setOptions] = useState<ExportOptions>({
|
||||||
format: 'json',
|
format: 'json',
|
||||||
@@ -1611,7 +1613,8 @@ function ExportPage() {
|
|||||||
excelCompactColumns: true,
|
excelCompactColumns: true,
|
||||||
txtColumns: defaultTxtColumns,
|
txtColumns: defaultTxtColumns,
|
||||||
displayNamePreference: 'remark',
|
displayNamePreference: 'remark',
|
||||||
exportConcurrency: 2
|
exportConcurrency: 2,
|
||||||
|
imageDeepSearchOnMiss: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const [exportDialog, setExportDialog] = useState<ExportDialogState>({
|
const [exportDialog, setExportDialog] = useState<ExportDialogState>({
|
||||||
@@ -2138,7 +2141,7 @@ function ExportPage() {
|
|||||||
setIsBaseConfigLoading(true)
|
setIsBaseConfigLoading(true)
|
||||||
let isReady = true
|
let isReady = true
|
||||||
try {
|
try {
|
||||||
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([
|
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([
|
||||||
configService.getExportPath(),
|
configService.getExportPath(),
|
||||||
configService.getExportDefaultFormat(),
|
configService.getExportDefaultFormat(),
|
||||||
configService.getExportDefaultAvatars(),
|
configService.getExportDefaultAvatars(),
|
||||||
@@ -2147,6 +2150,7 @@ function ExportPage() {
|
|||||||
configService.getExportDefaultExcelCompactColumns(),
|
configService.getExportDefaultExcelCompactColumns(),
|
||||||
configService.getExportDefaultTxtColumns(),
|
configService.getExportDefaultTxtColumns(),
|
||||||
configService.getExportDefaultConcurrency(),
|
configService.getExportDefaultConcurrency(),
|
||||||
|
configService.getExportDefaultImageDeepSearchOnMiss(),
|
||||||
configService.getExportLastSessionRunMap(),
|
configService.getExportLastSessionRunMap(),
|
||||||
configService.getExportLastContentRunMap(),
|
configService.getExportLastContentRunMap(),
|
||||||
configService.getExportSessionRecordMap(),
|
configService.getExportSessionRecordMap(),
|
||||||
@@ -2183,6 +2187,7 @@ function ExportPage() {
|
|||||||
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
||||||
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
||||||
setExportDefaultConcurrency(savedConcurrency ?? 2)
|
setExportDefaultConcurrency(savedConcurrency ?? 2)
|
||||||
|
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
|
||||||
const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange)
|
const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange)
|
||||||
setExportDefaultDateRangeSelection(resolvedDefaultDateRange)
|
setExportDefaultDateRangeSelection(resolvedDefaultDateRange)
|
||||||
setTimeRangeSelection(resolvedDefaultDateRange)
|
setTimeRangeSelection(resolvedDefaultDateRange)
|
||||||
@@ -2215,7 +2220,8 @@ function ExportPage() {
|
|||||||
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
|
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
|
||||||
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
|
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
|
||||||
txtColumns,
|
txtColumns,
|
||||||
exportConcurrency: savedConcurrency ?? prev.exportConcurrency
|
exportConcurrency: savedConcurrency ?? prev.exportConcurrency,
|
||||||
|
imageDeepSearchOnMiss: savedImageDeepSearchOnMiss ?? prev.imageDeepSearchOnMiss
|
||||||
}))
|
}))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
isReady = false
|
isReady = false
|
||||||
@@ -3989,7 +3995,8 @@ function ExportPage() {
|
|||||||
exportEmojis: exportDefaultMedia.emojis,
|
exportEmojis: exportDefaultMedia.emojis,
|
||||||
exportVoiceAsText: exportDefaultVoiceAsText,
|
exportVoiceAsText: exportDefaultVoiceAsText,
|
||||||
excelCompactColumns: exportDefaultExcelCompactColumns,
|
excelCompactColumns: exportDefaultExcelCompactColumns,
|
||||||
exportConcurrency: exportDefaultConcurrency
|
exportConcurrency: exportDefaultConcurrency,
|
||||||
|
imageDeepSearchOnMiss: exportDefaultImageDeepSearchOnMiss
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.scope === 'sns') {
|
if (payload.scope === 'sns') {
|
||||||
@@ -4022,7 +4029,8 @@ function ExportPage() {
|
|||||||
exportDefaultAvatars,
|
exportDefaultAvatars,
|
||||||
exportDefaultMedia,
|
exportDefaultMedia,
|
||||||
exportDefaultVoiceAsText,
|
exportDefaultVoiceAsText,
|
||||||
exportDefaultConcurrency
|
exportDefaultConcurrency,
|
||||||
|
exportDefaultImageDeepSearchOnMiss
|
||||||
])
|
])
|
||||||
|
|
||||||
const closeExportDialog = useCallback(() => {
|
const closeExportDialog = useCallback(() => {
|
||||||
@@ -4241,6 +4249,7 @@ function ExportPage() {
|
|||||||
txtColumns: options.txtColumns,
|
txtColumns: options.txtColumns,
|
||||||
displayNamePreference: options.displayNamePreference,
|
displayNamePreference: options.displayNamePreference,
|
||||||
exportConcurrency: options.exportConcurrency,
|
exportConcurrency: options.exportConcurrency,
|
||||||
|
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||||
sessionLayout,
|
sessionLayout,
|
||||||
sessionNameWithTypePrefix,
|
sessionNameWithTypePrefix,
|
||||||
dateRange: options.useAllTime
|
dateRange: options.useAllTime
|
||||||
@@ -4833,6 +4842,8 @@ function ExportPage() {
|
|||||||
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
|
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
|
||||||
await configService.setExportDefaultTxtColumns(options.txtColumns)
|
await configService.setExportDefaultTxtColumns(options.txtColumns)
|
||||||
await configService.setExportDefaultConcurrency(options.exportConcurrency)
|
await configService.setExportDefaultConcurrency(options.exportConcurrency)
|
||||||
|
await configService.setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
|
||||||
|
setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openSingleExport = useCallback((session: SessionRow) => {
|
const openSingleExport = useCallback((session: SessionRow) => {
|
||||||
@@ -6315,6 +6326,10 @@ function ExportPage() {
|
|||||||
const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog
|
const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog
|
||||||
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
|
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
|
||||||
const shouldShowMediaSection = !isContentScopeDialog
|
const shouldShowMediaSection = !isContentScopeDialog
|
||||||
|
const shouldShowImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
|
||||||
|
(isSessionScopeDialog && options.exportImages) ||
|
||||||
|
(isContentScopeDialog && exportDialog.contentType === 'image')
|
||||||
|
)
|
||||||
const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像'
|
const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像'
|
||||||
const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。'
|
const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。'
|
||||||
const activeDialogFormatLabel = exportDialog.scope === 'sns'
|
const activeDialogFormatLabel = exportDialog.scope === 'sns'
|
||||||
@@ -8031,6 +8046,26 @@ function ExportPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{shouldShowImageDeepSearchToggle && (
|
||||||
|
<div className="dialog-section">
|
||||||
|
<div className="dialog-switch-row">
|
||||||
|
<div className="dialog-switch-copy">
|
||||||
|
<h4>缺图时深度搜索</h4>
|
||||||
|
<div className="format-note">关闭后仅尝试 hardlink 命中,未命中将直接显示占位符,导出速度更快。</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dialog-switch ${options.imageDeepSearchOnMiss ? 'on' : ''}`}
|
||||||
|
aria-pressed={options.imageDeepSearchOnMiss}
|
||||||
|
aria-label="切换缺图时深度搜索"
|
||||||
|
onClick={() => setOptions(prev => ({ ...prev, imageDeepSearchOnMiss: !prev.imageDeepSearchOnMiss }))}
|
||||||
|
>
|
||||||
|
<span className="dialog-switch-thumb" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isSessionScopeDialog && (
|
{isSessionScopeDialog && (
|
||||||
<div className="dialog-section">
|
<div className="dialog-section">
|
||||||
<div className="dialog-switch-row">
|
<div className="dialog-switch-row">
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const CONFIG_KEYS = {
|
|||||||
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
|
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
|
||||||
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
|
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
|
||||||
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
|
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
|
||||||
|
EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS: 'exportDefaultImageDeepSearchOnMiss',
|
||||||
EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
|
EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
|
||||||
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
|
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
|
||||||
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
|
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
|
||||||
@@ -462,6 +463,18 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise<
|
|||||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
|
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取缺图时是否深度搜索(默认导出行为)
|
||||||
|
export async function getExportDefaultImageDeepSearchOnMiss(): Promise<boolean | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS)
|
||||||
|
if (typeof value === 'boolean') return value
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置缺图时是否深度搜索(默认导出行为)
|
||||||
|
export async function setExportDefaultImageDeepSearchOnMiss(enabled: boolean): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
export type ExportWriteLayout = 'A' | 'B' | 'C'
|
export type ExportWriteLayout = 'A' | 'B' | 'C'
|
||||||
|
|
||||||
export async function getExportWriteLayout(): Promise<ExportWriteLayout> {
|
export async function getExportWriteLayout(): Promise<ExportWriteLayout> {
|
||||||
|
|||||||
1
src/types/electron.d.ts
vendored
1
src/types/electron.d.ts
vendored
@@ -863,6 +863,7 @@ export interface ExportOptions {
|
|||||||
sessionNameWithTypePrefix?: boolean
|
sessionNameWithTypePrefix?: boolean
|
||||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||||
exportConcurrency?: number
|
exportConcurrency?: number
|
||||||
|
imageDeepSearchOnMiss?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportProgress {
|
export interface ExportProgress {
|
||||||
|
|||||||
Reference in New Issue
Block a user