-
-
diff --git a/frontend/composables/chat/useChatExport.js b/frontend/composables/chat/useChatExport.js
index 5843f76..148437d 100644
--- a/frontend/composables/chat/useChatExport.js
+++ b/frontend/composables/chat/useChatExport.js
@@ -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,
diff --git a/src/wechat_decrypt_tool/chat_export_service.py b/src/wechat_decrypt_tool/chat_export_service.py
index 3f721e3..ed9ce3d 100644
--- a/src/wechat_decrypt_tool/chat_export_service.py
+++ b/src/wechat_decrypt_tool/chat_export_service.py
@@ -51,8 +51,10 @@ from .chat_helpers import (
_should_keep_session,
_split_group_sender_prefix,
)
+from .chat_realtime_autosync import CHAT_REALTIME_AUTOSYNC
from .logging_config import get_logger
from .media_helpers import (
+ MediaPathIndex,
_convert_silk_to_browser_audio,
_detect_image_media_type,
_fallback_search_media_by_file_id,
@@ -62,6 +64,7 @@ from .media_helpers import (
_resolve_media_path_for_kind,
_try_find_decrypted_resource,
)
+from .perf_trace import create_perf_trace
logger = get_logger(__name__)
@@ -70,6 +73,88 @@ ExportScope = Literal["selected", "all", "groups", "singles"]
ExportStatus = Literal["queued", "running", "done", "error", "cancelled"]
MediaKind = Literal["image", "emoji", "video", "video_thumb", "voice", "file"]
+_EXPORT_PROGRESS_LOG_INTERVAL = 1000
+_EXPORT_SLOW_STEP_MS = 500.0
+
+
+def _elapsed_ms(started_at: float) -> float:
+ return round((time.perf_counter() - started_at) * 1000.0, 1)
+
+
+def _safe_json_dumps(value: Any) -> str:
+ try:
+ return json.dumps(value, ensure_ascii=False, default=str)
+ except Exception:
+ return str(value)
+
+
+def _safe_trace(trace_log: Optional[Callable[..., None]], phase: str, **fields: Any) -> None:
+ if trace_log is None:
+ return
+ try:
+ trace_log(phase, **fields)
+ except Exception:
+ pass
+
+
+def _log_export_slow_step(stage: str, started_at: float, **fields: Any) -> None:
+ elapsed = _elapsed_ms(started_at)
+ if elapsed < _EXPORT_SLOW_STEP_MS:
+ return
+ payload = {
+ **fields,
+ "stage": stage,
+ "elapsedMs": elapsed,
+ "thread": threading.current_thread().name,
+ }
+ logger.info("chat export slow step %s", _safe_json_dumps(payload))
+
+
+def _raise_if_job_cancelled(
+ job: Any,
+ stage: str,
+ trace_log: Optional[Callable[..., None]] = None,
+ **fields: Any,
+) -> None:
+ if not bool(getattr(job, "cancel_requested", False)):
+ return
+ export_id = str(getattr(job, "export_id", "") or "")
+ payload = {
+ **fields,
+ "exportId": export_id,
+ "stage": stage,
+ "thread": threading.current_thread().name,
+ }
+ _safe_trace(trace_log, "cancel_detected", **payload)
+ logger.info("chat export cancel detected %s", _safe_json_dumps(payload))
+ raise _JobCancelled()
+
+
+def _log_writer_progress(
+ trace_log: Optional[Callable[..., None]],
+ *,
+ export_format: str,
+ job: Any,
+ conv_username: str,
+ scanned: int,
+ exported: int,
+ force: bool = False,
+) -> None:
+ if not force and (scanned <= 0 or scanned % _EXPORT_PROGRESS_LOG_INTERVAL != 0):
+ return
+ progress = getattr(job, "progress", None)
+ _safe_trace(
+ trace_log,
+ "writer_progress",
+ format=export_format,
+ conversation=conv_username,
+ scanned=scanned,
+ exported=exported,
+ messagesExported=int(getattr(progress, "messages_exported", 0) or 0),
+ mediaCopied=int(getattr(progress, "media_copied", 0) or 0),
+ mediaMissing=int(getattr(progress, "media_missing", 0) or 0),
+ )
+
def _now_iso() -> str:
return datetime.now().isoformat(timespec="seconds")
@@ -343,6 +428,7 @@ _REMOTE_IMAGE_MAX_BYTES = 5 * 1024 * 1024
_REMOTE_IMAGE_TIMEOUT = (5, 10)
_REMOTE_IMAGE_ALLOWED_CT: dict[str, str] = {
"image/jpeg": "jpg",
+ "image/jpg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
@@ -394,6 +480,7 @@ def _download_remote_image_to_zip(
remote_written: dict[str, str],
report: dict[str, Any],
) -> str:
+ started_at = time.perf_counter()
raw = str(url or "").strip()
if not raw:
return ""
@@ -485,6 +572,15 @@ def _download_remote_image_to_zip(
arc = f"media/remote/{h[:32]}.{ext}"
zf.writestr(arc, bytes(buf))
remote_written[raw] = arc
+ _log_export_slow_step(
+ "download_remote_image",
+ started_at,
+ url=raw,
+ finalUrl=current,
+ arc=arc,
+ contentType=ct,
+ bytes=len(buf),
+ )
return arc
except Exception as e:
last_error = f"request failed: {e}"
@@ -502,6 +598,13 @@ def _download_remote_image_to_zip(
except Exception:
pass
remote_written[raw] = ""
+ _log_export_slow_step(
+ "download_remote_image_failed",
+ started_at,
+ url=raw,
+ finalUrl=current,
+ error=last_error,
+ )
return ""
@@ -2476,11 +2579,32 @@ class ChatExportManager:
with self._lock:
job = self._jobs.get(export_id)
if not job:
+ logger.info("chat export cancel requested for missing job export_id=%s", export_id)
return False
job.cancel_requested = True
+ logger.info(
+ "chat export cancel requested %s",
+ _safe_json_dumps(
+ {
+ "exportId": job.export_id,
+ "status": job.status,
+ "createdAt": job.created_at,
+ "startedAt": job.started_at,
+ "progress": {
+ "conversationsDone": job.progress.conversations_done,
+ "conversationsTotal": job.progress.conversations_total,
+ "currentConversationIndex": job.progress.current_conversation_index,
+ "messagesExported": job.progress.messages_exported,
+ "mediaCopied": job.progress.media_copied,
+ "mediaMissing": job.progress.media_missing,
+ },
+ }
+ ),
+ )
if job.status in {"queued"}:
job.status = "cancelled"
job.finished_at = time.time()
+ logger.info("chat export queued job cancelled export_id=%s", job.export_id)
return True
def create_job(
@@ -2534,6 +2658,17 @@ class ChatExportManager:
with self._lock:
self._jobs[export_id] = job
+ logger.info(
+ "chat export job created %s",
+ _safe_json_dumps(
+ {
+ "exportId": job.export_id,
+ "account": account_dir.name,
+ "options": job.options,
+ }
+ ),
+ )
+
t = threading.Thread(
target=self._run_job_safe,
args=(job, account_dir),
@@ -2565,6 +2700,34 @@ class ChatExportManager:
job.started_at = time.time()
job.error = ""
+ _trace_id, trace = create_perf_trace(
+ logger,
+ "chat_export_job",
+ exportId=job.export_id,
+ account=account_dir.name,
+ )
+ _safe_trace(trace, "job_started", thread=threading.current_thread().name)
+ realtime_pause_reason = f"chat_export:{job.export_id}"
+ realtime_paused = False
+ try:
+ pause_depth = CHAT_REALTIME_AUTOSYNC.pause_account(account_dir.name, reason=realtime_pause_reason)
+ realtime_paused = bool(pause_depth > 0)
+ _safe_trace(
+ trace,
+ "realtime_autosync_paused",
+ account=account_dir.name,
+ reason=realtime_pause_reason,
+ depth=int(pause_depth),
+ )
+ except Exception:
+ logger.exception("failed to pause realtime autosync account=%s export_id=%s", account_dir.name, job.export_id)
+ _safe_trace(
+ trace,
+ "realtime_autosync_pause_failed",
+ account=account_dir.name,
+ reason=realtime_pause_reason,
+ )
+
opts = dict(job.options or {})
scope: ExportScope = str(opts.get("scope") or "selected") # type: ignore[assignment]
export_format_raw = str(opts.get("format") or "json").strip() or "json"
@@ -2612,6 +2775,23 @@ class ChatExportManager:
local_types = None
estimate_local_types = None
+ _safe_trace(
+ trace,
+ "options_resolved",
+ scope=scope,
+ format=export_format,
+ includeMedia=include_media,
+ mediaKinds=media_kinds,
+ messageTypes=sorted(want_types) if want_types else None,
+ startTime=st,
+ endTime=et,
+ htmlPageSize=html_page_size,
+ downloadRemoteMedia=download_remote_media,
+ privacyMode=privacy_mode,
+ )
+ _raise_if_job_cancelled(job, "options_resolved", trace)
+
+ phase_started = time.perf_counter()
target_usernames = _resolve_export_targets(
account_dir=account_dir,
scope=scope,
@@ -2619,11 +2799,20 @@ class ChatExportManager:
include_hidden=include_hidden,
include_official=include_official,
)
+ _safe_trace(
+ trace,
+ "targets_resolved",
+ durationMs=_elapsed_ms(phase_started),
+ conversationCount=len(target_usernames),
+ scope=scope,
+ )
if not target_usernames:
raise ValueError("No target conversations to export.")
+ phase_started = time.perf_counter()
exports_root = _resolve_export_output_dir(account_dir, opts.get("outputDir"))
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
+ _safe_trace(trace, "output_dir_resolved", durationMs=_elapsed_ms(phase_started), outputDir=str(exports_root))
base_name = str(opts.get("fileName") or "").strip()
if not base_name:
@@ -2638,12 +2827,14 @@ class ChatExportManager:
final_zip = (exports_root / base_name).resolve()
tmp_zip = (exports_root / f".{base_name}.{job.export_id}.part").resolve()
+ _safe_trace(trace, "zip_paths_prepared", finalZip=str(final_zip), tmpZip=str(tmp_zip))
contact_db_path = account_dir / "contact.db"
message_resource_db_path = account_dir / "message_resource.db"
media_db_path = account_dir / "media_0.db"
head_image_db_path = account_dir / "head_image.db"
+ phase_started = time.perf_counter()
resource_conn: Optional[sqlite3.Connection] = None
try:
if message_resource_db_path.exists():
@@ -2670,6 +2861,16 @@ class ChatExportManager:
pass
head_image_conn = None
+ _safe_trace(
+ trace,
+ "db_connections_opened",
+ durationMs=_elapsed_ms(phase_started),
+ hasResourceDb=resource_conn is not None,
+ hasHeadImageDb=head_image_conn is not None,
+ hasMediaDb=media_db_path.exists(),
+ )
+ _raise_if_job_cancelled(job, "db_connections_opened", trace)
+
contact_cache: dict[str, str] = {}
contact_row_cache: dict[str, sqlite3.Row] = {}
@@ -2686,10 +2887,40 @@ class ChatExportManager:
contact_cache[u] = name
return name
+ phase_started = time.perf_counter()
conv_rows = _load_contact_rows(contact_db_path, target_usernames)
for k, v in conv_rows.items():
contact_row_cache[k] = v
contact_cache[k] = _pick_display_name(v, k)
+ _safe_trace(
+ trace,
+ "contacts_preloaded",
+ durationMs=_elapsed_ms(phase_started),
+ requested=len(target_usernames),
+ loaded=len(conv_rows),
+ )
+ _raise_if_job_cancelled(job, "contacts_preloaded", trace)
+
+ media_index: Optional[MediaPathIndex] = None
+ if include_media and any(kind in {"image", "emoji", "video", "video_thumb", "file"} for kind in media_kinds):
+ phase_started = time.perf_counter()
+ media_index = MediaPathIndex.build(
+ account_dir=account_dir,
+ usernames=target_usernames,
+ media_kinds=media_kinds,
+ )
+ _safe_trace(
+ trace,
+ "media_index_built",
+ durationMs=_elapsed_ms(phase_started),
+ usernames=len(target_usernames),
+ mediaKinds=media_kinds,
+ md5Keys=int(media_index.stats.get("md5Keys") or 0),
+ fileIdKeys=int(media_index.stats.get("fileIdKeys") or 0),
+ scannedFiles=int(media_index.stats.get("scannedFiles") or 0),
+ hardlinkRows=int(media_index.stats.get("hardlinkRows") or 0),
+ )
+ _raise_if_job_cancelled(job, "media_index_built", trace)
media_written: dict[str, str] = {}
avatar_written: dict[str, str] = {}
@@ -2708,6 +2939,7 @@ class ChatExportManager:
job.progress.messages_exported = 0
job.progress.media_copied = 0
job.progress.media_missing = 0
+ _safe_trace(trace, "progress_initialized", conversationCount=len(target_usernames))
try:
if tmp_zip.exists():
@@ -2716,13 +2948,18 @@ class ChatExportManager:
except Exception:
pass
+ phase_started = time.perf_counter()
+ _safe_trace(trace, "zip_open_start", tmpZip=str(tmp_zip))
with zipfile.ZipFile(tmp_zip, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
+ _safe_trace(trace, "zip_opened", durationMs=_elapsed_ms(phase_started))
html_index_items: list[dict[str, Any]] = []
self_avatar_path = ""
session_items: list[dict[str, Any]] = []
remote_written: dict[str, str] = {}
remote_download_enabled = bool(download_remote_media) and (export_format == "html") and include_media and (not privacy_mode)
if export_format == "html":
+ phase_started = time.perf_counter()
+ _safe_trace(trace, "html_assets_start")
ui_public_dir = _resolve_ui_public_dir()
css_payload = _load_ui_css_bundle(ui_public_dir=ui_public_dir, report=report)
zf.writestr("assets/wechat-chat-export.css", css_payload)
@@ -2767,11 +3004,20 @@ class ChatExportManager:
dest_prefix="assets/images/wechat",
written=static_written,
)
+ _safe_trace(
+ trace,
+ "html_assets_done",
+ durationMs=_elapsed_ms(phase_started),
+ uiPublicDir=str(ui_public_dir) if ui_public_dir is not None else "",
+ staticFiles=len(static_written),
+ )
+ _raise_if_job_cancelled(job, "html_assets_done", trace)
preview_by_username: dict[str, str] = {}
last_ts_by_username: dict[str, int] = {}
if not privacy_mode:
+ phase_started = time.perf_counter()
self_avatar_path = _materialize_avatar(
zf=zf,
head_image_conn=head_image_conn,
@@ -2821,8 +3067,19 @@ class ChatExportManager:
last_ts_by_username = {}
finally:
sconn.close()
+ _safe_trace(
+ trace,
+ "html_session_metadata_loaded",
+ durationMs=_elapsed_ms(phase_started),
+ previews=len(preview_by_username),
+ lastTimestamps=len(last_ts_by_username),
+ hasSelfAvatar=bool(self_avatar_path),
+ )
+ _raise_if_job_cancelled(job, "html_session_metadata_loaded", trace)
+ phase_started = time.perf_counter()
for idx, conv_username in enumerate(target_usernames, start=1):
+ _raise_if_job_cancelled(job, "html_session_index", trace, index=idx)
conv_row = contact_row_cache.get(conv_username)
conv_name = _pick_display_name(conv_row, conv_username)
conv_is_group = bool(conv_username.endswith("@chatroom"))
@@ -2848,11 +3105,17 @@ class ChatExportManager:
"previewText": ("" if privacy_mode else str(preview_by_username.get(conv_username) or "")),
}
)
+ _safe_trace(
+ trace,
+ "html_session_index_built",
+ durationMs=_elapsed_ms(phase_started),
+ sessionItems=len(session_items),
+ )
for idx, conv_username in enumerate(target_usernames, start=1):
- if self._should_cancel(job):
- raise _JobCancelled()
+ _raise_if_job_cancelled(job, "conversation_loop_start", trace, index=idx)
+ conv_started = time.perf_counter()
conv_row = contact_row_cache.get(conv_username)
conv_name = _pick_display_name(conv_row, conv_username)
conv_is_group = bool(conv_username.endswith("@chatroom"))
@@ -2867,6 +3130,7 @@ class ChatExportManager:
job.progress.current_conversation_messages_total = 0
try:
+ phase_started = time.perf_counter()
estimated_total = _estimate_conversation_message_count(
account_dir=account_dir,
conv_username=conv_username,
@@ -2876,26 +3140,57 @@ class ChatExportManager:
)
except Exception:
estimated_total = 0
+ _safe_trace(
+ trace,
+ "conversation_estimated",
+ index=idx,
+ conversation=conv_username,
+ displayName=conv_name,
+ durationMs=_elapsed_ms(phase_started),
+ estimatedTotal=estimated_total,
+ )
+ _raise_if_job_cancelled(job, "conversation_estimated", trace, index=idx, conversation=conv_username)
with self._lock:
job.progress.current_conversation_messages_total = int(estimated_total)
chat_id = None
try:
+ phase_started = time.perf_counter()
if resource_conn is not None:
chat_id = _resource_lookup_chat_id(resource_conn, conv_username)
except Exception:
chat_id = None
+ _safe_trace(
+ trace,
+ "conversation_resource_lookup",
+ index=idx,
+ conversation=conv_username,
+ durationMs=_elapsed_ms(phase_started),
+ chatId=chat_id,
+ )
+ _raise_if_job_cancelled(job, "conversation_resource_lookup", trace, index=idx, conversation=conv_username)
conv_avatar_path = ""
if not privacy_mode:
+ phase_started = time.perf_counter()
conv_avatar_path = _materialize_avatar(
zf=zf,
head_image_conn=head_image_conn,
username=conv_username,
avatar_written=avatar_written,
)
+ _safe_trace(
+ trace,
+ "conversation_avatar_materialized",
+ index=idx,
+ conversation=conv_username,
+ durationMs=_elapsed_ms(phase_started),
+ hasAvatar=bool(conv_avatar_path),
+ )
+ _raise_if_job_cancelled(job, "conversation_avatar_materialized", trace, index=idx, conversation=conv_username)
+ phase_started = time.perf_counter()
if export_format == "txt":
exported_count = _write_conversation_txt(
zf=zf,
@@ -2921,6 +3216,7 @@ class ChatExportManager:
report=report,
allow_process_key_extract=allow_process_key_extract,
media_db_path=media_db_path,
+ media_index=media_index,
job=job,
lock=self._lock,
)
@@ -2954,6 +3250,7 @@ class ChatExportManager:
report=report,
allow_process_key_extract=allow_process_key_extract,
media_db_path=media_db_path,
+ media_index=media_index,
job=job,
lock=self._lock,
)
@@ -2982,10 +3279,26 @@ class ChatExportManager:
report=report,
allow_process_key_extract=allow_process_key_extract,
media_db_path=media_db_path,
+ media_index=media_index,
job=job,
lock=self._lock,
)
+ _safe_trace(
+ trace,
+ "conversation_writer_done",
+ index=idx,
+ conversation=conv_username,
+ displayName=conv_name,
+ format=export_format,
+ durationMs=_elapsed_ms(phase_started),
+ exportedCount=exported_count,
+ mediaCopied=job.progress.media_copied,
+ mediaMissing=job.progress.media_missing,
+ )
+ _raise_if_job_cancelled(job, "conversation_writer_done", trace, index=idx, conversation=conv_username)
+
+ phase_started = time.perf_counter()
meta = {
"schemaVersion": 1,
"username": "" if privacy_mode else conv_username,
@@ -3003,8 +3316,19 @@ class ChatExportManager:
job.progress.current_conversation_messages_exported = int(exported_count)
job.progress.current_conversation_messages_total = int(exported_count)
job.progress.conversations_done += 1
+ _safe_trace(
+ trace,
+ "conversation_done",
+ index=idx,
+ conversation=conv_username,
+ durationMs=_elapsed_ms(conv_started),
+ metaWriteMs=_elapsed_ms(phase_started),
+ conversationsDone=job.progress.conversations_done,
+ exportedCount=exported_count,
+ )
if export_format == "html":
+ phase_started = time.perf_counter()
def esc_text(v: Any) -> str:
return html.escape(str(v or ""), quote=False)
@@ -3069,7 +3393,15 @@ class ChatExportManager:
parts.append("