improvement(chat-export): 优化导出取消响应并补充保存进度反馈

- 导出弹层展示实际生成位置、浏览器目录保存进度和取消中状态\n- 导出服务补充取消检查、链路追踪与实时同步暂停恢复\n- 预建媒体索引并减少 emoji 空查找开销,补充相关测试
This commit is contained in:
2977094657
2026-04-15 01:27:55 +08:00
Unverified
parent 23932bf89c
commit 3b34e786f1
5 changed files with 1315 additions and 84 deletions
+34 -53
View File
@@ -1440,39 +1440,12 @@
<div> <div>
<div class="text-sm font-medium text-gray-800 mb-2">消息类型导出内容</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="mt-2 p-3 bg-gray-50 rounded-md border border-gray-200">
<div class="flex items-center gap-2 mb-2"> <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">
<button <label
type="button" v-for="opt in exportMessageTypeOptions"
class="text-xs px-2 py-1 rounded border border-gray-200 bg-white hover:bg-gray-50" :key="opt.value"
@click="exportMessageTypes = exportMessageTypeOptions.map((x) => x.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" /> <input type="checkbox" :value="opt.value" v-model="exportMessageTypes" />
<span>{{ opt.label }}</span> <span>{{ opt.label }}</span>
</label> </label>
@@ -1512,7 +1485,7 @@
v-if="exportFolder" v-if="exportFolder"
type="button" type="button"
class="text-sm px-3 py-2 rounded-md bg-white border border-gray-200 hover:bg-gray-50" 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> </button>
@@ -1563,34 +1536,32 @@
<div>消息{{ exportJob.progress?.messagesExported || 0 }}媒体{{ exportJob.progress?.mediaCopied || 0 }}缺失{{ exportJob.progress?.mediaMissing || 0 }}</div> <div>消息{{ exportJob.progress?.messagesExported || 0 }}媒体{{ exportJob.progress?.mediaCopied || 0 }}缺失{{ exportJob.progress?.mediaMissing || 0 }}</div>
</div> </div>
<div class="mt-3 flex items-center gap-2"> <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">
<button <div>
v-if="exportJob.status === 'done' && hasWebExportFolder" <span class="font-medium text-gray-900">实际生成位置</span>
class="text-sm px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650] disabled:opacity-60" <div class="mt-1 break-all">{{ exportBackendZipPath || '未生成' }}</div>
type="button" </div>
:disabled="exportSaveBusy" <div v-if="hasWebExportFolder">
@click="saveExportToSelectedFolder" <span class="font-medium text-gray-900">浏览器目录</span>
> <div class="mt-1 break-all">{{ exportFolder || '未选择' }}</div>
{{ exportSaveBusy ? '保存中...' : '保存到已选目录' }} </div>
</button> <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 <a
v-if="exportJob.status === 'done' && !hasWebExportFolder"
class="text-sm px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650]" class="text-sm px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650]"
:href="getExportDownloadUrl(exportJob.exportId)" :href="getExportDownloadUrl(exportJob.exportId)"
target="_blank" target="_blank"
> >
下载 ZIP 下载 ZIP
</a> </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>
<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"> <div v-if="exportJob.status === 'error'" class="mt-2 text-sm text-red-600 whitespace-pre-wrap">
{{ exportJob.error || '导出失败' }} {{ exportJob.error || '导出失败' }}
@@ -1603,6 +1574,7 @@
关闭 关闭
</button> </button>
<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" class="text-sm px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650] disabled:opacity-60"
type="button" type="button"
@click="startChatExport" @click="startChatExport"
@@ -1610,6 +1582,15 @@
> >
{{ isExportCreating ? '创建中...' : '开始导出' }} {{ isExportCreating ? '创建中...' : '开始导出' }}
</button> </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> </div>
</div> </div>
+102 -11
View File
@@ -35,7 +35,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
const exportFolderHandle = ref(null) const exportFolderHandle = ref(null)
const exportSaveBusy = ref(false) const exportSaveBusy = ref(false)
const exportSaveMsg = ref('') const exportSaveMsg = ref('')
const exportSaveError = ref('')
const exportSaveState = ref('idle')
const exportSaveBytesWritten = ref(0)
const exportSaveBytesTotal = ref(0)
const exportAutoSavedFor = ref('') const exportAutoSavedFor = ref('')
const exportCancelRequested = ref(false)
const exportSearchQuery = ref('') const exportSearchQuery = ref('')
const exportListTab = ref('all') const exportListTab = ref('all')
@@ -50,6 +55,27 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
const next = Number(value) const next = Number(value)
return Number.isFinite(next) ? next : 0 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 exportOverallPercent = computed(() => {
const job = exportJob.value const job = exportJob.value
@@ -72,6 +98,17 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
if (total <= 0) return null if (total <= 0) return null
return Math.round(clamp01(done / total) * 100) 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 normalizeExportSelectedUsernames = (list) => {
const seen = new Set() const seen = new Set()
@@ -179,7 +216,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
const chooseExportFolder = async () => { const chooseExportFolder = async () => {
exportError.value = '' exportError.value = ''
exportSaveMsg.value = '' resetExportSaveFeedback()
try { try {
if (!process.client) { if (!process.client) {
exportError.value = '当前环境不支持选择导出目录' exportError.value = '当前环境不支持选择导出目录'
@@ -206,6 +243,10 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
exportError.value = '当前浏览器不支持目录选择,请使用桌面端或 Chromium 新版浏览器' exportError.value = '当前浏览器不支持目录选择,请使用桌面端或 Chromium 新版浏览器'
} catch (error) { } catch (error) {
const message = String(error?.message || '').trim()
if (error?.name === 'AbortError' || message.includes('The user aborted a request')) {
return
}
exportError.value = error?.message || '选择导出目录失败' exportError.value = error?.message || '选择导出目录失败'
} }
} }
@@ -227,7 +268,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
const saveExportToSelectedFolder = async (options = {}) => { const saveExportToSelectedFolder = async (options = {}) => {
const autoSave = !!options?.auto const autoSave = !!options?.auto
exportError.value = '' exportError.value = ''
exportSaveMsg.value = '' resetExportSaveFeedback()
if (!process.client || !isWebDirectoryPickerSupported()) { if (!process.client || !isWebDirectoryPickerSupported()) {
exportError.value = '当前环境不支持保存到浏览器目录' exportError.value = '当前环境不支持保存到浏览器目录'
return return
@@ -245,6 +286,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
} }
exportSaveBusy.value = true exportSaveBusy.value = true
exportSaveState.value = 'saving'
try { try {
const response = await fetch(getExportDownloadUrl(exportId)) const response = await fetch(getExportDownloadUrl(exportId))
if (!response.ok) { if (!response.ok) {
@@ -256,18 +298,46 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
}) })
throw new Error(`下载导出文件失败(${response.status}`) throw new Error(`下载导出文件失败(${response.status}`)
} }
const blob = await response.blob() exportSaveBytesTotal.value = asNumber(response.headers.get('Content-Length'))
const fileName = guessExportZipName(exportJob.value) const fileName = guessExportZipName(exportJob.value)
const fileHandle = await handle.getFileHandle(fileName, { create: true }) const fileHandle = await handle.getFileHandle(fileName, { create: true })
const writable = await fileHandle.createWritable() const writable = await fileHandle.createWritable()
await writable.write(blob) if (response.body && typeof response.body.getReader === 'function') {
await writable.close() 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) exportAutoSavedFor.value = String(exportId)
exportSaveState.value = 'success'
const folderLabel = String(exportFolder.value || '').trim() || '已选目录'
exportSaveMsg.value = autoSave exportSaveMsg.value = autoSave
? `已自动保存到已选目录:${fileName}` ? `浏览器目录自动保存成功:${fileName}\n位置:${folderLabel}`
: `已保存到已选目录:${fileName}` : `浏览器目录保存成功:${fileName}\n位置:${folderLabel}`
} catch (error) { } catch (error) {
exportError.value = error?.message || '保存到浏览器目录失败' exportSaveState.value = 'error'
exportSaveError.value = `浏览器目录保存失败:${error?.message || '未知错误'}`
} finally { } finally {
exportSaveBusy.value = false exportSaveBusy.value = false
} }
@@ -337,7 +407,8 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
const openExportModal = () => { const openExportModal = () => {
exportModalOpen.value = true exportModalOpen.value = true
exportError.value = '' exportError.value = ''
exportSaveMsg.value = '' resetExportSaveFeedback({ resetAutoSavedFor: true })
exportCancelRequested.value = false
exportSearchQuery.value = '' exportSearchQuery.value = ''
exportListTab.value = 'all' exportListTab.value = 'all'
exportSelectedUsernames.value = [] exportSelectedUsernames.value = []
@@ -356,6 +427,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
exportError.value = '' exportError.value = ''
} }
const clearExportFolderSelection = () => {
exportFolder.value = ''
exportFolderHandle.value = null
resetExportSaveFeedback({ resetAutoSavedFor: true })
}
watch(exportModalOpen, (open) => { watch(exportModalOpen, (open) => {
if (!process.client) return if (!process.client) return
if (!open) { if (!open) {
@@ -382,6 +459,9 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
status: String(exportJob.value?.status || '') status: String(exportJob.value?.status || '')
}), }),
async ({ exportId, status }) => { async ({ exportId, status }) => {
if (status !== 'queued' && status !== 'running') {
exportCancelRequested.value = false
}
if (!process.client || status !== 'done' || !exportId) return if (!process.client || status !== 'done' || !exportId) return
if (!hasWebExportFolder.value) return if (!hasWebExportFolder.value) return
if (exportAutoSavedFor.value === exportId) return if (exportAutoSavedFor.value === exportId) return
@@ -392,7 +472,8 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
const startChatExport = async () => { const startChatExport = async () => {
exportError.value = '' exportError.value = ''
exportSaveMsg.value = '' resetExportSaveFeedback({ resetAutoSavedFor: true })
exportCancelRequested.value = false
if (!selectedAccount.value) { if (!selectedAccount.value) {
exportError.value = '未选择账号' exportError.value = '未选择账号'
return return
@@ -490,13 +571,17 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
const cancelCurrentExport = async () => { const cancelCurrentExport = async () => {
const exportId = exportJob.value?.exportId 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 { try {
await api.cancelChatExport(exportId) await api.cancelChatExport(exportId)
const response = await api.getChatExport(exportId) const response = await api.getChatExport(exportId)
exportJob.value = response?.job || exportJob.value exportJob.value = response?.job || exportJob.value
} catch (error) { } catch (error) {
exportCancelRequested.value = false
exportError.value = error?.message || '取消导出失败' exportError.value = error?.message || '取消导出失败'
} }
} }
@@ -518,7 +603,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
exportFolderHandle, exportFolderHandle,
exportSaveBusy, exportSaveBusy,
exportSaveMsg, exportSaveMsg,
exportSaveError,
exportSaveState,
exportSaveProgressText,
exportBackendZipPath,
exportAutoSavedFor, exportAutoSavedFor,
exportCancelRequested,
exportSearchQuery, exportSearchQuery,
exportListTab, exportListTab,
exportSelectedUsernames, exportSelectedUsernames,
@@ -532,6 +622,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
isExportContactSelected, isExportContactSelected,
hasWebExportFolder, hasWebExportFolder,
chooseExportFolder, chooseExportFolder,
clearExportFolderSelection,
getExportDownloadUrl, getExportDownloadUrl,
saveExportToSelectedFolder, saveExportToSelectedFolder,
openExportModal, 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()
+130
View File
@@ -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()