mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
improvement(chat-export): 优化导出取消响应并补充保存进度反馈
- 导出弹层展示实际生成位置、浏览器目录保存进度和取消中状态\n- 导出服务补充取消检查、链路追踪与实时同步暂停恢复\n- 预建媒体索引并减少 emoji 空查找开销,补充相关测试
This commit is contained in:
@@ -1440,39 +1440,12 @@
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-800 mb-2">消息类型(导出内容)</div>
|
||||
<div class="mt-2 p-3 bg-gray-50 rounded-md border border-gray-200">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded border border-gray-200 bg-white hover:bg-gray-50"
|
||||
@click="exportMessageTypes = exportMessageTypeOptions.map((x) => x.value)"
|
||||
<div class="grid grid-cols-2 gap-x-2 gap-y-2 text-[13px] text-gray-700 md:grid-cols-[repeat(13,max-content)] md:justify-between md:gap-x-3 md:gap-y-0">
|
||||
<label
|
||||
v-for="opt in exportMessageTypeOptions"
|
||||
:key="opt.value"
|
||||
class="flex items-center gap-1.5 whitespace-nowrap md:flex-shrink-0"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded border border-gray-200 bg-white hover:bg-gray-50"
|
||||
@click="exportMessageTypes = ['voice']"
|
||||
>
|
||||
只语音
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded border border-gray-200 bg-white hover:bg-gray-50"
|
||||
@click="exportMessageTypes = ['transfer']"
|
||||
>
|
||||
只转账
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded border border-gray-200 bg-white hover:bg-gray-50"
|
||||
@click="exportMessageTypes = ['redPacket']"
|
||||
>
|
||||
只红包
|
||||
</button>
|
||||
<div class="ml-auto text-xs text-gray-500">已选 {{ exportMessageTypes.length }} 项</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 md:grid-cols-4 gap-2 text-sm text-gray-700">
|
||||
<label v-for="opt in exportMessageTypeOptions" :key="opt.value" class="flex items-center gap-2">
|
||||
<input type="checkbox" :value="opt.value" v-model="exportMessageTypes" />
|
||||
<span>{{ opt.label }}</span>
|
||||
</label>
|
||||
@@ -1512,7 +1485,7 @@
|
||||
v-if="exportFolder"
|
||||
type="button"
|
||||
class="text-sm px-3 py-2 rounded-md bg-white border border-gray-200 hover:bg-gray-50"
|
||||
@click="exportFolder = ''; exportFolderHandle = null; exportSaveMsg = ''"
|
||||
@click="clearExportFolderSelection"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
@@ -1563,34 +1536,32 @@
|
||||
<div>消息:{{ exportJob.progress?.messagesExported || 0 }};媒体:{{ exportJob.progress?.mediaCopied || 0 }};缺失:{{ exportJob.progress?.mediaMissing || 0 }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
v-if="exportJob.status === 'done' && hasWebExportFolder"
|
||||
class="text-sm px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650] disabled:opacity-60"
|
||||
type="button"
|
||||
:disabled="exportSaveBusy"
|
||||
@click="saveExportToSelectedFolder"
|
||||
>
|
||||
{{ exportSaveBusy ? '保存中...' : '保存到已选目录' }}
|
||||
</button>
|
||||
<div v-if="exportJob.status === 'done'" class="mt-3 rounded-md border border-gray-200 bg-white/80 px-3 py-2 text-xs text-gray-700 space-y-2">
|
||||
<div>
|
||||
<span class="font-medium text-gray-900">实际生成位置:</span>
|
||||
<div class="mt-1 break-all">{{ exportBackendZipPath || '未生成' }}</div>
|
||||
</div>
|
||||
<div v-if="hasWebExportFolder">
|
||||
<span class="font-medium text-gray-900">浏览器目录:</span>
|
||||
<div class="mt-1 break-all">{{ exportFolder || '未选择' }}</div>
|
||||
</div>
|
||||
<div v-if="exportSaveState === 'saving'" class="text-sky-600 whitespace-pre-wrap">{{ exportSaveProgressText }}</div>
|
||||
<div v-else-if="exportSaveMsg" class="text-green-600 whitespace-pre-wrap">{{ exportSaveMsg }}</div>
|
||||
<div v-else-if="exportSaveError" class="text-red-600 whitespace-pre-wrap">{{ exportSaveError }}</div>
|
||||
<div v-if="hasWebExportFolder" class="text-gray-500">
|
||||
浏览器模式通常会在写入完成后才显示文件,且出于安全限制,这里只能显示目录名,不能显示完整磁盘路径。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="exportJob.status === 'done' && !hasWebExportFolder" class="mt-3 flex items-center gap-2">
|
||||
<a
|
||||
v-if="exportJob.status === 'done' && !hasWebExportFolder"
|
||||
class="text-sm px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650]"
|
||||
:href="getExportDownloadUrl(exportJob.exportId)"
|
||||
target="_blank"
|
||||
>
|
||||
下载 ZIP
|
||||
</a>
|
||||
<button
|
||||
v-if="exportJob.status === 'running'"
|
||||
class="text-sm px-3 py-2 rounded-md bg-white border border-gray-200 hover:bg-gray-50"
|
||||
type="button"
|
||||
@click="cancelCurrentExport"
|
||||
>
|
||||
取消任务
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="exportSaveMsg" class="mt-2 text-xs text-green-600 whitespace-pre-wrap">{{ exportSaveMsg }}</div>
|
||||
|
||||
<div v-if="exportJob.status === 'error'" class="mt-2 text-sm text-red-600 whitespace-pre-wrap">
|
||||
{{ exportJob.error || '导出失败' }}
|
||||
@@ -1603,6 +1574,7 @@
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
v-if="!(exportJob && (exportJob.status === 'queued' || exportJob.status === 'running'))"
|
||||
class="text-sm px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650] disabled:opacity-60"
|
||||
type="button"
|
||||
@click="startChatExport"
|
||||
@@ -1610,6 +1582,15 @@
|
||||
>
|
||||
{{ isExportCreating ? '创建中...' : '开始导出' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="text-sm px-3 py-2 rounded-md bg-white border border-red-200 text-red-600 hover:bg-red-50 disabled:opacity-60"
|
||||
type="button"
|
||||
@click="cancelCurrentExport"
|
||||
:disabled="exportCancelRequested"
|
||||
>
|
||||
{{ exportCancelRequested ? '取消中...' : '取消任务' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
const exportFolderHandle = ref(null)
|
||||
const exportSaveBusy = ref(false)
|
||||
const exportSaveMsg = ref('')
|
||||
const exportSaveError = ref('')
|
||||
const exportSaveState = ref('idle')
|
||||
const exportSaveBytesWritten = ref(0)
|
||||
const exportSaveBytesTotal = ref(0)
|
||||
const exportAutoSavedFor = ref('')
|
||||
const exportCancelRequested = ref(false)
|
||||
|
||||
const exportSearchQuery = ref('')
|
||||
const exportListTab = ref('all')
|
||||
@@ -50,6 +55,27 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
const next = Number(value)
|
||||
return Number.isFinite(next) ? next : 0
|
||||
}
|
||||
const formatBytes = (value) => {
|
||||
const bytes = Number(value)
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let size = bytes
|
||||
let index = 0
|
||||
while (size >= 1024 && index < units.length - 1) {
|
||||
size /= 1024
|
||||
index += 1
|
||||
}
|
||||
const digits = size >= 100 || index === 0 ? 0 : size >= 10 ? 1 : 2
|
||||
return `${size.toFixed(digits)} ${units[index]}`
|
||||
}
|
||||
const resetExportSaveFeedback = ({ resetAutoSavedFor = false } = {}) => {
|
||||
exportSaveMsg.value = ''
|
||||
exportSaveError.value = ''
|
||||
exportSaveState.value = 'idle'
|
||||
exportSaveBytesWritten.value = 0
|
||||
exportSaveBytesTotal.value = 0
|
||||
if (resetAutoSavedFor) exportAutoSavedFor.value = ''
|
||||
}
|
||||
|
||||
const exportOverallPercent = computed(() => {
|
||||
const job = exportJob.value
|
||||
@@ -72,6 +98,17 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
if (total <= 0) return null
|
||||
return Math.round(clamp01(done / total) * 100)
|
||||
})
|
||||
const exportBackendZipPath = computed(() => {
|
||||
return String(exportJob.value?.zipPath || '').trim()
|
||||
})
|
||||
const exportSaveProgressText = computed(() => {
|
||||
if (exportSaveState.value !== 'saving') return ''
|
||||
const fileName = guessExportZipName(exportJob.value)
|
||||
if (exportSaveBytesTotal.value > 0) {
|
||||
return `正在保存到浏览器目录:${fileName}(${formatBytes(exportSaveBytesWritten.value)} / ${formatBytes(exportSaveBytesTotal.value)})`
|
||||
}
|
||||
return `正在保存到浏览器目录:${fileName}(${formatBytes(exportSaveBytesWritten.value)})`
|
||||
})
|
||||
|
||||
const normalizeExportSelectedUsernames = (list) => {
|
||||
const seen = new Set()
|
||||
@@ -179,7 +216,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
|
||||
const chooseExportFolder = async () => {
|
||||
exportError.value = ''
|
||||
exportSaveMsg.value = ''
|
||||
resetExportSaveFeedback()
|
||||
try {
|
||||
if (!process.client) {
|
||||
exportError.value = '当前环境不支持选择导出目录'
|
||||
@@ -206,6 +243,10 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
|
||||
exportError.value = '当前浏览器不支持目录选择,请使用桌面端或 Chromium 新版浏览器'
|
||||
} catch (error) {
|
||||
const message = String(error?.message || '').trim()
|
||||
if (error?.name === 'AbortError' || message.includes('The user aborted a request')) {
|
||||
return
|
||||
}
|
||||
exportError.value = error?.message || '选择导出目录失败'
|
||||
}
|
||||
}
|
||||
@@ -227,7 +268,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
const saveExportToSelectedFolder = async (options = {}) => {
|
||||
const autoSave = !!options?.auto
|
||||
exportError.value = ''
|
||||
exportSaveMsg.value = ''
|
||||
resetExportSaveFeedback()
|
||||
if (!process.client || !isWebDirectoryPickerSupported()) {
|
||||
exportError.value = '当前环境不支持保存到浏览器目录'
|
||||
return
|
||||
@@ -245,6 +286,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
}
|
||||
|
||||
exportSaveBusy.value = true
|
||||
exportSaveState.value = 'saving'
|
||||
try {
|
||||
const response = await fetch(getExportDownloadUrl(exportId))
|
||||
if (!response.ok) {
|
||||
@@ -256,18 +298,46 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
})
|
||||
throw new Error(`下载导出文件失败(${response.status})`)
|
||||
}
|
||||
const blob = await response.blob()
|
||||
exportSaveBytesTotal.value = asNumber(response.headers.get('Content-Length'))
|
||||
const fileName = guessExportZipName(exportJob.value)
|
||||
const fileHandle = await handle.getFileHandle(fileName, { create: true })
|
||||
const writable = await fileHandle.createWritable()
|
||||
await writable.write(blob)
|
||||
await writable.close()
|
||||
if (response.body && typeof response.body.getReader === 'function') {
|
||||
const reader = response.body.getReader()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (!value || !value.byteLength) continue
|
||||
await writable.write(value)
|
||||
exportSaveBytesWritten.value += value.byteLength
|
||||
}
|
||||
await writable.close()
|
||||
} catch (error) {
|
||||
try {
|
||||
await reader.cancel()
|
||||
} catch {}
|
||||
try {
|
||||
await writable.abort()
|
||||
} catch {}
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
const blob = await response.blob()
|
||||
exportSaveBytesWritten.value = asNumber(blob.size)
|
||||
if (exportSaveBytesTotal.value <= 0) exportSaveBytesTotal.value = exportSaveBytesWritten.value
|
||||
await writable.write(blob)
|
||||
await writable.close()
|
||||
}
|
||||
exportAutoSavedFor.value = String(exportId)
|
||||
exportSaveState.value = 'success'
|
||||
const folderLabel = String(exportFolder.value || '').trim() || '已选目录'
|
||||
exportSaveMsg.value = autoSave
|
||||
? `已自动保存到已选目录:${fileName}`
|
||||
: `已保存到已选目录:${fileName}`
|
||||
? `浏览器目录自动保存成功:${fileName}\n位置:${folderLabel}`
|
||||
: `浏览器目录保存成功:${fileName}\n位置:${folderLabel}`
|
||||
} catch (error) {
|
||||
exportError.value = error?.message || '保存到浏览器目录失败'
|
||||
exportSaveState.value = 'error'
|
||||
exportSaveError.value = `浏览器目录保存失败:${error?.message || '未知错误'}`
|
||||
} finally {
|
||||
exportSaveBusy.value = false
|
||||
}
|
||||
@@ -337,7 +407,8 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
const openExportModal = () => {
|
||||
exportModalOpen.value = true
|
||||
exportError.value = ''
|
||||
exportSaveMsg.value = ''
|
||||
resetExportSaveFeedback({ resetAutoSavedFor: true })
|
||||
exportCancelRequested.value = false
|
||||
exportSearchQuery.value = ''
|
||||
exportListTab.value = 'all'
|
||||
exportSelectedUsernames.value = []
|
||||
@@ -356,6 +427,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
exportError.value = ''
|
||||
}
|
||||
|
||||
const clearExportFolderSelection = () => {
|
||||
exportFolder.value = ''
|
||||
exportFolderHandle.value = null
|
||||
resetExportSaveFeedback({ resetAutoSavedFor: true })
|
||||
}
|
||||
|
||||
watch(exportModalOpen, (open) => {
|
||||
if (!process.client) return
|
||||
if (!open) {
|
||||
@@ -382,6 +459,9 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
status: String(exportJob.value?.status || '')
|
||||
}),
|
||||
async ({ exportId, status }) => {
|
||||
if (status !== 'queued' && status !== 'running') {
|
||||
exportCancelRequested.value = false
|
||||
}
|
||||
if (!process.client || status !== 'done' || !exportId) return
|
||||
if (!hasWebExportFolder.value) return
|
||||
if (exportAutoSavedFor.value === exportId) return
|
||||
@@ -392,7 +472,8 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
|
||||
const startChatExport = async () => {
|
||||
exportError.value = ''
|
||||
exportSaveMsg.value = ''
|
||||
resetExportSaveFeedback({ resetAutoSavedFor: true })
|
||||
exportCancelRequested.value = false
|
||||
if (!selectedAccount.value) {
|
||||
exportError.value = '未选择账号'
|
||||
return
|
||||
@@ -490,13 +571,17 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
|
||||
const cancelCurrentExport = async () => {
|
||||
const exportId = exportJob.value?.exportId
|
||||
if (!exportId) return
|
||||
const status = String(exportJob.value?.status || '')
|
||||
if (!exportId || (status !== 'queued' && status !== 'running') || exportCancelRequested.value) return
|
||||
|
||||
exportError.value = ''
|
||||
exportCancelRequested.value = true
|
||||
try {
|
||||
await api.cancelChatExport(exportId)
|
||||
const response = await api.getChatExport(exportId)
|
||||
exportJob.value = response?.job || exportJob.value
|
||||
} catch (error) {
|
||||
exportCancelRequested.value = false
|
||||
exportError.value = error?.message || '取消导出失败'
|
||||
}
|
||||
}
|
||||
@@ -518,7 +603,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
exportFolderHandle,
|
||||
exportSaveBusy,
|
||||
exportSaveMsg,
|
||||
exportSaveError,
|
||||
exportSaveState,
|
||||
exportSaveProgressText,
|
||||
exportBackendZipPath,
|
||||
exportAutoSavedFor,
|
||||
exportCancelRequested,
|
||||
exportSearchQuery,
|
||||
exportListTab,
|
||||
exportSelectedUsernames,
|
||||
@@ -532,6 +622,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
isExportContactSelected,
|
||||
hasWebExportFolder,
|
||||
chooseExportFolder,
|
||||
clearExportFolderSelection,
|
||||
getExportDownloadUrl,
|
||||
saveExportToSelectedFolder,
|
||||
openExportModal,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
import importlib
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestChatExportCancelResponsiveness(unittest.TestCase):
|
||||
def _reload_export_module(self):
|
||||
import wechat_decrypt_tool.chat_export_service as chat_export_service
|
||||
|
||||
importlib.reload(chat_export_service)
|
||||
return chat_export_service
|
||||
|
||||
def test_json_writer_checks_cancel_between_messages(self):
|
||||
svc = self._reload_export_module()
|
||||
job = svc.ExportJob(export_id="exp_cancel", account="wxid_test", status="running")
|
||||
rows = [
|
||||
svc._Row(
|
||||
db_stem="message_0",
|
||||
table_name="msg_demo",
|
||||
local_id=1,
|
||||
server_id=1001,
|
||||
local_type=1,
|
||||
sort_seq=1,
|
||||
create_time=1735689601,
|
||||
raw_text="第一条",
|
||||
sender_username="wxid_friend",
|
||||
is_sent=False,
|
||||
),
|
||||
svc._Row(
|
||||
db_stem="message_0",
|
||||
table_name="msg_demo",
|
||||
local_id=2,
|
||||
server_id=1002,
|
||||
local_type=1,
|
||||
sort_seq=2,
|
||||
create_time=1735689602,
|
||||
raw_text="第二条",
|
||||
sender_username="wxid_friend",
|
||||
is_sent=False,
|
||||
),
|
||||
]
|
||||
|
||||
original_iter = svc._iter_rows_for_conversation
|
||||
original_parse = svc._parse_message_for_export
|
||||
try:
|
||||
def fake_iter_rows_for_conversation(**_kwargs):
|
||||
yield rows[0]
|
||||
job.cancel_requested = True
|
||||
yield rows[1]
|
||||
|
||||
def fake_parse_message_for_export(**kwargs):
|
||||
row = kwargs["row"]
|
||||
return {
|
||||
"id": f"{row.db_stem}:{row.table_name}:{row.local_id}",
|
||||
"localId": row.local_id,
|
||||
"serverId": row.server_id,
|
||||
"createTime": row.create_time,
|
||||
"createTimeText": "2025-01-01 08:00:00",
|
||||
"sortSeq": row.sort_seq,
|
||||
"type": row.local_type,
|
||||
"renderType": "text",
|
||||
"isSent": bool(row.is_sent),
|
||||
"senderUsername": row.sender_username,
|
||||
"conversationUsername": kwargs["conv_username"],
|
||||
"isGroup": False,
|
||||
"content": row.raw_text,
|
||||
"title": "",
|
||||
"url": "",
|
||||
"from": "",
|
||||
"fromUsername": "",
|
||||
"linkType": "",
|
||||
"linkStyle": "",
|
||||
"objectId": "",
|
||||
"objectNonceId": "",
|
||||
"recordItem": "",
|
||||
"thumbUrl": "",
|
||||
"imageMd5": "",
|
||||
"imageFileId": "",
|
||||
"imageMd5Candidates": [],
|
||||
"imageFileIdCandidates": [],
|
||||
"imageUrl": "",
|
||||
"emojiMd5": "",
|
||||
"emojiUrl": "",
|
||||
"videoMd5": "",
|
||||
"videoThumbMd5": "",
|
||||
"videoFileId": "",
|
||||
"videoThumbFileId": "",
|
||||
"videoUrl": "",
|
||||
"videoThumbUrl": "",
|
||||
"voiceLength": "",
|
||||
"quoteUsername": "",
|
||||
"quoteServerId": "",
|
||||
"quoteType": "",
|
||||
"quoteThumbUrl": "",
|
||||
"quoteVoiceLength": "",
|
||||
"quoteTitle": "",
|
||||
"quoteContent": "",
|
||||
"amount": "",
|
||||
"coverUrl": "",
|
||||
"fileSize": "",
|
||||
"fileMd5": "",
|
||||
"paySubType": "",
|
||||
"transferStatus": "",
|
||||
"transferId": "",
|
||||
"voipType": "",
|
||||
"locationLat": None,
|
||||
"locationLng": None,
|
||||
"locationPoiname": "",
|
||||
"locationLabel": "",
|
||||
}
|
||||
|
||||
svc._iter_rows_for_conversation = fake_iter_rows_for_conversation
|
||||
svc._parse_message_for_export = fake_parse_message_for_export
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
zip_path = Path(td) / "out.zip"
|
||||
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
with self.assertRaises(svc._JobCancelled):
|
||||
svc._write_conversation_json(
|
||||
zf=zf,
|
||||
conv_dir="conversations/demo",
|
||||
account_dir=Path(td),
|
||||
conv_username="wxid_friend",
|
||||
conv_name="测试好友",
|
||||
conv_avatar_path="",
|
||||
conv_is_group=False,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
want_types=None,
|
||||
local_types=None,
|
||||
resource_conn=None,
|
||||
resource_chat_id=None,
|
||||
head_image_conn=None,
|
||||
resolve_display_name=lambda username: username,
|
||||
privacy_mode=False,
|
||||
include_media=False,
|
||||
media_kinds=[],
|
||||
media_written={},
|
||||
avatar_written={},
|
||||
report={"errors": [], "missingMedia": []},
|
||||
allow_process_key_extract=False,
|
||||
media_db_path=Path(td) / "media_0.db",
|
||||
media_index=None,
|
||||
job=job,
|
||||
lock=threading.Lock(),
|
||||
)
|
||||
self.assertEqual(job.progress.messages_exported, 1)
|
||||
self.assertTrue(job.cancel_requested)
|
||||
finally:
|
||||
svc._iter_rows_for_conversation = original_iter
|
||||
svc._parse_message_for_export = original_parse
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,130 @@
|
||||
import importlib
|
||||
import io
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
class TestChatExportEmojiFastMiss(unittest.TestCase):
|
||||
def _reload_export_module(self):
|
||||
import wechat_decrypt_tool.chat_export_service as chat_export_service
|
||||
|
||||
importlib.reload(chat_export_service)
|
||||
return chat_export_service
|
||||
|
||||
def test_emoji_miss_skips_fallback_scan_and_caches_negative_result(self):
|
||||
svc = self._reload_export_module()
|
||||
md5 = "8f436b616da7832e5c206ba3d781a714"
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
original_try_find = svc._try_find_decrypted_resource
|
||||
original_resolve_kind = svc._resolve_media_path_for_kind
|
||||
try:
|
||||
svc._try_find_decrypted_resource = lambda *args, **kwargs: None
|
||||
|
||||
def fake_resolve_kind(account_dir, kind, md5, username, allow_fallback_scan=True):
|
||||
calls.append(
|
||||
{
|
||||
"account_dir": account_dir,
|
||||
"kind": kind,
|
||||
"md5": md5,
|
||||
"username": username,
|
||||
"allow_fallback_scan": allow_fallback_scan,
|
||||
}
|
||||
)
|
||||
return None
|
||||
|
||||
svc._resolve_media_path_for_kind = fake_resolve_kind
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
media_written: dict[str, str] = {}
|
||||
with io.BytesIO() as buf, zipfile.ZipFile(buf, "w") as zf:
|
||||
arc1, is_new1 = svc._materialize_media(
|
||||
zf=zf,
|
||||
account_dir=Path(td),
|
||||
conv_username="room@chatroom",
|
||||
kind="emoji",
|
||||
md5=md5,
|
||||
file_id="",
|
||||
media_written=media_written,
|
||||
suggested_name="",
|
||||
media_index=None,
|
||||
)
|
||||
arc2, is_new2 = svc._materialize_media(
|
||||
zf=zf,
|
||||
account_dir=Path(td),
|
||||
conv_username="room@chatroom",
|
||||
kind="emoji",
|
||||
md5=md5,
|
||||
file_id="",
|
||||
media_written=media_written,
|
||||
suggested_name="",
|
||||
media_index=None,
|
||||
)
|
||||
|
||||
self.assertEqual(arc1, "")
|
||||
self.assertEqual(arc2, "")
|
||||
self.assertFalse(is_new1)
|
||||
self.assertFalse(is_new2)
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(calls[0]["kind"], "emoji")
|
||||
self.assertEqual(calls[0]["md5"], md5)
|
||||
self.assertEqual(calls[0]["username"], "room@chatroom")
|
||||
self.assertFalse(bool(calls[0]["allow_fallback_scan"]))
|
||||
self.assertIn("emoji:" + md5, media_written)
|
||||
self.assertEqual(media_written["emoji:" + md5], "")
|
||||
finally:
|
||||
svc._try_find_decrypted_resource = original_try_find
|
||||
svc._resolve_media_path_for_kind = original_resolve_kind
|
||||
|
||||
def test_image_lookup_keeps_fallback_scan_enabled(self):
|
||||
svc = self._reload_export_module()
|
||||
md5 = "80793a35a19810699a03579c654f4c50"
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
original_try_find = svc._try_find_decrypted_resource
|
||||
original_resolve_kind = svc._resolve_media_path_for_kind
|
||||
try:
|
||||
svc._try_find_decrypted_resource = lambda *args, **kwargs: None
|
||||
|
||||
def fake_resolve_kind(account_dir, kind, md5, username, allow_fallback_scan=True):
|
||||
calls.append(
|
||||
{
|
||||
"kind": kind,
|
||||
"md5": md5,
|
||||
"allow_fallback_scan": allow_fallback_scan,
|
||||
}
|
||||
)
|
||||
return None
|
||||
|
||||
svc._resolve_media_path_for_kind = fake_resolve_kind
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
with io.BytesIO() as buf, zipfile.ZipFile(buf, "w") as zf:
|
||||
arc, is_new = svc._materialize_media(
|
||||
zf=zf,
|
||||
account_dir=Path(td),
|
||||
conv_username="friend",
|
||||
kind="image",
|
||||
md5=md5,
|
||||
file_id="",
|
||||
media_written={},
|
||||
suggested_name="",
|
||||
media_index=None,
|
||||
)
|
||||
|
||||
self.assertEqual(arc, "")
|
||||
self.assertFalse(is_new)
|
||||
self.assertEqual(len(calls), 2)
|
||||
self.assertEqual(calls[0]["kind"], "image")
|
||||
self.assertFalse(bool(calls[0]["allow_fallback_scan"]))
|
||||
self.assertEqual(calls[1]["kind"], "image")
|
||||
self.assertTrue(bool(calls[1]["allow_fallback_scan"]))
|
||||
finally:
|
||||
svc._try_find_decrypted_resource = original_try_find
|
||||
svc._resolve_media_path_for_kind = original_resolve_kind
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user