Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion frontend/composables/chat/useChatExport.js
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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 } }
Expand Down
27 changes: 27 additions & 0 deletions frontend/tests/chat-export-order.test.mjs
Original file line number Diff line number Diff line change
@@ -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)
}
})
18 changes: 18 additions & 0 deletions frontend/utils/chatExportOrder.js
Original file line number Diff line number Diff line change
@@ -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
}
}
25 changes: 11 additions & 14 deletions src/wechat_decrypt_tool/chat_export_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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]] = []
Expand All @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 52 additions & 2 deletions src/wechat_decrypt_tool/chat_incremental_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
}
Expand Down Expand Up @@ -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 = {}
Expand All @@ -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",
Expand Down
18 changes: 16 additions & 2 deletions tests/test_chat_export_index_updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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()
Loading
Loading