From e04a371a38046f6e868db0910b28cc29dd9f5a5f Mon Sep 17 00:00:00 2001 From: xiaosheng <73678111+xiaoshengbao@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:09:10 +0800 Subject: [PATCH] fix(export): preserve contact order in incremental HTML archives --- frontend/composables/chat/useChatExport.js | 14 ++- frontend/tests/chat-export-order.test.mjs | 27 ++++ frontend/utils/chatExportOrder.js | 18 +++ .../chat_export_service.py | 25 ++-- .../chat_incremental_export.py | 54 +++++++- tests/test_chat_export_index_updates.py | 18 ++- tests/test_chat_incremental_export.py | 119 ++++++++++++++++++ 7 files changed, 256 insertions(+), 19 deletions(-) create mode 100644 frontend/tests/chat-export-order.test.mjs create mode 100644 frontend/utils/chatExportOrder.js diff --git a/frontend/composables/chat/useChatExport.js b/frontend/composables/chat/useChatExport.js index b0461c41..8ca9d1ab 100644 --- a/frontend/composables/chat/useChatExport.js +++ b/frontend/composables/chat/useChatExport.js @@ -1,6 +1,7 @@ import { computed, ref, watch } from 'vue' import { reportServerErrorFromResponse } from '~/lib/server-error-logging' import { toUnixSeconds } from '~/lib/chat/formatters' +import { restoreLegacyChatExportOrder } from '~/utils/chatExportOrder' export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selectedContact, privacyMode }) => { const exportModalOpen = ref(false) @@ -440,7 +441,18 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte try { const handle = await root.getFileHandle(CHAT_EXPORT_BASELINE_FILE) const file = await handle.getFile() - return { found: true, baseline: JSON.parse(await file.text()) } + let baseline = JSON.parse(await file.text()) + if (baseline && !Array.isArray(baseline.conversationOrder)) { + try { + const assets = await root.getDirectoryHandle('assets', { create: false }) + const catalogHandle = await assets.getFileHandle('chat-sessions.js') + const catalogFile = await catalogHandle.getFile() + baseline = restoreLegacyChatExportOrder(baseline, await catalogFile.text()) + } catch { + // A missing legacy catalog must not invalidate the baseline. + } + } + return { found: true, baseline } } catch (error) { if (error?.name === 'NotFoundError') return { found: false, baseline: null } return { found: true, baseline: { invalid: true } } diff --git a/frontend/tests/chat-export-order.test.mjs b/frontend/tests/chat-export-order.test.mjs new file mode 100644 index 00000000..66875063 --- /dev/null +++ b/frontend/tests/chat-export-order.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { restoreLegacyChatExportOrder } from '../utils/chatExportOrder.js' + +test('browser folder migration recovers catalog order from hash-sorted baselines', () => { + const baseline = { conversations: { + a: { directory: 'conversations/second' }, + z: { directory: 'conversations/first' }, + } } + const catalog = 'window.__WCE_FOLDER_SESSIONS__=' + JSON.stringify({ items: [ + { convDir: 'conversations/first' }, { convDir: 'conversations/unknown' }, + { convDir: 'conversations/second' }, { convDir: 'conversations/first' }, + ] }) + ';\n' + const migrated = restoreLegacyChatExportOrder(baseline, catalog) + assert.deepEqual(migrated.legacyConversationOrder, ['z', 'a']) + assert.equal(baseline.conversationOrder, undefined) + const persisted = { ...baseline, conversationOrder: ['z', 'a'] } + assert.equal(restoreLegacyChatExportOrder(persisted, catalog), persisted) +}) + +test('missing or malformed legacy catalogs preserve the baseline', () => { + const baseline = { conversations: { a: { directory: 'conversations/a' } } } + for (const text of ['', 'window.__WCE_FOLDER_SESSIONS__=invalid;', + 'window.__WCE_FOLDER_SESSIONS__={};', 'throw new Error("do not execute")']) { + assert.equal(restoreLegacyChatExportOrder(baseline, text), baseline) + } +}) diff --git a/frontend/utils/chatExportOrder.js b/frontend/utils/chatExportOrder.js new file mode 100644 index 00000000..5ea6ccd8 --- /dev/null +++ b/frontend/utils/chatExportOrder.js @@ -0,0 +1,18 @@ +// Older baselines sorted conversation hashes and did not record presentation order. +// Recover the visible order from the shared catalog without executing its JavaScript. +export const restoreLegacyChatExportOrder = (baseline, catalogText) => { + if (!baseline || Array.isArray(baseline.conversationOrder)) return baseline + try { + const prefix = 'window.__WCE_FOLDER_SESSIONS__=' + const text = String(catalogText || '').trim() + if (!text.startsWith(prefix)) return baseline + const catalog = JSON.parse(text.slice(prefix.length).replace(/;\s*$/, '')) + const byDirectory = new Map(Object.entries(baseline.conversations || {}) + .map(([key, value]) => [value?.directory, key])) + const order = catalog.items.filter(item => byDirectory.has(item?.convDir)) + .map(item => byDirectory.get(item.convDir)) + return order.length ? { ...baseline, legacyConversationOrder: [...new Set(order)] } : baseline + } catch { + return baseline + } +} diff --git a/src/wechat_decrypt_tool/chat_export_service.py b/src/wechat_decrypt_tool/chat_export_service.py index 810ab22a..4bbbbfc8 100644 --- a/src/wechat_decrypt_tool/chat_export_service.py +++ b/src/wechat_decrypt_tool/chat_export_service.py @@ -70,6 +70,7 @@ materialize_folder_archive, missing_conversation_keys, normalize_pending_media, + ordered_conversation_keys, prepare_folder_context, ) from .logging_config import get_logger @@ -1224,16 +1225,9 @@ def _replace_ordered_export_index_item( index: dict[str, dict[str, Any]], item: dict[str, Any], ) -> None: - """Replace an index item while retaining the old remove-then-append order. - - ``dict`` preserves insertion order. Removing the existing conversation - before assigning it again is therefore equivalent to the previous - ``[... if convDir != current]`` plus ``append`` implementation, without - rescanning the complete index for every conversation. - """ + """Update existing entries in place and append newly exported conversations.""" conv_dir = str(item.get("convDir") or "") - index.pop(conv_dir, None) index[conv_dir] = item @@ -2491,10 +2485,8 @@ def ensure_media_index() -> Optional[MediaPathIndex]: ) _safe_trace(trace, "zip_opened", durationMs=_elapsed_ms(phase_started)) # Keep the indexes keyed by conversation directory while the - # export is running. Folder exports replace existing entries - # and intentionally move them to the end; dict pop+assign - # preserves that order in O(1), unlike filtering a growing - # list for every conversation. + # export is running. Assignment preserves existing positions + # in O(1), and new conversations are appended at the end. html_index_by_conv_dir: dict[str, dict[str, Any]] = {} excel_index_by_conv_dir: dict[str, dict[str, Any]] = {} html_index_items: list[dict[str, Any]] = [] @@ -2508,7 +2500,8 @@ def ensure_media_index() -> Optional[MediaPathIndex]: if isinstance(folder_context.old_state.get("conversations"), dict) else {} ) - for old_value in old_conversations.values(): + for old_key in ordered_conversation_keys(folder_context.old_state): + old_value = old_conversations[old_key] if not isinstance(old_value, dict): continue old_session = old_value.get("session") @@ -2751,7 +2744,11 @@ def ensure_media_index() -> Optional[MediaPathIndex]: session_value = { "username": "" if privacy_mode else conv_username, - "displayName": (f"会话 {idx:04d}" if privacy_mode else conv_name), + "displayName": ( + str((session_items_by_conv_dir.get(conv_dir) or {}).get("displayName") + or f"会话 {len(session_items_by_conv_dir) + 1:04d}") + if privacy_mode else conv_name + ), "isGroup": bool(conv_is_group), "convDir": conv_dir, "avatarPath": "" if privacy_mode else conv_avatar_path, diff --git a/src/wechat_decrypt_tool/chat_incremental_export.py b/src/wechat_decrypt_tool/chat_incremental_export.py index 9a433422..792c85b6 100644 --- a/src/wechat_decrypt_tool/chat_incremental_export.py +++ b/src/wechat_decrypt_tool/chat_incremental_export.py @@ -291,6 +291,47 @@ def _read_json_file(path: Path) -> dict[str, Any]: return value +def ordered_conversation_keys(state: dict[str, Any]) -> list[str]: + """Restore presentation order independently of JSON object key sorting.""" + + conversations = state.get("conversations") if isinstance(state.get("conversations"), dict) else {} + saved_order = state.get("conversationOrder") + if not isinstance(saved_order, list): + saved_order = state.get("legacyConversationOrder") + result: dict[str, None] = {} + for key in saved_order if isinstance(saved_order, list) else []: + if isinstance(key, str) and isinstance(conversations.get(key), dict): + result[key] = None + for key, value in conversations.items(): + if isinstance(value, dict): + result[str(key)] = None + return list(result) + + +def _restore_legacy_conversation_order(state: dict[str, Any], target_root: Path) -> None: + """Use the existing HTML catalog when an older baseline lacks order.""" + + if isinstance(state.get("conversationOrder"), list): + return + try: + text = (target_root / "assets/chat-sessions.js").read_text(encoding="utf-8").strip() + prefix = "window.__WCE_FOLDER_SESSIONS__=" + if not text.startswith(prefix): + return + catalog = json.loads(text[len(prefix):].rstrip(";\r\n")) + by_directory = { + str(value.get("directory") or ""): key + for key, value in state["conversations"].items() + } + order = [by_directory[item["convDir"]] for item in catalog["items"] + if isinstance(item, dict) and item.get("convDir") in by_directory] + if order: + state["legacyConversationOrder"] = order + except (OSError, ValueError, KeyError, TypeError): + # Missing/old catalogs must not prevent an otherwise valid export. + return + + def _baseline_is_owned(value: dict[str, Any]) -> bool: return ( int(value.get("schemaVersion") or 0) == SCHEMA_VERSION @@ -359,6 +400,8 @@ def prepare_folder_context( raise ChatIncrementalError("incremental_baseline_invalid", "增量基线损坏或不属于聊天导出,请选择新目录。") if owned: _validate_baseline_paths(old_state) + if desktop_output and target_root is not None: + _restore_legacy_conversation_order(old_state, target_root) desired_hash = config_fingerprint(config) if owned: @@ -827,9 +870,15 @@ def materialize_folder_archive( else generated_at ), "conversations": persisted_conversations, + "conversationOrder": ordered_conversation_keys({ + "conversations": persisted_conversations, + "conversationOrder": ordered_conversation_keys(context.old_state), + }), "files": current_files, } state_bytes = json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8") + # Persist an order migration even when no message/media files changed. + state_unchanged = quiet_noop and context.old_state.get("conversationOrder") == state["conversationOrder"] state_path = _write_staged_file(staging_dir, STATE_FILE_NAME, state_bytes) state_file_id = uuid.uuid4().hex job.staged_files[state_file_id] = state_path @@ -863,7 +912,7 @@ def materialize_folder_archive( "path": STATE_FILE_NAME, "size": len(state_bytes), "sha256": hashlib.sha256(state_bytes).hexdigest(), - "unchanged": quiet_noop, + "unchanged": state_unchanged, }, "stats": dict(job.incremental), } @@ -892,7 +941,7 @@ def materialize_folder_archive( destination.unlink(missing_ok=True) state_destination = target_root / STATE_FILE_NAME - if not quiet_noop or not state_destination.is_file(): + if not state_unchanged or not state_destination.is_file(): os.replace(state_path, state_destination) job.folder_path = target_root job.staged_files = {} @@ -915,6 +964,7 @@ def materialize_folder_archive( "materialize_folder_archive", "missing_conversation_keys", "normalize_pending_media", + "ordered_conversation_keys", "normalize_relative_path", "prepare_folder_context", "privacy_account_token", diff --git a/tests/test_chat_export_index_updates.py b/tests/test_chat_export_index_updates.py index 4cc2cf89..80a33d46 100644 --- a/tests/test_chat_export_index_updates.py +++ b/tests/test_chat_export_index_updates.py @@ -2,16 +2,17 @@ from wechat_decrypt_tool.chat_export_service import _replace_ordered_export_index_item +from wechat_decrypt_tool.chat_incremental_export import ordered_conversation_keys class TestChatExportIndexUpdates(unittest.TestCase): - def test_replace_moves_existing_conversation_to_end(self): + def test_replace_preserves_existing_conversation_position(self): index = {} _replace_ordered_export_index_item(index, {"convDir": "conversations/a", "value": "old-a"}) _replace_ordered_export_index_item(index, {"convDir": "conversations/b", "value": "b"}) _replace_ordered_export_index_item(index, {"convDir": "conversations/a", "value": "new-a"}) - self.assertEqual(list(index), ["conversations/b", "conversations/a"]) + self.assertEqual(list(index), ["conversations/a", "conversations/b"]) self.assertEqual(index["conversations/a"]["value"], "new-a") def test_new_conversations_are_added_without_reordering_existing_items(self): @@ -21,6 +22,19 @@ def test_new_conversations_are_added_without_reordering_existing_items(self): self.assertEqual(list(index), ["conversations/a", "conversations/b", "conversations/c"]) + def test_saved_order_deduplicates_known_keys_and_appends_unlisted_conversations(self): + state = { + "conversationOrder": ["c", "missing", "c", None, "a"], + "conversations": {"a": {}, "b": {}, "c": {}}, + } + self.assertEqual(ordered_conversation_keys(state), ["c", "a", "b"]) + + def test_legacy_order_is_used_only_without_a_persisted_order(self): + state = {"legacyConversationOrder": ["b", "a"], "conversations": {"a": {}, "b": {}}} + self.assertEqual(ordered_conversation_keys(state), ["b", "a"]) + state["conversationOrder"] = ["a", "b"] + self.assertEqual(ordered_conversation_keys(state), ["a", "b"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_chat_incremental_export.py b/tests/test_chat_incremental_export.py index 6f882507..2a5f56b3 100644 --- a/tests/test_chat_incremental_export.py +++ b/tests/test_chat_incremental_export.py @@ -2,6 +2,7 @@ import io import json import os +import re import sqlite3 import threading import time @@ -588,6 +589,124 @@ def test_html_folder_uses_one_shared_session_catalog_across_incremental_pages(se else: os.environ["WECHAT_TOOL_DATA_DIR"] = previous + def test_html_folder_preserves_contact_order_across_incremental_exports(self): + for privacy_mode in (False, True): + with self.subTest(privacy_mode=privacy_mode), TemporaryDirectory() as td: + root = Path(td) + account = "wxid_order_account" + usernames = ["wxid_first", "wxid_second", "wxid_third"] + account_dir = self._prepare_account(root, account=account, username=usernames[0]) + for index, username in enumerate(usernames[1:], start=20): + self._add_conversation(account_dir, username=username, display_name=username, local_id=index) + with mock.patch.dict(os.environ, {"WECHAT_TOOL_DATA_DIR": str(root)}): + service = self._reload_export_modules() + + def export(selected): + job = self._create_folder_job( + service.CHAT_EXPORT_MANAGER, account=account, usernames=selected, + output_dir=root / "exports", export_format="html", privacy_mode=privacy_mode, + ) + self.assertEqual(job.status, "done", msg=job.error) + return job + + def catalog(folder): + return json.loads((folder / "assets/chat-sessions.js").read_text(encoding="utf-8") + .removeprefix("window.__WCE_FOLDER_SESSIONS__=").rstrip(";\r\n"))["items"] + + first = export(usernames) + folder = first.folder_path + expected = [item["convDir"] for item in catalog(folder)] + first_names = [item["displayName"] for item in catalog(folder)] + state_path = folder / ".wechat-chat-export.json" + baseline = json.loads(state_path.read_text(encoding="utf-8")) + # Make the baseline dictionary order disagree with the visible catalog. + baseline["conversations"] = dict(reversed(list(baseline["conversations"].items()))) + state_path.write_text(json.dumps(baseline), encoding="utf-8") + + export(list(reversed(usernames))) + self.assertEqual([item["convDir"] for item in catalog(folder)], expected) + self.assertEqual([item["displayName"] for item in catalog(folder)], first_names) + export([usernames[0]]) + self.assertEqual([item["convDir"] for item in catalog(folder)], expected) + + # Appending real messages also updates the existing entry in place. + with sqlite3.connect(str(account_dir / "message_0.db")) as connection: + connection.execute( + f"INSERT INTO {self._message_table(usernames[0])} VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (8, 1008, 1, 8, 2, 1735689800, "新增消息不改变联系人位置", None), + ) + appended = export([usernames[0]]) + self.assertEqual(appended.incremental.get("messagesAdded"), 1) + self.assertEqual([item["convDir"] for item in catalog(folder)], expected) + + # Migrate old baselines which predate the explicit order field. + baseline = json.loads(state_path.read_text(encoding="utf-8")) + baseline.pop("conversationOrder", None) + baseline["conversations"] = dict(reversed(list(baseline["conversations"].items()))) + state_path.write_text(json.dumps(baseline), encoding="utf-8") + migrated = export([usernames[1]]) + self.assertEqual([item["convDir"] for item in catalog(folder)], expected) + self.assertIn("conversationOrder", json.loads(state_path.read_text(encoding="utf-8"))) + self.assertEqual(migrated.incremental.get("filesChanged"), 0) + + new_username = "wxid_new" + self._add_conversation(account_dir, username=new_username, display_name="新增联系人", local_id=30) + export([new_username, usernames[0]]) + items = catalog(folder) + expected.append(items[-1]["convDir"]) + self.assertEqual([item["convDir"] for item in items], expected) + self.assertEqual(len(set(expected)), 4) + index_html = (folder / "index.html").read_text(encoding="utf-8") + self.assertEqual(re.findall(r'class="wce-index-item" href="(.*?)/messages.html"', index_html), expected) + baseline = json.loads(state_path.read_text(encoding="utf-8")) + self.assertEqual([baseline["conversations"][key]["directory"] + for key in baseline["conversationOrder"]], expected) + no_change = export([usernames[0], new_username]) + self.assertEqual(no_change.incremental.get("filesChanged"), 0) + self.assertEqual([item["convDir"] for item in catalog(folder)], expected) + + def test_browser_html_folder_preserves_saved_order(self): + with TemporaryDirectory() as td: + root = Path(td) + account = "wxid_browser_order" + usernames = ["wxid_first", "wxid_second"] + account_dir = self._prepare_account(root, account=account, username=usernames[0]) + self._add_conversation(account_dir, username=usernames[1], display_name="第二个联系人") + with mock.patch.dict(os.environ, {"WECHAT_TOOL_DATA_DIR": str(root)}): + service = self._reload_export_modules() + browser_files = {} + + def export(selected, baseline=None): + job = self._create_folder_job( + service.CHAT_EXPORT_MANAGER, account=account, usernames=selected, + output_dir=None, export_format="html", baseline=baseline, + ) + self.assertEqual(job.status, "done", msg=job.error) + if baseline and "conversationOrder" not in baseline: + self.assertFalse(job.change_manifest["state"]["unchanged"]) + state = json.loads((job.staging_dir / ".wechat-chat-export.json").read_text(encoding="utf-8")) + for entry in job.change_manifest["files"]: + if entry["path"] == "assets/chat-sessions.js": + browser_files[entry["path"]] = job.staged_files[entry["fileId"]].read_text(encoding="utf-8") + text = browser_files["assets/chat-sessions.js"] + items = json.loads(text.removeprefix("window.__WCE_FOLDER_SESSIONS__=").rstrip(";\r\n"))["items"] + service.CHAT_EXPORT_MANAGER.commit_staged_files(job.export_id) + return state, [item["convDir"] for item in items] + + baseline, expected = export(usernames) + # The transported JSON still sorts hashes; the explicit list controls order. + baseline = json.loads(json.dumps(baseline, sort_keys=True)) + baseline, actual = export(list(reversed(usernames)), baseline) + self.assertEqual(actual, expected) + _, actual = export([usernames[0]], baseline) + self.assertEqual(actual, expected) + legacy_order = baseline.pop("conversationOrder") + baseline["legacyConversationOrder"] = legacy_order + migrated, actual = export([usernames[0]], baseline) + self.assertEqual(actual, expected) + self.assertEqual(migrated["conversationOrder"], legacy_order) + self.assertNotIn("legacyConversationOrder", migrated) + def test_html_folder_shared_session_catalog_respects_privacy_mode(self): with TemporaryDirectory() as td: root = Path(td)