Merge branch 'nanin/develop' into develop

This commit is contained in:
Wxw-Gu
2026-08-05 18:44:25 +08:00
37 changed files with 5185 additions and 492 deletions
+1
View File
@@ -34,6 +34,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
if: runner.os == 'macOS'
run: pnpm exec playwright install chromium
- name: Type check
+3
View File
@@ -1,6 +1,7 @@
{
"name": "wechatexplorer",
"version": "2.1.8",
"packageManager": "pnpm@7.33.7",
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
"keywords": [
"wechat",
@@ -59,6 +60,7 @@
"@electron-toolkit/utils": "^4.0.0",
"@koromix/koffi-win32-x64": "3.1.0",
"@tanstack/react-virtual": "^3.14.6",
"archiver": "^8.0.0",
"cross-env": "^10.1.0",
"electron-updater": "^6.6.2",
"ffmpeg-static": "5.3.0",
@@ -81,6 +83,7 @@
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/archiver": "^8.0.0",
"@types/fs-extra": "^11.0.4",
"@types/node": "^22.19.1",
"@types/react": "^19.2.7",
+311 -6
View File
@@ -19,12 +19,14 @@ specifiers:
'@testing-library/jest-dom': ^7.0.0
'@testing-library/react': ^16.3.2
'@testing-library/user-event': ^14.6.1
'@types/archiver': ^8.0.0
'@types/fs-extra': ^11.0.4
'@types/node': ^22.19.1
'@types/react': ^19.2.7
'@types/react-dom': ^19.2.3
'@vitejs/plugin-react': ^5.1.1
'@vitest/coverage-v8': ^4.1.10
archiver: ^8.0.0
cross-env: ^10.1.0
electron: ^43.0.0
electron-builder: ^26.0.12
@@ -57,6 +59,7 @@ dependencies:
'@electron-toolkit/utils': 4.0.0_electron@43.1.0
'@koromix/koffi-win32-x64': 3.1.0
'@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq
archiver: 8.0.0
cross-env: 10.1.0
electron-updater: 6.8.9
ffmpeg-static: 5.3.0
@@ -79,6 +82,7 @@ devDependencies:
'@testing-library/jest-dom': 7.0.0_rwcxf7kajngkz26znmtz643644
'@testing-library/react': 16.3.2_ifbmwgqdqpqwyvhobr7vc7fmyu
'@testing-library/user-event': 14.6.1_rwcxf7kajngkz26znmtz643644
'@types/archiver': 8.0.0
'@types/fs-extra': 11.0.4
'@types/node': 22.19.2
'@types/react': 19.2.7
@@ -1515,6 +1519,13 @@ packages:
engines: {node: '>= 10'}
dev: true
/@types/archiver/8.0.0:
resolution: {integrity: sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A==}
dependencies:
'@types/node': 22.19.2
'@types/readdir-glob': 1.1.5
dev: true
/@types/aria-query/5.0.4:
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
dev: true
@@ -1652,6 +1663,12 @@ packages:
csstype: 3.2.3
dev: true
/@types/readdir-glob/1.1.5:
resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==}
dependencies:
'@types/node': 22.19.2
dev: true
/@types/responselike/1.0.3:
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
dependencies:
@@ -1913,6 +1930,13 @@ packages:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
dev: true
/abort-controller/3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
dependencies:
event-target-shim: 5.0.1
dev: false
/acorn-jsx/5.3.2_acorn@8.15.0:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -2049,6 +2073,25 @@ packages:
- supports-color
dev: true
/archiver/8.0.0:
resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==}
engines: {node: '>=18'}
dependencies:
async: 3.2.6
buffer-crc32: 1.0.0
is-stream: 4.0.1
lazystream: 1.0.1
normalize-path: 3.0.0
readable-stream: 4.7.0
readdir-glob: 3.0.0
tar-stream: 3.2.0
zip-stream: 7.0.5
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- react-native-b4a
dev: false
/argparse/2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -2178,7 +2221,6 @@ packages:
/async/3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
dev: true
/asynckit/0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -2196,13 +2238,86 @@ packages:
possible-typed-array-names: 1.1.0
dev: true
/b4a/1.8.1:
resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==}
peerDependencies:
react-native-b4a: '*'
peerDependenciesMeta:
react-native-b4a:
optional: true
dev: false
/balanced-match/1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
dev: true
/balanced-match/4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
dev: false
/bare-events/2.9.1:
resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==}
peerDependencies:
bare-abort-controller: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
dev: false
/bare-fs/4.8.0:
resolution: {integrity: sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==}
engines: {bare: '>=1.28.0'}
peerDependencies:
bare-buffer: '*'
peerDependenciesMeta:
bare-buffer:
optional: true
dependencies:
bare-events: 2.9.1
bare-path: 3.1.1
bare-stream: 2.13.3_bare-events@2.9.1
bare-url: 2.4.7
fast-fifo: 1.3.2
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
dev: false
/bare-path/3.1.1:
resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==}
dev: false
/bare-stream/2.13.3_bare-events@2.9.1:
resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==}
peerDependencies:
bare-abort-controller: '*'
bare-buffer: '*'
bare-events: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
bare-buffer:
optional: true
bare-events:
optional: true
dependencies:
b4a: 1.8.1
bare-events: 2.9.1
streamx: 2.28.0
teex: 1.0.1
transitivePeerDependencies:
- react-native-b4a
dev: false
/bare-url/2.4.7:
resolution: {integrity: sha512-o8CRCiJtib+ycO3mE4A5UChtGX4dDP2XxsWVu9P+Zc3H8tcmKwNVEDoDTXmwN+uuMhfKeT7/i7Y26xS8W7ohoA==}
dependencies:
bare-path: 3.1.1
dev: false
/base64-js/1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
dev: true
/baseline-browser-mapping/2.9.5:
resolution: {integrity: sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==}
@@ -2236,6 +2351,13 @@ packages:
balanced-match: 1.0.2
dev: true
/brace-expansion/5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
dependencies:
balanced-match: 4.0.4
dev: false
/browserslist/4.28.1:
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -2248,6 +2370,11 @@ packages:
update-browserslist-db: 1.2.2_browserslist@4.28.1
dev: true
/buffer-crc32/1.0.0:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'}
dev: false
/buffer-from/1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -2258,6 +2385,13 @@ packages:
ieee754: 1.2.1
dev: true
/buffer/6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
dev: false
/builder-util-runtime/9.3.1:
resolution: {integrity: sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==}
engines: {node: '>=12.0.0'}
@@ -2498,6 +2632,17 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/compress-commons/7.0.1:
resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==}
engines: {node: '>=18'}
dependencies:
crc-32: 1.2.2
crc32-stream: 7.0.1
is-stream: 4.0.1
normalize-path: 3.0.0
readable-stream: 4.7.0
dev: false
/concat-map/0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
dev: true
@@ -2525,8 +2670,12 @@ packages:
/core-util-is/1.0.2:
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
dev: true
optional: true
/crc-32/1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
dev: false
/crc/3.8.0:
resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==}
@@ -2535,6 +2684,14 @@ packages:
dev: true
optional: true
/crc32-stream/7.0.1:
resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==}
engines: {node: '>=18'}
dependencies:
crc-32: 1.2.2
readable-stream: 4.7.0
dev: false
/cross-env/10.1.0:
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
engines: {node: '>=20'}
@@ -3249,6 +3406,24 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/event-target-shim/5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
dev: false
/events-universal/1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
dependencies:
bare-events: 2.9.1
transitivePeerDependencies:
- bare-abort-controller
dev: false
/events/3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
dev: false
/expect-type/1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
@@ -3272,6 +3447,10 @@ packages:
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
dev: true
/fast-fifo/1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
dev: false
/fast-json-stable-stringify/2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
dev: true
@@ -3540,7 +3719,7 @@ packages:
/glob/8.1.0:
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
engines: {node: '>=12'}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
dependencies:
fs.realpath: 1.0.0
inflight: 1.0.6
@@ -3747,7 +3926,6 @@ packages:
/ieee754/1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
dev: true
/ignore/5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
@@ -3969,6 +4147,11 @@ packages:
call-bound: 1.0.4
dev: true
/is-stream/4.0.1:
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
engines: {node: '>=18'}
dev: false
/is-string/1.1.1:
resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
engines: {node: '>= 0.4'}
@@ -4018,6 +4201,10 @@ packages:
get-intrinsic: 1.3.0
dev: true
/isarray/1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
dev: false
/isarray/2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
dev: true
@@ -4209,6 +4396,13 @@ packages:
/lazy-val/1.0.5:
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
/lazystream/1.0.1:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
dependencies:
readable-stream: 2.3.8
dev: false
/levn/0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -4393,6 +4587,13 @@ packages:
'@isaacs/brace-expansion': 5.0.0
dev: true
/minimatch/10.2.6:
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
engines: {node: 18 || 20 || >=22}
dependencies:
brace-expansion: 5.0.9
dev: false
/minimatch/3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
dependencies:
@@ -4540,6 +4741,11 @@ packages:
abbrev: 1.1.1
dev: true
/normalize-path/3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
dev: false
/normalize-url/6.1.0:
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
engines: {node: '>=10'}
@@ -4836,6 +5042,15 @@ packages:
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
dev: true
/process-nextick-args/2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
dev: false
/process/0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
dev: false
/progress/2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
@@ -4916,6 +5131,18 @@ packages:
- supports-color
dev: true
/readable-stream/2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
dependencies:
core-util-is: 1.0.2
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
dev: false
/readable-stream/3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
@@ -4924,6 +5151,24 @@ packages:
string_decoder: 1.3.0
util-deprecate: 1.0.2
/readable-stream/4.7.0:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
abort-controller: 3.0.0
buffer: 6.0.3
events: 3.3.0
process: 0.11.10
string_decoder: 1.3.0
dev: false
/readdir-glob/3.0.0:
resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==}
engines: {node: '>=18'}
dependencies:
minimatch: 10.2.6
dev: false
/readdirp/5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
@@ -5068,6 +5313,10 @@ packages:
isarray: 2.0.5
dev: true
/safe-buffer/5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
dev: false
/safe-buffer/5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@@ -5376,6 +5625,17 @@ packages:
internal-slot: 1.1.0
dev: true
/streamx/2.28.0:
resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==}
dependencies:
events-universal: 1.0.1
fast-fifo: 1.3.2
text-decoder: 1.2.7
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
dev: false
/string-width/4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -5452,6 +5712,12 @@ packages:
es-object-atoms: 1.1.1
dev: true
/string_decoder/1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
dependencies:
safe-buffer: 5.1.2
dev: false
/string_decoder/1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
dependencies:
@@ -5514,6 +5780,19 @@ packages:
'@pkgr/core': 0.2.9
dev: true
/tar-stream/3.2.0:
resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==}
dependencies:
b4a: 1.8.1
bare-fs: 4.8.0
fast-fifo: 1.3.2
streamx: 2.28.0
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- react-native-b4a
dev: false
/tar/6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
@@ -5526,6 +5805,15 @@ packages:
yallist: 4.0.0
dev: true
/teex/1.0.1:
resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
dependencies:
streamx: 2.28.0
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
dev: false
/temp-file/3.4.0:
resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==}
dependencies:
@@ -5533,6 +5821,14 @@ packages:
fs-extra: 10.1.0
dev: true
/text-decoder/1.2.7:
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
dependencies:
b4a: 1.8.1
transitivePeerDependencies:
- react-native-b4a
dev: false
/tiny-async-pool/1.3.0:
resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==}
dependencies:
@@ -6090,6 +6386,15 @@ packages:
engines: {node: '>=10'}
dev: true
/zip-stream/7.0.5:
resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==}
engines: {node: '>=18'}
dependencies:
compress-commons: 7.0.1
normalize-path: 3.0.0
readable-stream: 4.7.0
dev: false
/zod-validation-error/4.0.2_zod@4.1.13:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
engines: {node: '>=18.0.0'}
+29
View File
@@ -1,9 +1,21 @@
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
const { chmodSync, existsSync, renameSync } = require('node:fs')
const { execFileSync } = require('node:child_process')
const path = require('node:path')
const asar = require('@electron/asar')
const COMPATIBILITY_NAME = 'Electron'
const HELPER_SUFFIXES = ['', ' (Plugin)', ' (Renderer)', ' (GPU)']
const REQUIRED_RUNTIME_PACKAGES = [
'@electron-toolkit/preload',
'@electron-toolkit/utils',
'archiver',
'electron-updater',
'ffmpeg-static',
'fs-extra',
'jsonrepair',
'koffi'
]
function getRuntimeResources(context) {
const productName = context.packager.appInfo.productFilename
@@ -72,12 +84,28 @@ function normalizeBuilderArch(arch) {
return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch)
}
function validateAsarRuntimeDependencies(runtimeResources) {
const asarPath = path.join(runtimeResources, 'app.asar')
if (!existsSync(asarPath)) throw new Error(`Missing packaged application archive: ${asarPath}`)
const entries = new Set(asar.listPackage(asarPath))
const missingPackages = REQUIRED_RUNTIME_PACKAGES.filter(
(packageName) => !entries.has(`/node_modules/${packageName}/package.json`)
)
if (missingPackages.length > 0) {
throw new Error(
`Missing packaged runtime dependencies: ${missingPackages.join(', ')}. ` +
'Use pnpm 7.33.7 so electron-builder can read pnpm-lock.yaml.'
)
}
}
function setPlistValue(plistPath, key, value) {
execFileSync('/usr/libexec/PlistBuddy', ['-c', `Set :${key} ${value}`, plistPath])
}
exports.default = async function afterPack(context) {
const runtimeResources = getRuntimeResources(context)
validateAsarRuntimeDependencies(runtimeResources)
validateSilkWasmRuntime(runtimeResources)
const ffmpegPath = validateFfmpegRuntime(runtimeResources, context.electronPlatformName)
validateSherpaRuntime(
@@ -153,6 +181,7 @@ exports.default = async function afterPack(context) {
}
exports.getRuntimeResources = getRuntimeResources
exports.validateAsarRuntimeDependencies = validateAsarRuntimeDependencies
exports.validateFfmpegRuntime = validateFfmpegRuntime
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
exports.validateSherpaRuntime = validateSherpaRuntime
File diff suppressed because it is too large Load Diff
+662 -110
View File
File diff suppressed because it is too large Load Diff
+14 -7
View File
@@ -1231,14 +1231,21 @@ app.whenReady().then(async () => {
return stickerService.resolveSticker(cdnUrl, md5)
})
ipcMain.handle('db:getVideo', async (_, hashes: string[]) => {
if (!videoAssetService) {
const client = chat.getChatDb()?.getWcdb4Client()
if (!client) return { success: false, error: '数据库尚未连接' }
videoAssetService = new VideoAssetService(client)
ipcMain.handle(
'db:getVideo',
async (
_,
hashes: string[],
options?: { createTime?: number; duration?: number; width?: number; height?: number }
) => {
if (!videoAssetService) {
const client = chat.getChatDb()?.getWcdb4Client()
if (!client) return { success: false, error: '数据库尚未连接' }
videoAssetService = new VideoAssetService(client)
}
return videoAssetService.resolve(Array.isArray(hashes) ? hashes : [], options)
}
return videoAssetService.resolve(Array.isArray(hashes) ? hashes : [])
})
)
// -------- Settings & API service --------
+6 -1
View File
@@ -430,9 +430,14 @@ function parseLocationMessage(content: string): ParsedContent {
function parseShareMessage(content: string): ParsedContent {
const appMsgType = extractAppMsgType(content)
if (appMsgType === '19' || /<recorditem\b|<dataitem\b/i.test(content)) {
const isFileMessage = appMsgType === '6' || appMsgType === '74'
if (appMsgType === '19') {
return parseForwardBundle(content)
}
if (!isFileMessage && /<recorditem\b|<dataitem\b/i.test(content)) {
const forwardBundle = parseForwardBundle(content)
if (forwardBundle.items.length > 0) return forwardBundle
}
if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) {
const sticker = parseStickerMessage(content)
if (sticker.type === 'sticker') return sticker
+17
View File
@@ -449,6 +449,23 @@ export async function listMessagesAsync(
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
}
export async function listMessagesForExport(
userMd5: string,
startTime?: number,
endTime?: number
): Promise<FormattedMessage[]> {
if (!dbRef) return []
const rawMessages = await dbRef.getUserMessagesForExport(userMd5, startTime, endTime)
const sourceMessages = listSourceMessages(userMd5, startTime, endTime, undefined, rawMessages)
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
recordRecallArchiveMessages(userMd5, username, sourceMessages)
const mergedMessages = mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime)
console.log(
`[ChatService] listMessagesForExport end md5=${userMd5} source=${sourceMessages.length} merged=${mergedMessages.length}`
)
return mergedMessages
}
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
if (!dbRef) return null
const wcdb4Client = dbRef.getWcdb4Client()
+4 -3
View File
@@ -40,12 +40,13 @@ let archivePath = ''
let writeTimer: NodeJS.Timeout | null = null
let writeQueue: Promise<void> = Promise.resolve()
function messageIdentity(message: Message): string {
export function messageIdentity(message: Message): string {
if (message.recoveredFromRecallJournal) {
return `recovered:${message.localId || 0}:${message.serverId || message.id}`
}
if (message.localId) return `local:${message.localId}`
if (message.serverId) return `server:${message.serverId}`
const serverId = String(message.serverId || '').trim()
if (serverId && serverId !== '0') return `server:${serverId}`
if (message.localId) return `local:${message.localId}:${message.createTime || 0}`
if (message.id) return `id:${message.id}`
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
}
+226 -8
View File
@@ -8,14 +8,39 @@ type VideoAsset = {
posterPath?: string
}
export type VideoResolveOptions = {
createTime?: number
duration?: number
width?: number
height?: number
}
type ImageDimensions = {
width: number
height: number
}
type Mp4Box = {
type: string
size: number
contentOffset: number
}
export class VideoAssetService {
private readonly urlTokens = new Map<string, string>()
private readonly fileTokens = new Map<string, string>()
private readonly monthAssets = new Map<string, VideoAsset[]>()
private readonly fileHashes = new Map<string, Promise<string | undefined>>()
private readonly videoDurations = new Map<string, number | undefined>()
private readonly imageDimensions = new Map<string, ImageDimensions | undefined>()
private index: Map<string, VideoAsset> | null = null
constructor(private readonly client: Wcdb4Client) {}
resolve(hashes: string[]): { success: boolean; url?: string; poster?: string; error?: string } {
async resolve(
hashes: string[],
options: VideoResolveOptions = {}
): Promise<{ success: boolean; url?: string; poster?: string; error?: string }> {
const candidates = Array.from(
new Set(
hashes
@@ -53,6 +78,15 @@ export class VideoAssetService {
poster: asset.posterPath ? this.createLocalMediaUrl(asset.posterPath) : undefined
}
}
const fallback = await this.resolveFromLocalMetadata(candidates, options)
if (fallback) {
return {
success: true,
url: this.createLocalMediaUrl(fallback.filePath),
poster: fallback.posterPath ? this.createLocalMediaUrl(fallback.posterPath) : undefined
}
}
return { success: false, error: '本地未找到该视频文件' }
}
@@ -105,22 +139,206 @@ export class VideoAssetService {
for (const month of fs.readdirSync(root)) {
const monthPath = path.join(root, month)
if (!fs.statSync(monthPath).isDirectory()) continue
const monthly = new Map<string, VideoAsset>()
for (const name of fs.readdirSync(monthPath)) {
const match = /^([a-f0-9]{32})(?:(_raw))?\.(mp4|jpg)$/i.exec(name)
const videoMatch = /^([a-f0-9]{32})(?:(_raw))?\.mp4$/i.exec(name)
const posterMatch = /^([a-f0-9]{32})(?:(_raw))?(?:_thumb)?\.jpg$/i.exec(name)
const match = videoMatch || posterMatch
if (!match) continue
const key = `${match[1].toLowerCase()}${match[2] || ''}`
const fullPath = path.join(monthPath, name)
const existing = result.get(key) || { filePath: '' }
if (match[3].toLowerCase() === 'mp4') existing.filePath = fullPath
const existing = monthly.get(key) || { filePath: '' }
if (videoMatch) existing.filePath = fullPath
else if (!existing.posterPath) existing.posterPath = fullPath
result.set(key, existing)
monthly.set(key, existing)
}
}
for (const [key, asset] of result) {
if (!asset.filePath) result.delete(key)
const assets: VideoAsset[] = []
for (const [key, asset] of monthly) {
if (!asset.filePath) continue
result.set(key, asset)
assets.push(asset)
}
this.monthAssets.set(month, assets)
}
this.index = result
return result
}
private async resolveFromLocalMetadata(
hashes: string[],
options: VideoResolveOptions
): Promise<VideoAsset | undefined> {
const month = this.monthForCreateTime(options.createTime)
if (!month) return undefined
this.getIndex()
const assets = this.monthAssets.get(month) || []
if (assets.length === 0) return undefined
let narrowed = assets
let appliedCriteria = 0
const width = Number(options.width)
const height = Number(options.height)
if (width > 0 && height > 0) {
const matches = narrowed.filter((asset) => {
const dimensions = asset.posterPath ? this.readImageDimensions(asset.posterPath) : undefined
return dimensions?.width === width && dimensions.height === height
})
if (matches.length > 0) {
narrowed = matches
appliedCriteria += 1
}
}
const duration = Number(options.duration)
if (duration > 0) {
const matches = narrowed.filter((asset) => {
const actual = this.readMp4Duration(asset.filePath)
return actual !== undefined && Math.abs(actual - duration) <= 1.5
})
if (matches.length > 0) {
narrowed = matches
appliedCriteria += 1
}
}
if (appliedCriteria >= 2 && narrowed.length === 1) return narrowed[0]
const hashPool = narrowed.length > 0 ? narrowed : assets
const contentMatches: VideoAsset[] = []
for (const asset of hashPool) {
const contentHash = await this.hashFile(asset.filePath)
if (contentHash && hashes.includes(contentHash)) contentMatches.push(asset)
}
return contentMatches.length === 1 ? contentMatches[0] : undefined
}
private monthForCreateTime(createTime?: number): string | undefined {
const raw = Number(createTime)
if (!Number.isFinite(raw) || raw <= 0) return undefined
const date = new Date(raw > 10_000_000_000 ? raw : raw * 1000)
if (Number.isNaN(date.getTime())) return undefined
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
}
private hashFile(filePath: string): Promise<string | undefined> {
const cached = this.fileHashes.get(filePath)
if (cached) return cached
const pending = new Promise<string | undefined>((resolve) => {
const hash = crypto.createHash('md5')
const stream = fs.createReadStream(filePath)
stream.on('data', (chunk) => hash.update(chunk))
stream.on('error', () => resolve(undefined))
stream.on('end', () => resolve(hash.digest('hex')))
})
this.fileHashes.set(filePath, pending)
return pending
}
private readImageDimensions(filePath: string): ImageDimensions | undefined {
if (this.imageDimensions.has(filePath)) return this.imageDimensions.get(filePath)
let dimensions: ImageDimensions | undefined
try {
const data = fs.readFileSync(filePath)
if (data.length >= 4 && data[0] === 0xff && data[1] === 0xd8) {
let offset = 2
const startOfFrame = new Set([
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf
])
while (offset + 8 < data.length) {
if (data[offset] !== 0xff) {
offset += 1
continue
}
while (offset < data.length && data[offset] === 0xff) offset += 1
const marker = data[offset]
offset += 1
if (marker === 0xd8 || marker === 0x01) continue
if (marker === 0xd9 || marker === 0xda || offset + 2 > data.length) break
const length = data.readUInt16BE(offset)
if (length < 2 || offset + length > data.length) break
if (startOfFrame.has(marker) && length >= 7) {
dimensions = {
height: data.readUInt16BE(offset + 3),
width: data.readUInt16BE(offset + 5)
}
break
}
offset += length
}
}
} catch {
dimensions = undefined
}
this.imageDimensions.set(filePath, dimensions)
return dimensions
}
private readMp4Duration(filePath: string): number | undefined {
if (this.videoDurations.has(filePath)) return this.videoDurations.get(filePath)
let duration: number | undefined
let descriptor: number | undefined
try {
descriptor = fs.openSync(filePath, 'r')
const fileSize = fs.fstatSync(descriptor).size
const moov = this.findMp4Box(descriptor, 0, fileSize, 'moov')
const mvhd = moov
? this.findMp4Box(descriptor, moov.contentOffset, moov.contentOffset + moov.size, 'mvhd')
: undefined
if (mvhd) {
const header = Buffer.alloc(32)
const bytesRead = fs.readSync(descriptor, header, 0, header.length, mvhd.contentOffset)
const version = header[0]
if (version === 0 && bytesRead >= 20) {
const timescale = header.readUInt32BE(12)
const ticks = header.readUInt32BE(16)
if (timescale > 0) duration = ticks / timescale
} else if (version === 1 && bytesRead >= 32) {
const timescale = header.readUInt32BE(20)
const ticks = Number(header.readBigUInt64BE(24))
if (timescale > 0 && Number.isSafeInteger(ticks)) duration = ticks / timescale
}
}
} catch {
duration = undefined
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor)
}
this.videoDurations.set(filePath, duration)
return duration
}
private findMp4Box(
descriptor: number,
start: number,
end: number,
target: string
): Mp4Box | undefined {
let offset = start
const header = Buffer.alloc(16)
while (offset + 8 <= end) {
const bytesRead = fs.readSync(descriptor, header, 0, header.length, offset)
if (bytesRead < 8) return undefined
const size32 = header.readUInt32BE(0)
const type = header.toString('ascii', 4, 8)
let headerSize = 8
let size = size32
if (size32 === 1) {
if (bytesRead < 16) return undefined
const extendedSize = header.readBigUInt64BE(8)
if (extendedSize > BigInt(Number.MAX_SAFE_INTEGER)) return undefined
size = Number(extendedSize)
headerSize = 16
} else if (size32 === 0) {
size = end - offset
}
if (size < headerSize || offset + size > end) return undefined
if (type === target) {
return { type, size: size - headerSize, contentOffset: offset + headerSize }
}
offset += size
}
return undefined
}
}
+7 -5
View File
@@ -1286,11 +1286,13 @@ export class Wcdb4Client {
const merged = new Map<string, Wcdb4Message>()
for (const message of [...recovered, ...current]) {
const recoveredRow = Boolean(message.raw?.['_wxe_recovered'])
const identity = message.mesLocalID
? `local:${message.mesLocalID}`
: message.serverId
? `server:${message.serverId}`
: `${message.msgCreateTime}:${message.msgContent}`
const serverId = String(message.serverId || '').trim()
const identity =
serverId && serverId !== '0'
? `server:${serverId}`
: message.mesLocalID
? `local:${message.mesLocalID}:${message.msgCreateTime || 0}`
: `${message.msgCreateTime}:${message.msgContent}`
const key = recoveredRow ? `recovered:${identity}` : identity
merged.set(key, message)
}
+48 -3
View File
@@ -34,6 +34,24 @@ export interface GroupMemberInfo {
m_nsHeadImgUrl: string
}
function mergeExportMessages(messages: WechatMessage[]): WechatMessage[] {
const merged = new Map<string, WechatMessage>()
for (const message of messages) {
const serverId = String(message.serverId || '')
const recovered = Boolean(message.raw?.['_wxe_recovered'] || message['_wxe_recovered'])
const identity =
serverId && serverId !== '0'
? `server:${serverId}`
: `local:${message.mesLocalID}:${message.msgCreateTime}`
merged.set(recovered ? `recovered:${identity}` : identity, message)
}
return Array.from(merged.values()).sort(
(left, right) =>
Number(left.msgCreateTime || 0) - Number(right.msgCreateTime || 0) ||
Number(left.mesLocalID || 0) - Number(right.mesLocalID || 0)
)
}
export class WechatDb {
private wcdb4Client: Wcdb4Client
private chatMd5ToUsername = new Map<string, string>()
@@ -170,8 +188,8 @@ export class WechatDb {
const username = this.chatMd5ToUsername.get(userMd5)
if (!username) return []
return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({
...message,
...message.raw
...message.raw,
...message
}))
}
@@ -185,7 +203,34 @@ export class WechatDb {
const username = this.chatMd5ToUsername.get(userMd5)
if (!username) return []
const messages = await this.wcdb4Client.getMessagesAsync(username, startTime, endTime, options)
return messages.map((message) => ({ ...message, ...message.raw }))
return messages.map((message) => ({ ...message.raw, ...message }))
}
public async getUserMessagesForExport(
userMd5: string,
startTime?: number,
endTime?: number
): Promise<WechatMessage[]> {
this.ensureChatTableMapping()
const username = this.chatMd5ToUsername.get(userMd5)
if (!username) return []
if (startTime && endTime && startTime > endTime) return []
// A bounded native cursor can omit rows stored across a message shard boundary.
// Scan without bounds first, then apply the requested range in application code.
const rows = await this.wcdb4Client.getMessagesAsync(username)
const messages = rows
.map((message) => ({ ...message.raw, ...message }))
.filter((message) => {
const createTime = Number(message.msgCreateTime || 0)
if (startTime && createTime < startTime) return false
if (endTime && createTime > endTime) return false
return true
})
console.log(
`[WechatDb] export scan username=${username} raw=${rows.length} filtered=${messages.length} start=${startTime || 0} end=${endTime || 0}`
)
return mergeExportMessages(messages)
}
public searchAllMessages(keyword: string): string | null {
+2 -1
View File
@@ -234,7 +234,8 @@ declare global {
filePath?: string
}>
getVideo: (
hashes: string[]
hashes: string[],
options?: { createTime?: number; duration?: number; width?: number; height?: number }
) => Promise<{ success: boolean; url?: string; poster?: string; error?: string }>
getSticker: (
cdnUrl?: string,
+4 -1
View File
@@ -107,7 +107,10 @@ const api = {
sessionId?: string,
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes),
getVideo: (
hashes: string[],
options?: { createTime?: number; duration?: number; width?: number; height?: number }
) => ipcRenderer.invoke('db:getVideo', hashes, options),
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
cancelExport: (jobId: string) => ipcRenderer.invoke('export:cancel', jobId),
+37 -12
View File
@@ -212,7 +212,32 @@ function App(): React.ReactElement {
const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => {
try {
const stored = JSON.parse(localStorage.getItem('wxe_export_tasks') || '[]')
return Array.isArray(stored) ? (stored as ExportTaskRecord[]).slice(0, 20) : []
if (!Array.isArray(stored)) return []
return stored.slice(0, 20).map(
(
value: Partial<ExportTaskRecord> & {
contactId?: string
contactName?: string
}
) => {
const targetIds = Array.isArray(value.targetIds)
? value.targetIds
: value.contactId
? [value.contactId]
: []
const targetNames = Array.isArray(value.targetNames)
? value.targetNames
: value.contactName
? [value.contactName]
: []
return {
...value,
targetIds,
targetNames,
targetLabel: value.targetLabel || targetNames.join('、') || '聊天导出'
} as ExportTaskRecord
}
)
} catch {
return []
}
@@ -353,10 +378,14 @@ function App(): React.ReactElement {
const handleStartExport = async (
request: ExportRequest
): Promise<import('../../shared/export').ExportResult> => {
const targetNames = request.targets.map((target) => target.name)
const targetLabel =
targetNames.length > 1 ? `${targetNames[0]}${targetNames.length} 个聊天` : targetNames[0]
const task: ExportTaskRecord = {
jobId: request.jobId,
contactId: request.userMd5,
contactName: request.name,
targetIds: request.targets.map((target) => target.userMd5),
targetNames,
targetLabel,
format: request.format,
status: 'running',
progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 },
@@ -1232,16 +1261,14 @@ function App(): React.ReactElement {
}
}
const loadExportPreview = async (contact: Contact): Promise<void> => {
setSelectedContact(contact)
selectedContactMd5Ref.current = contact.md5
const loadExportPreviewMessages = async (contact: Contact): Promise<Message[]> => {
try {
const previewMessages = await window.api.getMessages(contact.md5, undefined, undefined, {
return await window.api.getMessages(contact.md5, undefined, undefined, {
limit: EXPORT_PREVIEW_LIMIT
})
if (selectedContactMd5Ref.current === contact.md5) setMessages(previewMessages)
} catch (error) {
console.warn('[Export] preview load failed:', error)
return []
}
}
@@ -1334,7 +1361,6 @@ function App(): React.ReactElement {
const handlePageChange = (page: AppPage): void => {
setActivePage(page)
if (page === 'archive' && selectedContact) void handleSelectContact(selectedContact)
if (page === 'export' && selectedContact) void loadExportPreview(selectedContact)
if (page === 'settings') setSettingsCategory('account-database')
if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) {
setReportSourceContact(selectedContact)
@@ -1714,11 +1740,10 @@ function App(): React.ReactElement {
return (
<ExportWorkspace
contacts={contacts}
selectedContact={selectedContact}
previewMessages={messages}
initialContact={selectedContact}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
onSelectContact={loadExportPreview}
loadPreviewMessages={loadExportPreviewMessages}
onOpenSettings={openSettings}
exportTasks={exportTasks}
onStartExport={handleStartExport}
+9 -3
View File
@@ -4,14 +4,20 @@ interface VideoBubbleProps {
md5?: string
newMd5?: string
rawMd5?: string
createTime?: number
duration?: number
width?: number
height?: number
}
export function VideoBubble({
md5,
newMd5,
rawMd5,
duration
createTime,
duration,
width,
height
}: VideoBubbleProps): React.ReactElement {
const hashes = useMemo(
() => [rawMd5, newMd5, md5].filter((value): value is string => Boolean(value)),
@@ -22,7 +28,7 @@ export function VideoBubble({
useEffect(() => {
let cancelled = false
window.api
.getVideo(hashes)
.getVideo(hashes, { createTime, duration, width, height })
.then((result) => {
if (!cancelled) setMedia(result.success ? result : { error: result.error })
})
@@ -32,7 +38,7 @@ export function VideoBubble({
return () => {
cancelled = true
}
}, [hashes])
}, [createTime, duration, hashes, height, width])
if (!media.url) {
return <div className="video-placeholder">{media.error || '视频加载中…'}</div>
@@ -81,7 +81,10 @@ export function MessageBubble({
md5={message.contentData.md5}
newMd5={message.contentData.newMd5}
rawMd5={message.contentData.rawMd5}
createTime={message.createTime}
duration={message.contentData.duration}
width={message.contentData.width}
height={message.contentData.height}
/>
) : isRichMedia && message.contentData ? (
<RichMessageBubble
@@ -6,6 +6,9 @@ interface ExportContactPanelProps {
contacts: Contact[]
filteredContacts: Contact[]
activeContact: Contact | null
selectedContactIds: string[]
selectionMode: boolean
selectionLimit: number
selfInfo: SelfInfo | null
dbReady: boolean
contactFilter: string
@@ -13,6 +16,7 @@ interface ExportContactPanelProps {
onContactFilterChange: (value: string) => void
onContactTypeChange: (value: 'all' | 'group' | 'user') => void
onSelectContact: (contact: Contact) => void
onCompleteSelection: () => void
onOpenSettings: () => void
}
@@ -20,6 +24,9 @@ export function ExportContactPanel({
contacts,
filteredContacts,
activeContact,
selectedContactIds,
selectionMode,
selectionLimit,
selfInfo,
dbReady,
contactFilter,
@@ -27,6 +34,7 @@ export function ExportContactPanel({
onContactFilterChange,
onContactTypeChange,
onSelectContact,
onCompleteSelection,
onOpenSettings
}: ExportContactPanelProps): React.ReactElement {
return (
@@ -65,15 +73,30 @@ export function ExportContactPanel({
</div>
</div>
{selectionMode && (
<div className="export-multi-select-bar">
<span>
{selectedContactIds.length} / {selectionLimit}
</span>
<button type="button" onClick={onCompleteSelection}>
</button>
</div>
)}
<div className="export-contact-list">
{filteredContacts.map((contact) => {
const name = displayName(contact)
const selected = selectedContactIds.includes(contact.md5)
const atLimit = selectionMode && !selected && selectedContactIds.length >= selectionLimit
return (
<button
key={contact.md5}
type="button"
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''}`}
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''} ${selected ? 'selected' : ''}`}
onClick={() => onSelectContact(contact)}
disabled={atLimit}
aria-pressed={selected}
>
<span className="export-contact-avatar">
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
@@ -82,6 +105,11 @@ export function ExportContactPanel({
<strong>{name}</strong>
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
</span>
{selectionMode && (
<span className={`export-contact-check ${selected ? 'checked' : ''}`} aria-hidden>
{selected ? '✓' : ''}
</span>
)}
</button>
)
})}
@@ -10,6 +10,7 @@ interface ExportPreviewPanelProps {
previewBytes: number
selfInfo: SelfInfo | null
progress: ExportJobProgress | null
selectedCount: number
jobId: string
onCancel: (jobId: string) => void
onReveal: (path: string) => void
@@ -22,6 +23,7 @@ export function ExportPreviewPanel({
previewBytes,
selfInfo,
progress,
selectedCount,
jobId,
onCancel,
onReveal
@@ -32,7 +34,9 @@ export function ExportPreviewPanel({
<>
<div className="export-preview-heading">
<strong></strong>
<span> 20 </span>
<span>
{selectedCount > 1 ? `${selectedCount} 个聊天 · 合并预览` : '仅预览最近 20 条'}
</span>
</div>
<div className="export-message-preview">
<div className="export-preview-date"></div>
@@ -50,7 +54,7 @@ export function ExportPreviewPanel({
]
).map((message) => (
<div
key={message.id}
key={`${message.exportConversationId || 'single'}:${message.id}`}
className={`export-preview-message ${message.isSender ? 'mine' : ''} ${
message.contentData?.type === 'system' && message.contentData.pat ? 'system' : ''
}`}
@@ -64,6 +68,9 @@ export function ExportPreviewPanel({
</span>
<span className="export-preview-bubble">
<small>
{selectedCount > 1 && message.exportConversationName
? `${message.exportConversationName} · `
: ''}
{message.name || (message.isSender ? '我' : '联系人')} ·{' '}
{formatPreviewTime(message)}
</small>
@@ -108,7 +115,11 @@ export function ExportPreviewPanel({
<ol>
<li className="done"></li>
<li className="current">
{progress?.phase === 'writing' ? '生成档案' : '分批读取聊天记录'}
{progress?.phase === 'compressing'
? '压缩 ZIP'
: progress?.phase === 'writing'
? '生成档案'
: '分批读取聊天记录'}
</li>
<li></li>
<li></li>
@@ -118,9 +129,11 @@ export function ExportPreviewPanel({
<span style={{ width: `${progress?.percent ?? 0}%` }} />
</div>
<strong>
{progress?.phase === 'writing'
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
: `正在读取消息... ${progress?.percent ?? 0}%`}
{progress?.phase === 'compressing'
? `正在压缩资源包... ${progress.percent ?? 0}%`
: progress?.phase === 'writing'
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
: `正在读取消息... ${progress?.percent ?? 0}%`}
</strong>
<button type="button" className="export-cancel-button" onClick={() => onCancel(jobId)}>
@@ -9,6 +9,25 @@ interface ExportTaskCenterProps {
onCancel: (jobId: string) => void
}
const phaseLabels: Record<ExportTaskRecord['progress']['phase'], string> = {
reading: '读取消息',
writing: '导出资源',
compressing: '压缩归档',
completed: '已完成',
cancelled: '已取消',
failed: '导出失败'
}
const taskDetail = (task: ExportTaskRecord): string | null => {
if (task.status === 'completed') {
return `成功导出 ${task.progress.total ?? task.progress.processed} 条消息`
}
if (task.status === 'failed') {
return `失败原因:${task.progress.error || '未知错误'}`
}
return null
}
export function ExportTaskCenter({
open,
taskCount,
@@ -22,7 +41,7 @@ export function ExportTaskCenter({
const log = [
'WechatExplorer 导出任务日志',
`时间:${new Date(task.createdAt).toLocaleString('zh-CN')}`,
`会话:${task.contactName}`,
`会话:${task.targetLabel}`,
`格式:${task.format.toUpperCase()}`,
`状态:${task.progress.phase}`,
`进度:${task.progress.percent ?? 0}%`,
@@ -46,35 +65,41 @@ export function ExportTaskCenter({
{tasks.length === 0 ? (
<p></p>
) : (
tasks.map((task) => (
<div className="export-task-row" key={task.jobId}>
<span>
<strong>{task.contactName}</strong>
<small>
{task.format.toUpperCase()} · {task.progress.phase}
</small>
{task.progress.error && (
<small className="export-task-error" title={task.progress.error}>
{task.progress.error}
tasks.map((task) => {
const detail = taskDetail(task)
return (
<div className="export-task-row" key={task.jobId}>
<span>
<strong>{task.targetLabel}</strong>
<small>
{task.format.toUpperCase()} · {phaseLabels[task.progress.phase]}
</small>
{detail && (
<small
className={`export-task-detail ${task.status}`}
title={task.status === 'failed' ? detail : undefined}
>
{detail}
</small>
)}
</span>
<span className="export-task-progress">
<i style={{ width: `${task.progress.percent ?? 0}%` }} />
<b>{task.progress.percent ?? 0}%</b>
</span>
{task.status === 'running' && (
<button type="button" onClick={() => onCancel(task.jobId)}>
</button>
)}
</span>
<span className="export-task-progress">
<i style={{ width: `${task.progress.percent ?? 0}%` }} />
<b>{task.progress.percent ?? 0}%</b>
</span>
{task.status === 'running' && (
<button type="button" onClick={() => onCancel(task.jobId)}>
</button>
)}
{task.status === 'failed' && (
<button type="button" onClick={() => void copyTaskLog(task)}>
{copiedJobId === task.jobId ? '已复制' : '复制日志'}
</button>
)}
</div>
))
{task.status === 'failed' && (
<button type="button" onClick={() => void copyTaskLog(task)}>
{copiedJobId === task.jobId ? '已复制' : '复制日志'}
</button>
)}
</div>
)
})
)}
</section>
)}
@@ -3,12 +3,15 @@ import type { Message } from '../../../../shared/types'
import type {
ExportJobProgress,
ExportMessageKind,
ExportNameMode
ExportNameMode,
ExportRequest,
ExportTarget
} from '../../../../shared/export'
import { ExportContactPanel } from './ExportContactPanel'
import { ExportPreviewPanel } from './ExportPreviewPanel'
import { ExportTaskCenter } from './ExportTaskCenter'
import type {
Contact,
ExportFormat,
ExportRange,
ExportStatus,
@@ -20,24 +23,33 @@ import type { VoiceModelStatus } from '../../../../shared/voice-recognition'
export function ExportWorkspace({
contacts,
selectedContact,
previewMessages,
initialContact,
selfInfo,
dbReady,
onSelectContact,
loadPreviewMessages,
onOpenSettings,
exportTasks,
onStartExport,
onCancelExport
}: ExportWorkspaceProps): React.ReactElement {
const initialSelection = initialContact || contacts[0] || null
const initialContactRef = React.useRef<Contact | null>(initialSelection)
const previewLoadingRef = React.useRef(new Set<string>())
const [contactFilter, setContactFilter] = useState('')
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
const [selectionMode, setSelectionMode] = useState(false)
const [selectedContacts, setSelectedContacts] = useState<Contact[]>(() =>
initialSelection ? [initialSelection] : []
)
const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '')
const [previewByContact, setPreviewByContact] = useState<Record<string, Message[]>>({})
const [range, setRange] = useState<ExportRange>('today')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
const [nameMode, setNameMode] = useState<ExportNameMode>('remark')
const [groupMembers, setGroupMembers] = useState<GroupMemberName[]>([])
const [nameMode, setNameMode] = useState<ExportNameMode>(
initialSelection?.type === 'group' ? 'groupNickname' : 'remark'
)
const [includeMedia, setIncludeMedia] = useState(true)
const [includeVoiceTranscripts, setIncludeVoiceTranscripts] = useState(true)
const [voiceModelStatus, setVoiceModelStatus] = useState<VoiceModelStatus | null>(null)
@@ -52,6 +64,27 @@ export function ExportWorkspace({
const [jobId, setJobId] = useState('')
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
const [taskCenterOpen, setTaskCenterOpen] = useState(false)
const selectionLimit = 5
React.useEffect(() => {
if (selectedContacts.length > 0) return
const candidate = initialContact || contacts[0]
if (!candidate) return
initialContactRef.current = candidate
setSelectedContacts([candidate])
setActiveContactId(candidate.md5)
}, [contacts, initialContact, selectedContacts.length])
React.useEffect(() => {
for (const contact of selectedContacts) {
if (previewByContact[contact.md5] || previewLoadingRef.current.has(contact.md5)) continue
previewLoadingRef.current.add(contact.md5)
void loadPreviewMessages(contact).then((items) => {
previewLoadingRef.current.delete(contact.md5)
setPreviewByContact((current) => ({ ...current, [contact.md5]: items }))
})
}
}, [loadPreviewMessages, previewByContact, selectedContacts])
const filteredContacts = useMemo(() => {
const keyword = contactFilter.trim().toLowerCase()
@@ -64,11 +97,35 @@ export function ExportWorkspace({
})
}, [contactFilter, contactType, contacts])
const activeContact = selectedContact || filteredContacts[0] || contacts[0] || null
const currentTask = exportTasks.find((task) => task.contactId === activeContact?.md5)
const activeContact =
selectedContacts.find((contact) => contact.md5 === activeContactId) ||
selectedContacts[0] ||
null
const selectedTargetKey = selectedContacts
.map((contact) => contact.md5)
.sort()
.join('|')
const currentTask = exportTasks.find(
(task) => [...task.targetIds].sort().join('|') === selectedTargetKey
)
const taskCount = exportTasks.filter((task) => task.status === 'running').length
const activeName = displayName(activeContact)
const preview = previewMessages.slice(-20)
const selectedNames = selectedContacts.map(displayName)
const selectedLabel =
selectedNames.length > 1
? `${selectedNames.join('、')} · 共 ${selectedNames.length} 个聊天`
: selectedNames[0] || '未选择聊天'
const preview = selectedContacts
.flatMap((contact) =>
(previewByContact[contact.md5] || []).map((message) => ({
...message,
exportConversationId: contact.md5,
exportConversationName: displayName(contact),
exportConversationAvatarUrl: contact.avatar
}))
)
.sort((left, right) => Number(left.createTime || 0) - Number(right.createTime || 0))
.slice(-20)
const previewMediaCount = preview.filter(
(message) =>
['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '') ||
@@ -78,79 +135,59 @@ export function ExportWorkspace({
(total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0),
0
)
const outputName = fileName.trim() || `${activeName}_聊天档案`
const nameOptions: { value: ExportNameMode; label: string }[] =
activeContact?.type === 'group'
? [
{ value: 'groupNickname', label: '群昵称' },
{ value: 'remark', label: '备注' },
{ value: 'wechatNickname', label: '微信名' }
]
: [
{ value: 'remark', label: '备注' },
{ value: 'wechatNickname', label: '微信名' }
]
const nameMap = useMemo(() => {
const map: Record<string, string> = {}
if (activeContact?.type === 'group') {
for (const member of groupMembers) {
const value =
nameMode === 'groupNickname'
? member.groupNickname || member.nickname || member.wxid
: nameMode === 'remark'
? member.remark || member.wechatNickname || member.wxid
: member.wechatNickname || member.wxid
map[member.wxid] = value
}
} else if (activeContact) {
map[activeContact.m_nsUsrName] =
nameMode === 'remark'
? activeContact.remark || activeContact.m_nsNickName || activeContact.m_nsUsrName
: activeContact.wechatNickname || activeContact.m_nsUsrName
}
if (selfInfo?.wxid) map[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
return map
}, [activeContact, groupMembers, nameMode, selfInfo])
const avatarUrls = useMemo(() => {
const map: Record<string, string> = {}
if (activeContact?.m_nsUsrName && activeContact.avatar) {
map[activeContact.m_nsUsrName] = activeContact.avatar
}
for (const member of groupMembers) {
if (member.avatar) map[member.wxid] = member.avatar
}
if (selfInfo?.wxid && selfInfo.avatar) map[selfInfo.wxid] = selfInfo.avatar
return map
}, [activeContact, groupMembers, selfInfo])
const defaultOutputName =
selectedContacts.length > 1
? `${selectedNames[0]}${selectedContacts.length}个聊天_合并档案`
: `${activeName}_聊天档案`
const outputName = fileName.trim() || defaultOutputName
const nameOptions: { value: ExportNameMode; label: string }[] = selectedContacts.some(
(contact) => contact.type === 'group'
)
? [
{ value: 'groupNickname', label: '群昵称' },
{ value: 'remark', label: '备注' },
{ value: 'wechatNickname', label: '微信名' }
]
: [
{ value: 'remark', label: '备注' },
{ value: 'wechatNickname', label: '微信名' }
]
const previewName = (message: Message): string =>
(message.senderId && nameMap[message.senderId]) ||
message.name ||
(message.isSender ? selfInfo?.nickname : undefined) ||
(message.isSender ? '我' : '联系人')
const previewAvatar = (message: Message): string | undefined =>
(message.senderId && avatarUrls[message.senderId]) ||
message.img ||
(message.isSender ? selfInfo?.avatar : undefined)
message.img || (message.isSender ? selfInfo?.avatar : undefined)
const previewItems = preview.map((message) => ({
...message,
name: previewName(message),
img: previewAvatar(message)
}))
React.useEffect(() => {
setNameMode(activeContact?.type === 'group' ? 'groupNickname' : 'remark')
setGroupMembers([])
if (!activeContact || activeContact.type !== 'group') return
const timer = window.setTimeout(() => {
void window.api.getGroupSnapshot(activeContact.md5).then((snapshot) => {
setGroupMembers((snapshot?.members || []) as GroupMemberName[])
})
}, 300)
return () => window.clearTimeout(timer)
}, [activeContact])
const handleSelectContact = (contact: Contact): void => {
if (!selectionMode) {
setSelectedContacts([contact])
setActiveContactId(contact.md5)
setStatus('idle')
return
}
const selected = selectedContacts.some((item) => item.md5 === contact.md5)
if (selected) {
if (selectedContacts.length === 1) return
const next = selectedContacts.filter((item) => item.md5 !== contact.md5)
setSelectedContacts(next)
if (activeContactId === contact.md5) setActiveContactId(next[0].md5)
setStatus('idle')
return
}
if (selectedContacts.length >= selectionLimit) return
const next = [...selectedContacts, contact]
setSelectedContacts(next)
setActiveContactId(contact.md5)
setFormat('html')
setStatus('idle')
}
React.useEffect(() => {
let active = true
@@ -173,42 +210,59 @@ export function ExportWorkspace({
}
const handleStart = async (): Promise<void> => {
if (!activeContact || status === 'running') return
if (!activeContact || selectedContacts.length === 0 || status === 'running') return
// Runs only from the export button event; a fresh id is required for each job.
// eslint-disable-next-line react-hooks/purity
const nextJobId = `export-${Date.now()}`
setJobId(nextJobId)
setProgress(null)
setStatus('running')
let exportNameMap = nameMap
let exportAvatarUrls = avatarUrls
if (activeContact.type === 'group') {
const snapshot = await window.api.getGroupSnapshot(activeContact.md5)
const members = (snapshot?.members || []) as GroupMemberName[]
setGroupMembers(members)
exportNameMap = { ...nameMap }
exportAvatarUrls = { ...avatarUrls }
for (const member of members) {
exportNameMap[member.wxid] =
nameMode === 'groupNickname'
? member.groupNickname || member.nickname || member.wxid
: nameMode === 'remark'
? member.remark || member.wechatNickname || member.wxid
: member.wechatNickname || member.wxid
if (member.avatar) exportAvatarUrls[member.wxid] = member.avatar
}
}
const targets: ExportTarget[] = await Promise.all(
selectedContacts.map(async (contact) => {
const nameMap: Record<string, string> = {}
const avatarUrls: Record<string, string> = {}
if (contact.type === 'group') {
const snapshot = await window.api.getGroupSnapshot(contact.md5)
for (const member of (snapshot?.members || []) as GroupMemberName[]) {
nameMap[member.wxid] =
nameMode === 'groupNickname'
? member.groupNickname || member.nickname || member.wxid
: nameMode === 'remark'
? member.remark || member.wechatNickname || member.wxid
: member.wechatNickname || member.wxid
if (member.avatar) avatarUrls[member.wxid] = member.avatar
}
} else {
nameMap[contact.m_nsUsrName] =
nameMode === 'remark'
? contact.remark || contact.m_nsNickName || contact.m_nsUsrName
: contact.wechatNickname || contact.m_nsUsrName
if (contact.avatar) avatarUrls[contact.m_nsUsrName] = contact.avatar
}
if (selfInfo?.wxid) {
nameMap[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
if (selfInfo.avatar) avatarUrls[selfInfo.wxid] = selfInfo.avatar
}
return {
userMd5: contact.md5,
name: displayName(contact),
type: contact.type,
avatarUrl: contact.avatar,
nameMode,
nameMap,
avatarUrls
}
})
)
const now = new Date()
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
const days = range === 'today' ? 1 : range === 'threeDays' ? 3 : range === 'sevenDays' ? 7 : 0
const startOfRange = days
? new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1)
: null
const request = {
const request: ExportRequest = {
jobId: nextJobId,
userMd5: activeContact.md5,
name: activeName,
format,
targets,
format: selectedContacts.length > 1 ? 'html' : format,
outputName,
startTime: startOfRange
? Math.floor(startOfRange.getTime() / 1000)
@@ -232,9 +286,6 @@ export function ExportWorkspace({
fallbackThumbnail,
keepMissing,
includeAvatars,
avatarUrls: exportAvatarUrls,
nameMode,
nameMap: exportNameMap,
zip
}
const result = await onStartExport(request)
@@ -277,6 +328,29 @@ export function ExportWorkspace({
)
}, [currentTask])
const resetDefaults = (): void => {
const contact = initialContactRef.current || contacts[0] || null
setSelectedContacts(contact ? [contact] : [])
setActiveContactId(contact?.md5 || '')
setSelectionMode(false)
setRange('today')
setStartDate('')
setEndDate('')
setSelectedKinds(new Set(['text']))
setNameMode(contact?.type === 'group' ? 'groupNickname' : 'remark')
setIncludeMedia(true)
setIncludeAvatars(true)
setPreferOriginal(true)
setFallbackThumbnail(true)
setKeepMissing(true)
setFormat('csv')
setZip(false)
setFileName('')
setStatus('idle')
setJobId('')
setProgress(null)
}
const targetPath =
format === 'html'
? zip
@@ -290,13 +364,17 @@ export function ExportWorkspace({
contacts={contacts}
filteredContacts={filteredContacts}
activeContact={activeContact}
selectedContactIds={selectedContacts.map((contact) => contact.md5)}
selectionMode={selectionMode}
selectionLimit={selectionLimit}
selfInfo={selfInfo}
dbReady={dbReady}
contactFilter={contactFilter}
contactType={contactType}
onContactFilterChange={setContactFilter}
onContactTypeChange={setContactType}
onSelectContact={onSelectContact}
onSelectContact={handleSelectContact}
onCompleteSelection={() => setSelectionMode(false)}
onOpenSettings={onOpenSettings}
/>
@@ -310,20 +388,28 @@ export function ExportWorkspace({
onCancel={(taskJobId) => void onCancelExport(taskJobId)}
/>
<header className="export-config-header">
<span className="export-chat-avatar">
{activeContact?.avatar ? (
<img src={activeContact.avatar} alt="" />
) : (
activeName.slice(0, 1)
)}
<span className="export-chat-avatar-stack" aria-hidden>
{selectedContacts.slice(0, 3).map((contact) => (
<span className="export-chat-avatar" key={contact.md5}>
{contact.avatar ? (
<img src={contact.avatar} alt="" />
) : (
displayName(contact).slice(0, 1)
)}
</span>
))}
</span>
<span>
<span className="export-config-title">
<h1></h1>
<p>
{activeName}
{activeContact?.type === 'group' ? ' · 群聊' : ''}
</p>
<p>{selectedLabel}</p>
</span>
<button
type="button"
className="export-add-chat-button"
onClick={() => setSelectionMode((current) => !current)}
>
{selectionMode ? '完成选择' : '+ 添加聊天'}
</button>
</header>
<section className="export-section export-format-top">
@@ -334,6 +420,7 @@ export function ExportWorkspace({
key={value}
type="button"
className={format === value ? 'active' : ''}
disabled={selectedContacts.length > 1 && value !== 'html'}
onClick={() => setFormat(value)}
>
<strong>{formatLabels[value].label}</strong>
@@ -342,7 +429,9 @@ export function ExportWorkspace({
))}
</div>
<p className="export-helper-text">
CSV HTML
{selectedContacts.length > 1
? '多聊天合并仅支持 HTML,会保留每条消息所属的聊天。'
: 'CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。'}
</p>
{format === 'html' && (
<>
@@ -544,45 +633,6 @@ export function ExportWorkspace({
</label>
</section>
<section className="export-section">
<h3></h3>
<div className="export-format-grid">
{formatOrder.map((value) => (
<button
key={value}
type="button"
className={format === value ? 'active' : ''}
onClick={() => setFormat(value)}
>
<strong>{formatLabels[value].label}</strong>
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
</button>
))}
</div>
{format === 'html' && (
<div className="export-html-options">
<label>
<input
type="radio"
name="html-package"
checked={!zip}
onChange={() => setZip(false)}
/>{' '}
HTML
</label>
<label>
<input
type="radio"
name="html-package"
checked={zip}
onChange={() => setZip(true)}
/>{' '}
HTML ZIP
</label>
</div>
)}
</section>
<section className="export-section export-save-section">
<h3></h3>
<label>
@@ -590,7 +640,7 @@ export function ExportWorkspace({
<input
value={fileName}
onChange={(event) => setFileName(event.target.value)}
placeholder={`${activeName}_聊天档案`}
placeholder={defaultOutputName}
/>
</label>
<div className="export-target-path">
@@ -615,7 +665,7 @@ export function ExportWorkspace({
: '准备就绪'}
</span>
<span className="export-target-summary">{targetPath}</span>
<button type="button" className="export-reset-button" onClick={() => setStatus('idle')}>
<button type="button" className="export-reset-button" onClick={resetDefaults}>
</button>
<button
@@ -636,6 +686,7 @@ export function ExportWorkspace({
previewBytes={previewBytes}
selfInfo={selfInfo}
progress={progress}
selectedCount={selectedContacts.length}
jobId={jobId}
onCancel={(exportJobId) => {
void window.api.cancelExport(exportJobId)
@@ -30,15 +30,21 @@ export interface SelfInfo {
export interface ExportWorkspaceProps {
contacts: Contact[]
selectedContact: Contact | null
previewMessages: Message[]
initialContact: Contact | null
selfInfo: SelfInfo | null
dbReady: boolean
onSelectContact: (contact: Contact) => void
loadPreviewMessages: (contact: Contact) => Promise<Message[]>
onOpenSettings: () => void
exportTasks: ExportTaskRecord[]
onStartExport: (request: ExportRequest) => Promise<ExportResult>
onCancelExport: (jobId: string) => Promise<void>
}
export type { Contact, ExportJobProgress, ExportMessageKind, Message, ExportNameMode, ExportTaskRecord }
export type {
Contact,
ExportJobProgress,
ExportMessageKind,
Message,
ExportNameMode,
ExportTaskRecord
}
+101
View File
@@ -116,6 +116,25 @@
padding: 8px 0;
}
.export-multi-select-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 16px;
border-bottom: 1px solid var(--wxex-border);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-size: 12px;
button {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
}
}
.export-contact-item {
display: flex;
align-items: center;
@@ -137,6 +156,28 @@
border-left-color: var(--wxex-brand);
background: var(--wxex-brand-soft);
}
&:disabled {
cursor: not-allowed;
opacity: 0.48;
}
}
.export-contact-check {
width: 18px;
height: 18px;
display: grid;
place-items: center;
flex: 0 0 auto;
border: 1px solid var(--wxex-border-strong);
border-radius: 4px;
color: #fff;
font-size: 12px;
&.checked {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
}
}
.export-contact-avatar,
@@ -246,6 +287,46 @@
font-size: 13px;
}
}
.export-config-title {
min-width: 0;
}
.export-config-title p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.export-chat-avatar-stack {
position: relative;
width: 70px;
height: 58px;
flex: 0 0 70px;
.export-chat-avatar {
position: absolute;
top: 3px;
border: 2px solid var(--wxex-bg-main);
}
.export-chat-avatar:nth-child(2) {
left: 12px;
top: 8px;
}
.export-chat-avatar:nth-child(3) {
left: 24px;
top: 13px;
}
}
.export-add-chat-button {
margin-left: auto;
padding: 7px 10px;
border: 1px solid var(--wxex-brand);
border-radius: 6px;
background: transparent;
color: var(--wxex-brand);
cursor: pointer;
white-space: nowrap;
font: 600 12px/18px var(--wxex-font);
}
.export-chat-avatar {
width: 52px;
height: 52px;
@@ -259,6 +340,11 @@
}
}
.export-format-grid button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.export-section {
margin-bottom: 25px;
@@ -768,6 +854,21 @@
color: var(--wxex-danger, #b42318);
}
.export-task-detail {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
&.completed {
color: var(--wxex-brand);
}
&.failed {
color: var(--wxex-danger, #c43d3d);
}
}
button {
border: 1px solid var(--wxex-border);
border-radius: 5px;
+15 -8
View File
@@ -14,10 +14,19 @@ export type ExportMessageKind =
export type ExportNameMode = 'groupNickname' | 'remark' | 'wechatNickname'
export interface ExportRequest {
jobId: string
export interface ExportTarget {
userMd5: string
name: string
type: 'user' | 'group'
avatarUrl?: string
nameMode?: ExportNameMode
nameMap?: Record<string, string>
avatarUrls?: Record<string, string>
}
export interface ExportRequest {
jobId: string
targets: ExportTarget[]
format: ExportFormat
outputName: string
startTime?: number
@@ -29,15 +38,12 @@ export interface ExportRequest {
fallbackThumbnail?: boolean
keepMissing?: boolean
includeAvatars?: boolean
avatarUrls?: Record<string, string>
nameMode?: ExportNameMode
nameMap?: Record<string, string>
zip?: boolean
}
export interface ExportJobProgress {
jobId: string
phase: 'reading' | 'writing' | 'completed' | 'cancelled' | 'failed'
phase: 'reading' | 'writing' | 'compressing' | 'completed' | 'cancelled' | 'failed'
processed: number
total?: number
percent?: number
@@ -47,8 +53,9 @@ export interface ExportJobProgress {
export interface ExportTaskRecord {
jobId: string
contactId: string
contactName: string
targetIds: string[]
targetNames: string[]
targetLabel: string
format: ExportFormat
status: 'running' | 'completed' | 'cancelled' | 'failed'
progress: ExportJobProgress
+3
View File
@@ -38,6 +38,9 @@ export interface Message {
exportShowAvatar?: boolean
exportMediaError?: string
exportAvatarUrl?: string
exportConversationId?: string
exportConversationName?: string
exportConversationAvatarUrl?: string
}
type TextContent = { type: 'text'; content: string }
+162
View File
@@ -0,0 +1,162 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ExportWorkspace } from '../../src/renderer/src/components/export/ExportWorkspace'
import { ExportTaskCenter } from '../../src/renderer/src/components/export/ExportTaskCenter'
import type { ExportTaskRecord } from '../../src/shared/export'
import type { Contact, Message } from '../../src/shared/types'
const contacts: Contact[] = Array.from({ length: 6 }, (_, index) => ({
md5: `contact-${index + 1}`,
m_nsUsrName: `wxid_contact_${index + 1}`,
m_nsNickName: `聊天 ${String.fromCharCode(65 + index)}`,
type: index === 2 ? 'group' : 'user'
}))
const previewMessage = (contact: Contact): Message => ({
id: `preview-${contact.md5}`,
from: 'user',
type: '普通文本',
datetime: '',
content: `${contact.m_nsNickName} 的预览`,
isSender: false,
createTime: contacts.indexOf(contact) + 1
})
describe('ExportWorkspace multi-chat selection', () => {
beforeEach(() => {
Object.defineProperty(window, 'api', {
configurable: true,
value: {
onExportProgress: vi.fn(() => vi.fn()),
getGroupSnapshot: vi.fn(async () => ({ members: [] })),
cancelExport: vi.fn(async () => ({ success: true })),
revealExport: vi.fn(async () => ({ success: true }))
}
})
})
const renderWorkspace = (
onStartExport = vi.fn(async () => ({ success: false }))
): { loadPreviewMessages: ReturnType<typeof vi.fn> } => {
const loadPreviewMessages = vi.fn(async (contact: Contact) => [previewMessage(contact)])
render(
<ExportWorkspace
contacts={contacts}
initialContact={contacts[0]}
selfInfo={{ wxid: 'self', nickname: '本人', accountRoot: '/fixture' }}
dbReady
loadPreviewMessages={loadPreviewMessages}
onOpenSettings={vi.fn()}
exportTasks={[]}
onStartExport={onStartExport}
onCancelExport={vi.fn(async () => undefined)}
/>
)
return { loadPreviewMessages }
}
it('defaults to one chat, forces HTML after adding another, merges the preview, and resets locally', async () => {
const onStartExport = vi.fn(async () => ({ success: false }))
const { loadPreviewMessages } = renderWorkspace(onStartExport)
expect(screen.getAllByText('聊天 A')).toHaveLength(2)
expect(screen.getByRole('button', { name: 'CSV' })).toBeEnabled()
expect(await screen.findByText('聊天 A 的预览')).toBeVisible()
await userEvent.click(screen.getByRole('button', { name: '+ 添加聊天' }))
await userEvent.click(screen.getByRole('button', { name: /聊天 B/ }))
expect(screen.getByText('已选 2 / 5 个')).toBeVisible()
expect(screen.getByRole('button', { name: 'CSV' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'JSON' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Markdown' })).toBeDisabled()
expect(screen.getByRole('button', { name: /HTML/ })).toHaveClass('active')
expect(await screen.findByText('聊天 B 的预览')).toBeVisible()
expect(screen.getByText('2 个聊天 · 合并预览')).toBeVisible()
await userEvent.click(screen.getByRole('button', { name: '开始导出' }))
await waitFor(() => expect(onStartExport).toHaveBeenCalledOnce())
expect(onStartExport.mock.calls[0][0]).toMatchObject({
format: 'html',
outputName: '聊天 A等2个聊天_合并档案',
targets: [
{ userMd5: 'contact-1', name: '聊天 A' },
{ userMd5: 'contact-2', name: '聊天 B' }
]
})
await userEvent.click(screen.getByRole('button', { name: '恢复默认' }))
expect(screen.queryByText('已选 2 / 5 个')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'CSV' })).toBeEnabled()
expect(screen.getAllByText('聊天 A').length).toBeGreaterThanOrEqual(2)
expect(loadPreviewMessages).toHaveBeenCalledWith(contacts[0])
expect(contacts[0].md5).toBe('contact-1')
})
it('does not allow removing the last chat and disables unselected chats at five', async () => {
renderWorkspace()
await userEvent.click(screen.getByRole('button', { name: '+ 添加聊天' }))
await userEvent.click(screen.getByRole('button', { name: /聊天 A/ }))
expect(screen.getByText('已选 1 / 5 个')).toBeVisible()
for (const name of ['聊天 B', '聊天 C', '聊天 D', '聊天 E']) {
await userEvent.click(screen.getByRole('button', { name: new RegExp(name) }))
}
expect(screen.getByText('已选 5 / 5 个')).toBeVisible()
expect(screen.getByRole('button', { name: /聊天 F/ })).toBeDisabled()
await userEvent.click(screen.getByRole('button', { name: /聊天 B/ }))
expect(screen.getByText('已选 4 / 5 个')).toBeVisible()
expect(screen.getByRole('button', { name: /聊天 F/ })).toBeEnabled()
})
})
describe('ExportTaskCenter details', () => {
it('shows the exported message count for success and the reason for failure', () => {
const tasks: ExportTaskRecord[] = [
{
jobId: 'success',
targetIds: ['contact-1'],
targetNames: ['聊天 A'],
targetLabel: '聊天 A',
format: 'html',
status: 'completed',
progress: {
jobId: 'success',
phase: 'completed',
processed: 125,
total: 125,
percent: 100
},
createdAt: 1
},
{
jobId: 'failure',
targetIds: ['contact-1', 'contact-2'],
targetNames: ['聊天 A', '聊天 B'],
targetLabel: '聊天 A 等 2 个聊天',
format: 'html',
status: 'failed',
progress: {
jobId: 'failure',
phase: 'failed',
processed: 0,
percent: 0,
error: '视频文件没有写入权限'
},
createdAt: 2
}
]
render(
<ExportTaskCenter open taskCount={0} tasks={tasks} onToggle={vi.fn()} onCancel={vi.fn()} />
)
expect(screen.getByText('HTML · 已完成')).toBeVisible()
expect(screen.getByText('成功导出 125 条消息')).toBeVisible()
expect(screen.getByText('HTML · 导出失败')).toBeVisible()
expect(screen.getByText('失败原因:视频文件没有写入权限')).toBeVisible()
})
})
+32
View File
@@ -114,6 +114,38 @@ test('NAV-01 NAV-02 every top-level page is unique and switchable', async () =>
}
})
test('EXPORT-01 multi-chat selection stays local to export and forces HTML', async () => {
const fixture = await launchTestApp()
try {
const navigation = fixture.page.getByRole('navigation', { name: '一级导航' })
await fixture.page.getByRole('button', { name: '联系人 (1)' }).click()
await fixture.page.getByText('文件传输助手', { exact: true }).click()
await expect(fixture.page.getByText('转发多条内容', { exact: true })).toBeVisible()
await navigation.getByRole('button', { name: '导出' }).click()
const contactList = fixture.page.locator('.export-contact-list')
await expect(contactList.getByRole('button', { name: /文件传输助手/ })).toHaveAttribute(
'aria-pressed',
'true'
)
await fixture.page.getByRole('button', { name: '+ 添加聊天' }).click()
await contactList.getByRole('button', { name: /产品测试群/ }).click()
await expect(fixture.page.getByText('已选 2 / 5 个')).toBeVisible()
await expect(fixture.page.getByRole('button', { name: 'CSV' })).toBeDisabled()
await expect(fixture.page.getByRole('button', { name: /HTML/ })).toHaveClass(/active/)
await expect(fixture.page.getByText('文件传输助手、产品测试群 · 共 2 个聊天')).toBeVisible()
await expect(
fixture.page.locator('.export-preview-bubble').filter({ hasText: '这是一条脱敏测试消息' })
).toHaveCount(1)
await navigation.getByRole('button', { name: '档案' }).click()
await expect(fixture.page.getByText('转发多条内容', { exact: true })).toBeVisible()
} finally {
await fixture.close()
}
})
test('ARCH-01 ARCH-02 folded chats and supported message types are represented explicitly', async () => {
const fixture = await launchTestApp()
try {
+830
View File
@@ -0,0 +1,830 @@
import { expect, test } from '@playwright/test'
import { execFileSync } from 'child_process'
import { createWriteStream, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { dirname, join } from 'path'
import { pathToFileURL } from 'url'
import { ZipArchive } from 'archiver'
import { renderExportPage } from '../../src/main/export-html-template'
import type { Message } from '../../src/shared/types'
const archiveMessage = (
id: string,
conversationId: string,
conversationName: string,
content: string,
createTime: number
): Message => ({
id,
from: 'user',
type: '普通文本',
datetime: '',
content,
isSender: false,
name: '脱敏成员',
createTime,
exportConversationId: conversationId,
exportConversationName: conversationName
})
const zipDirectory = async (
sourceDir: string,
zipPath: string,
folderName: string
): Promise<void> => {
const output = createWriteStream(zipPath)
const archive = new ZipArchive({ zlib: { level: 6 } })
await new Promise<void>((resolve, reject) => {
output.on('close', resolve)
output.on('error', reject)
archive.on('error', reject)
archive.pipe(output)
archive.directory(sourceDir, folderName)
void archive.finalize().catch(reject)
})
}
test('EXPORT-ARCHIVE-00 shows a loading state while archive data is still loading', async ({
page
}, testInfo) => {
let releaseData!: () => void
const dataReady = new Promise<void>((resolve) => {
releaseData = resolve
})
await page.route('http://archive.test/**', async (route) => {
if (route.request().url().endsWith('/data/messages.js')) {
await dataReady
await route.fulfill({
contentType: 'application/javascript',
body: `window.__WECHAT_EXPORT__ = ${JSON.stringify({
version: 1,
sourceId: 'loading-fixture',
name: '大量消息',
exportedAt: '2026-08-05T00:00:00.000Z',
messages: [
archiveMessage('loading-1', 'loading-fixture', '大量消息', '加载完成', 1_767_225_600)
]
})};`
})
return
}
await route.fulfill({
contentType: 'text/html',
body: renderExportPage('大量消息')
})
})
await page.setViewportSize({ width: 1440, height: 900 })
const navigation = page.goto('http://archive.test/index.html')
const loading = page.locator('#archive-loading')
await expect(loading).toBeVisible()
await expect(loading).toContainText('正在加载聊天档案')
await expect(loading).toHaveAttribute('aria-busy', 'true')
await page.screenshot({ path: testInfo.outputPath('archive-loading-1440.png') })
await page.setViewportSize({ width: 390, height: 844 })
await expect(loading).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('archive-loading-390.png') })
releaseData()
await navigation
await expect(loading).toBeHidden()
await expect(page.getByText('加载完成')).toBeVisible()
})
test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobile', async ({
page
}, testInfo) => {
const fixtureRoot = mkdtempSync(join(tmpdir(), 'wxe-merged-archive-e2e-'))
const outputDir = join(fixtureRoot, 'source')
try {
const dataPath = join(outputDir, 'data', 'messages.js')
mkdirSync(dirname(dataPath), { recursive: true })
writeFileSync(join(outputDir, 'index.html'), renderExportPage('合并聊天档案'), 'utf8')
writeFileSync(
dataPath,
`window.__WECHAT_EXPORT__ = ${JSON.stringify({
version: 2,
name: '合并聊天档案',
exportedAt: '2026-08-04T00:00:00.000Z',
conversations: [
{
id: 'alpha',
name: '项目群',
type: 'group',
avatarUrl:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Cpath fill='%23176b57' d='M0 0h10v10H0z'/%3E%3C/svg%3E",
messageCount: 3
},
{
id: 'beta',
name: '文件传输助手',
type: 'user',
avatarUrl:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Cpath fill='%23d9f0e2' d='M0 0h10v10H0z'/%3E%3C/svg%3E",
messageCount: 1
}
],
messages: [
archiveMessage('alpha-1', 'alpha', '项目群', '项目群第一条', 1_764_547_200),
archiveMessage('beta-1', 'beta', '文件传输助手', '个人聊天消息', 1_769_904_000),
archiveMessage('alpha-2', 'alpha', '项目群', '项目群第二条', 1_769_990_400),
{
...archiveMessage(
'alpha-sent',
'alpha',
'Jamie',
'那边多少度呀 热不,这是用于验证移动端右侧头像不会被裁切的消息',
1_775_315_283
),
isSender: true,
name: 'Nanin'
}
]
})};\n`,
'utf8'
)
const zipPath = join(fixtureRoot, 'merged-archive.zip')
const extractedDir = join(fixtureRoot, 'extracted')
await zipDirectory(outputDir, zipPath, '合并聊天档案')
mkdirSync(extractedDir, { recursive: true })
execFileSync('unzip', ['-q', zipPath, '-d', extractedDir])
const offlineIndex = join(extractedDir, '合并聊天档案', 'index.html')
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(pathToFileURL(offlineIndex).href)
const conversationTrigger = page.getByRole('button', { name: '筛选聊天' })
const conversationMenu = page.getByRole('listbox', { name: '选择聊天' })
const chooseConversation = async (name: string): Promise<void> => {
await conversationTrigger.click()
await conversationMenu.getByRole('option', { name, exact: true }).click()
}
await expect(conversationTrigger).toHaveAttribute('aria-expanded', 'false')
await expect(conversationMenu).toBeHidden()
await expect(conversationTrigger.locator('.conversation-switch-icon')).toBeVisible()
await expect(
conversationTrigger.locator('.conversation-trigger-name + .conversation-switch-icon')
).toBeVisible()
await expect(conversationTrigger.locator('.conversation-chevron')).toHaveCount(0)
await expect(page.locator('#conversation-trigger-name')).toHaveText('全部聊天')
await expect(page.locator('#conversation-trigger-avatar img')).toHaveCount(2)
await expect(page.locator('#archive-title')).toBeHidden()
await expect(page.locator('.archive-heading #conversation-filter')).toBeVisible()
await expect(page.locator('#archive-meta')).toHaveCount(0)
await expect(page.locator('.message')).toHaveCount(4)
await expect(page.locator('.conversation-source')).toHaveCount(4)
const desktopSwitcherBounds = await page.evaluate(() => {
const trigger = document.querySelector('#conversation-trigger')!.getBoundingClientRect()
const name = document.querySelector('#conversation-trigger-name')!.getBoundingClientRect()
const icon = document.querySelector('.conversation-switch-icon')!.getBoundingClientRect()
return { triggerWidth: trigger.width, nameToIcon: icon.left - name.right }
})
expect(desktopSwitcherBounds.triggerWidth).toBeLessThan(200)
expect(desktopSwitcherBounds.nameToIcon).toBeLessThanOrEqual(12)
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
await conversationTrigger.click()
await expect(conversationTrigger).toHaveAttribute('aria-expanded', 'true')
await expect(conversationMenu).toBeVisible()
await expect(conversationMenu.getByRole('option')).toHaveCount(3)
await expect(conversationMenu.getByRole('option', { name: '全部聊天' })).toBeVisible()
await expect(conversationMenu.getByRole('option', { name: '项目群' })).toBeVisible()
await expect(conversationMenu.getByRole('option', { name: '文件传输助手' })).toBeVisible()
await expect(conversationMenu).not.toContainText('条消息')
await expect(conversationMenu.getByRole('option', { name: '全部聊天' })).toHaveAttribute(
'aria-selected',
'true'
)
await expect(conversationTrigger).toHaveCSS('border-top-width', '0px')
await page.screenshot({ path: testInfo.outputPath('conversation-menu-1440.png') })
await page.keyboard.press('Escape')
await expect(conversationMenu).toBeHidden()
await page.screenshot({ path: testInfo.outputPath('merged-archive-1440.png'), fullPage: true })
await chooseConversation('文件传输助手')
await expect(page.locator('.message')).toHaveCount(1)
await expect(page.locator('#conversation-trigger-name')).toHaveText('文件传输助手')
await expect(page.locator('#conversation-trigger-avatar img')).toHaveCount(1)
await expect(page.locator('.conversation-source')).toHaveCount(0)
await chooseConversation('全部聊天')
await page.setViewportSize({ width: 390, height: 844 })
await expect(page.locator('.timeline-year')).toHaveCount(2)
await expect(page.locator('.timeline-year').first()).toBeVisible()
await expect(page.locator('.timeline-year').first()).toHaveText('2025 年')
const positions = await page.evaluate(() => {
const conversations = document.querySelector('#conversation-filter')!.getBoundingClientRect()
const toolbar = document.querySelector('.toolbar')!.getBoundingClientRect()
const timeline = document.querySelector('#timeline')!.getBoundingClientRect()
return {
conversationTop: conversations.top,
conversationBottom: conversations.bottom,
toolbarTop: toolbar.top,
toolbarBottom: toolbar.bottom,
timelineTop: timeline.top,
documentWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth
}
})
expect(positions.conversationTop).toBeGreaterThanOrEqual(positions.toolbarTop)
expect(positions.conversationBottom).toBeLessThanOrEqual(positions.toolbarBottom)
expect(positions.timelineTop).toBeGreaterThanOrEqual(positions.toolbarBottom)
expect(positions.documentWidth).toBeLessThanOrEqual(positions.viewportWidth)
await expect(page.locator('.message')).toHaveCount(4)
const searchInput = page.getByLabel('搜索消息')
await expect(conversationTrigger).toBeVisible()
const compactControlBounds = await page.evaluate(() => {
const conversations = document.querySelector('#conversation-filter')!.getBoundingClientRect()
const search = document.querySelector('#query')!.getBoundingClientRect()
return {
conversationTop: conversations.top,
conversationBottom: conversations.bottom,
conversationWidth: conversations.width,
searchTop: search.top,
searchBottom: search.bottom,
searchWidth: search.width,
searchFontSize: getComputedStyle(document.querySelector('#query')!).fontSize
}
})
expect(
Math.abs(compactControlBounds.conversationTop - compactControlBounds.searchTop)
).toBeLessThanOrEqual(1)
expect(
Math.abs(compactControlBounds.conversationBottom - compactControlBounds.searchBottom)
).toBeLessThanOrEqual(1)
expect(compactControlBounds.searchWidth).toBeGreaterThan(compactControlBounds.conversationWidth)
expect(compactControlBounds.searchFontSize).toBe('16px')
await expect(conversationTrigger.locator('.conversation-switch-icon')).toHaveCSS(
'width',
'13px'
)
await conversationTrigger.click()
await expect(conversationMenu).toBeVisible()
const mobileMenuBounds = await conversationMenu.evaluate((element) => {
const bounds = element.getBoundingClientRect()
return { left: bounds.left, right: bounds.right, viewportWidth: window.innerWidth }
})
expect(mobileMenuBounds.left).toBeGreaterThanOrEqual(0)
expect(mobileMenuBounds.right).toBeLessThanOrEqual(mobileMenuBounds.viewportWidth)
await page.screenshot({ path: testInfo.outputPath('conversation-menu-390.png') })
await conversationMenu.getByRole('option', { name: '文件传输助手' }).click()
await expect(page.locator('.message')).toHaveCount(1)
await chooseConversation('全部聊天')
await expect(page.locator('.message')).toHaveCount(4)
await expect(searchInput).toBeVisible()
await searchInput.fill('个人聊天消息')
await expect(page.locator('.search-highlight')).toHaveText('个人聊天消息')
const mobileSearchResult = page.locator('.message')
const mobileLocateButton = mobileSearchResult.getByRole('button', {
name: '定位到聊天位置'
})
await mobileSearchResult.hover()
await expect(mobileLocateButton).toHaveCSS('opacity', '1')
await page.screenshot({
path: testInfo.outputPath('search-highlight-390.png'),
animations: 'disabled'
})
await mobileLocateButton.click()
await expect(searchInput).toHaveValue('')
await expect(page.locator('.message.located')).toContainText('个人聊天消息')
const mobileFilterButtons = page.locator('.filter-button:visible')
await expect(mobileFilterButtons).toHaveCount(7)
const filterButtonTops = await mobileFilterButtons.evaluateAll((buttons) =>
buttons.map((button) => button.getBoundingClientRect().top)
)
expect(Math.max(...filterButtonTops) - Math.min(...filterButtonTops)).toBeLessThanOrEqual(1)
const countTop = await page
.locator('#count')
.evaluate((element) => element.getBoundingClientRect().top)
const filterBottom = await mobileFilterButtons
.first()
.evaluate((element) => element.getBoundingClientRect().bottom)
expect(countTop).toBeGreaterThanOrEqual(filterBottom)
expect(positions.toolbarBottom - positions.toolbarTop).toBeLessThanOrEqual(150)
await page.getByRole('button', { name: '文字', exact: true }).click()
const messageList = page.locator('#messages')
const sentMessageBounds = await page.locator('.message.sent').evaluate((element) => {
const list = element.parentElement!.getBoundingClientRect()
const message = element.getBoundingClientRect()
const row = element.querySelector('.row')!.getBoundingClientRect()
const avatar = element.querySelector('.avatar')!.getBoundingClientRect()
return {
listLeft: list.left,
listRight: list.right,
messageLeft: message.left,
messageRight: message.right,
rowLeft: row.left,
rowRight: row.right,
avatarLeft: avatar.left,
avatarRight: avatar.right
}
})
expect(Math.abs(sentMessageBounds.rowLeft - sentMessageBounds.messageLeft)).toBeLessThanOrEqual(
1
)
expect(
Math.abs(sentMessageBounds.rowRight - sentMessageBounds.messageRight)
).toBeLessThanOrEqual(1)
expect(sentMessageBounds.rowLeft).toBeGreaterThanOrEqual(sentMessageBounds.listLeft)
expect(sentMessageBounds.rowRight).toBeLessThanOrEqual(sentMessageBounds.listRight)
expect(sentMessageBounds.avatarLeft).toBeGreaterThanOrEqual(sentMessageBounds.listLeft)
expect(sentMessageBounds.avatarRight).toBeLessThanOrEqual(sentMessageBounds.listRight)
const mobileScrollBehavior = await messageList.evaluate((element) => {
const styles = getComputedStyle(element)
return {
overflowX: styles.overflowX,
overscrollBehaviorX: styles.overscrollBehaviorX,
touchAction: styles.touchAction
}
})
expect(mobileScrollBehavior).toEqual({
overflowX: 'hidden',
overscrollBehaviorX: 'none',
touchAction: 'pan-y'
})
await messageList.hover()
await page.mouse.wheel(80, 120)
await expect.poll(() => messageList.evaluate((element) => element.scrollLeft)).toBe(0)
await page.screenshot({ path: testInfo.outputPath('merged-archive-390.png'), fullPage: true })
} finally {
rmSync(fixtureRoot, { recursive: true, force: true })
}
})
test('EXPORT-ARCHIVE-02 legacy single-chat archive keeps its original layout', async ({
page
}, testInfo) => {
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-single-archive-e2e-'))
try {
const dataPath = join(outputDir, 'data', 'messages.js')
mkdirSync(dirname(dataPath), { recursive: true })
writeFileSync(join(outputDir, 'index.html'), renderExportPage('单聊天档案'), 'utf8')
writeFileSync(
dataPath,
`window.__WECHAT_EXPORT__ = ${JSON.stringify({
version: 1,
sourceId: 'single',
name: '单聊天档案',
exportedAt: '2026-08-04T00:00:00.000Z',
messages: [archiveMessage('single-1', 'single', '单聊天档案', '单聊天消息', 1_767_225_600)]
})};\n`,
'utf8'
)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(pathToFileURL(join(outputDir, 'index.html')).href)
await expect(page.locator('#conversation-filter')).toBeHidden()
await expect(page.locator('#archive-title')).toBeVisible()
await expect(page.locator('#archive-title')).toHaveText('单聊天档案')
await expect(page.locator('#archive-meta')).toHaveCount(0)
await expect(page.locator('.archive-layout')).toHaveClass(/single-conversation/)
await expect(page.locator('.message')).toHaveCount(1)
await page.screenshot({ path: testInfo.outputPath('single-archive-1440.png'), fullPage: true })
await page.setViewportSize({ width: 390, height: 844 })
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
await expect(page.locator('.message')).toHaveCount(1)
await page.screenshot({ path: testInfo.outputPath('single-archive-390.png'), fullPage: true })
} finally {
rmSync(outputDir, { recursive: true, force: true })
}
})
test('EXPORT-ARCHIVE-04 timeline follows the latest visible month after changing tabs', async ({
page
}, testInfo) => {
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-timeline-sync-e2e-'))
try {
const dataPath = join(outputDir, 'data', 'messages.js')
mkdirSync(dirname(dataPath), { recursive: true })
writeFileSync(join(outputDir, 'index.html'), renderExportPage('时间轴同步档案'), 'utf8')
const oldVoiceMessages = Array.from({ length: 240 }, (_, index) => ({
...archiveMessage(
`old-voice-${index}`,
'timeline',
'时间轴同步档案',
`旧语音-${index}`,
Date.UTC(2006 + Math.floor(index / 12), index % 12, 1) / 1000
),
type: '语音'
}))
writeFileSync(
dataPath,
`window.__WECHAT_EXPORT__ = ${JSON.stringify({
version: 1,
sourceId: 'timeline',
name: '时间轴同步档案',
exportedAt: '2026-08-04T00:00:00.000Z',
messages: [
...oldVoiceMessages,
{
...archiveMessage(
'latest-voice',
'timeline',
'时间轴同步档案',
'最新语音',
1_775_520_000
),
type: '语音'
}
]
})};\n`,
'utf8'
)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(pathToFileURL(join(outputDir, 'index.html')).href)
await page.getByRole('button', { name: '语音', exact: true }).click()
const messages = page.locator('#messages')
const activeMonth = page.locator('.timeline-month.active')
await expect(activeMonth).toHaveAttribute('data-month', '2026-04')
const expandedYear = page.locator('.timeline-year[aria-expanded="true"]')
const latestYear = page.locator('.timeline-year[data-year="2026"]')
await expect(expandedYear).toHaveCount(1)
await expect(expandedYear).toHaveText('2026 年')
await expect(page.locator('.timeline-month:visible')).toHaveCount(1)
await latestYear.click()
await expect(latestYear).toHaveAttribute('aria-expanded', 'false')
await expect(page.locator('.timeline-month:visible')).toHaveCount(0)
await latestYear.click()
await expect(latestYear).toHaveAttribute('aria-expanded', 'true')
await expect(page.locator('.timeline-month:visible')).toHaveCount(1)
await expect(page.locator('#archive-loading')).toBeHidden()
await page.screenshot({ path: testInfo.outputPath('timeline-collapsed-1440.png') })
expect(
await messages.evaluate(
(element) => element.scrollHeight - element.scrollTop - element.clientHeight
)
).toBeLessThanOrEqual(2)
const timelinePosition = await activeMonth.evaluate((element) => {
const button = element.getBoundingClientRect()
const timeline = element.parentElement!.getBoundingClientRect()
return {
buttonTop: button.top,
buttonBottom: button.bottom,
timelineTop: timeline.top,
timelineBottom: timeline.bottom
}
})
expect(timelinePosition.buttonTop).toBeGreaterThanOrEqual(timelinePosition.timelineTop)
expect(timelinePosition.buttonBottom).toBeLessThanOrEqual(timelinePosition.timelineBottom + 1)
const selectedYear = page.locator('.timeline-year[data-year="2020"]')
await selectedYear.click()
await expect(expandedYear).toHaveText('2020 年')
await expect(selectedYear).toHaveAttribute('aria-expanded', 'true')
await expect(page.locator('.timeline-year[data-year="2026"]')).toHaveAttribute(
'aria-expanded',
'false'
)
await expect(page.locator('.timeline-month:visible')).toHaveCount(12)
const selectedMonth = page.locator('.timeline-month[data-month="2020-07"]')
await selectedMonth.click()
await expect(selectedMonth).toHaveClass(/active/)
await expect(activeMonth).toHaveAttribute('data-month', '2020-07')
const visibleMonths = await messages.evaluate((element) => {
const bounds = element.getBoundingClientRect()
const anchor = bounds.top + Math.min(24, bounds.height / 4)
const items = Array.from(element.querySelectorAll<HTMLElement>('.message'))
return {
firstVisible: items.find((item) => item.getBoundingClientRect().bottom > bounds.top)
?.dataset.month,
firstAnchored: items.find((item) => item.getBoundingClientRect().bottom > anchor)?.dataset
.month
}
})
expect(visibleMonths).toEqual({ firstVisible: '2020-06', firstAnchored: '2020-07' })
await messages.evaluate((element) => {
element.scrollTop = 0
})
await expect(activeMonth).toHaveAttribute('data-month', '2006-01')
await expect(expandedYear).toHaveText('2006 年')
await expect(page.locator('.timeline-month:visible')).toHaveCount(12)
await page.setViewportSize({ width: 390, height: 844 })
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
const mobileLayoutBounds = await page.evaluate(() => {
const layout = document.querySelector('.archive-layout')!.getBoundingClientRect()
const messages = document.querySelector('#messages')!.getBoundingClientRect()
return {
viewportWidth: window.innerWidth,
layoutLeft: layout.left,
layoutRight: layout.right,
messagesLeft: messages.left,
messagesRight: messages.right
}
})
expect(mobileLayoutBounds.layoutLeft).toBeGreaterThanOrEqual(0)
expect(mobileLayoutBounds.layoutRight).toBeLessThanOrEqual(mobileLayoutBounds.viewportWidth)
expect(mobileLayoutBounds.messagesLeft).toBeGreaterThanOrEqual(0)
expect(mobileLayoutBounds.messagesRight).toBeLessThanOrEqual(mobileLayoutBounds.viewportWidth)
await expect(expandedYear).toHaveText('2006 年')
await page.screenshot({ path: testInfo.outputPath('timeline-collapsed-390.png') })
} finally {
rmSync(outputDir, { recursive: true, force: true })
}
})
test('EXPORT-ARCHIVE-05 each message tab restores its previous scroll anchor', async ({ page }) => {
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-tab-position-e2e-'))
try {
const dataPath = join(outputDir, 'data', 'messages.js')
mkdirSync(dirname(dataPath), { recursive: true })
writeFileSync(join(outputDir, 'index.html'), renderExportPage('Tab 位置档案'), 'utf8')
const messages = Array.from({ length: 600 }, (_, index) => ({
...archiveMessage(
`message-${index}`,
'tab-position',
'Tab 位置档案',
`${index % 2 === 0 ? '文字' : '语音'}消息-${index}`,
1_735_689_600 + index * 86_400
),
type: index % 2 === 0 ? '普通文本' : '语音'
}))
writeFileSync(
dataPath,
`window.__WECHAT_EXPORT__ = ${JSON.stringify({
version: 1,
sourceId: 'tab-position',
name: 'Tab 位置档案',
exportedAt: '2026-08-04T00:00:00.000Z',
messages
})};\n`,
'utf8'
)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(pathToFileURL(join(outputDir, 'index.html')).href)
await page.getByRole('button', { name: '文字', exact: true }).click()
const messageList = page.locator('#messages')
const target = page.locator('.message[data-index="100"]')
await target.evaluate((element) => {
const list = element.parentElement!
list.scrollTop += element.getBoundingClientRect().top - list.getBoundingClientRect().top - 37
})
await expect(target).toBeInViewport()
const before = await target.evaluate((element) => {
const message = element.getBoundingClientRect()
const list = element.parentElement!.getBoundingClientRect()
return message.top - list.top
})
await page.getByRole('button', { name: '语音', exact: true }).click()
await page.getByRole('button', { name: '文字', exact: true }).click()
await expect(target).toBeInViewport()
const after = await target.evaluate((element) => {
const message = element.getBoundingClientRect()
const list = element.parentElement!.getBoundingClientRect()
return message.top - list.top
})
expect(Math.abs(after - before)).toBeLessThanOrEqual(1)
expect(
await messageList.evaluate(
(element) => element.scrollHeight - element.scrollTop - element.clientHeight
)
).toBeGreaterThan(100)
} finally {
rmSync(outputDir, { recursive: true, force: true })
}
})
test('EXPORT-ARCHIVE-03 renders shares and locations, and groups payments under system', async ({
page
}, testInfo) => {
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-structured-archive-e2e-'))
try {
const dataPath = join(outputDir, 'data', 'messages.js')
mkdirSync(dirname(dataPath), { recursive: true })
writeFileSync(join(outputDir, 'index.html'), renderExportPage('结构化消息档案'), 'utf8')
const message = (
id: string,
type: string,
createTime: number,
contentData: Message['contentData']
): Message => ({
...archiveMessage(id, 'structured', '结构化消息档案', '', createTime),
type,
contentData
})
writeFileSync(
dataPath,
`window.__WECHAT_EXPORT__ = ${JSON.stringify({
version: 1,
sourceId: 'structured',
name: '结构化消息档案',
exportedAt: '2026-08-04T12:57:32.000Z',
messages: [
message('article', '公众号链接', 1_775_000_001, {
type: 'share',
typeVal: '5',
title: '真正的公众号标题',
des: '文章摘要与关键内容',
appname: '示例公众号',
url: 'https://example.com/article?a=1&amp;b=2'
}),
message('mini', '小程序', 1_775_000_002, {
type: 'miniProgram',
title: '小程序商品标题',
description: '商品的真实描述',
appName: '示例小程序'
}),
message('channel', '视频号', 1_775_000_003, {
type: 'share',
typeVal: '51',
title: '当前微信版本不支持展示该内容,请升级至最新版本。',
des: '视频号真实标题\n视频号正文内容',
url: 'https://example.com/channel'
}),
message('forward', '合并转发', 1_775_000_004, {
type: 'forwardBundle',
title: '项目群的聊天记录',
description: '项目成员: 项目结论',
items: [
{
messageType: 1,
sender: '项目成员',
sentAt: '2026-08-04 20:00',
text: '项目结论已经确认'
}
]
}),
message('red-packet', '微信红包', 1_775_000_005, {
type: 'redPacket',
title: '微信红包',
description: '我给你发了一个红包'
}),
message('transfer', '转账', 1_775_000_006, {
type: 'share',
typeVal: '2000',
title: '微信转账',
des: '收到转账¥1000.00元',
url: ''
}),
message('voip', '通话', 1_775_000_007, {
type: 'voip',
status: '通话时长 2分15秒'
}),
message('location', '位置', 1_775_000_008, {
type: 'location',
poiname: '望和公园南园',
label: '北京市朝阳区望京街道北四环东路41号望和公园',
lat: 39.986984,
lng: 116.448578
}),
{
...message('legacy-recall', '系统消息', 1_775_000_009, {
type: 'system',
content: '"联系人" 撤回了一条消息'
}),
from: 'system',
content: '"联系人" 撤回了一条消息',
name: ''
},
{
...message('structured-recall', '系统消息', 1_775_000_010, {
type: 'system',
content: '你撤回了一条消息',
recall: {
targetId: 'fixture-target',
replacement: '你撤回了一条消息',
actor: '你'
}
}),
from: 'system',
content: '你撤回了一条消息',
name: ''
},
{
...message('location-sharing-ended', '系统消息', 1_775_000_011, {
type: 'system',
content: '位置共享已经结束'
}),
from: 'system',
content: '位置共享已经结束',
name: ''
}
]
})};\n`,
'utf8'
)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(pathToFileURL(join(outputDir, 'index.html')).href)
await expect(page.locator('[data-rich-kind="share"]')).toHaveCount(2)
await expect(page.getByText('真正的公众号标题')).toBeVisible()
await expect(page.getByText('文章摘要与关键内容')).toBeVisible()
await expect(page.getByText('小程序商品标题')).toBeVisible()
await expect(page.getByText('视频号真实标题')).toBeVisible()
await expect(page.getByText('当前微信版本不支持展示该内容,请升级至最新版本。')).toHaveCount(0)
await expect(page.getByText('项目群的聊天记录')).toBeVisible()
await page.getByText('展开 1 条消息').click()
await expect(page.getByText('项目结论已经确认')).toBeVisible()
const searchInput = page.getByLabel('搜索消息')
await searchInput.fill('真正的公众号标题')
await expect(page.locator('.message')).toHaveCount(1)
await expect(page.locator('.search-highlight')).toHaveText('真正的公众号标题')
const searchResult = page.locator('.message')
await searchResult.hover()
await page.screenshot({
path: testInfo.outputPath('search-highlight-1440.png'),
animations: 'disabled'
})
await searchResult.getByRole('button', { name: '定位到聊天位置' }).click()
await expect(searchInput).toHaveValue('')
await expect(page.getByRole('button', { name: '全部', exact: true })).toHaveClass(/active/)
await expect(page.locator('.search-highlight')).toHaveCount(0)
await expect(page.locator('.message.located')).toContainText('真正的公众号标题')
await page.getByRole('button', { name: '分享', exact: true }).click()
await expect(page.locator('.message')).toHaveCount(5)
await expect(page.locator('[data-rich-kind="forwardBundle"]')).toHaveCount(1)
const locationCard = page.locator('[data-rich-kind="location"]')
await expect(locationCard).toHaveCount(1)
await expect(locationCard.getByText('望和公园南园')).toBeVisible()
await expect(locationCard.getByText('北京市朝阳区望京街道北四环东路41号望和公园')).toBeVisible()
await expect(locationCard.getByText('39.986984, 116.448578')).toBeVisible()
await expect(locationCard.getByText('在地图中打开')).toBeVisible()
await expect(locationCard.locator('xpath=ancestor::a')).toHaveAttribute(
'href',
/^https:\/\/maps\.apple\.com\/\?q=.*&ll=39\.986984,116\.448578$/
)
await expect(page.locator('.content', { hasText: '[位置]' })).toHaveCount(0)
await expect(page.locator('[data-rich-kind="transfer"]')).toHaveCount(0)
const locationMessage = locationCard.locator('xpath=ancestor::article')
const locateButton = locationMessage.getByRole('button', { name: '定位到聊天位置' })
const locateLabel = locateButton.locator('.locate-label')
await page.mouse.move(0, 0)
await expect(locateButton).toHaveCSS('opacity', '0')
await locationMessage.hover()
await expect(locateButton).toHaveCSS('opacity', '1')
await expect(locateLabel).toHaveCSS('opacity', '0')
await locateButton.hover()
await expect(locateLabel).toHaveCSS('opacity', '1')
await page.screenshot({ path: testInfo.outputPath('locate-hover-1440.png') })
await locateButton.click()
await expect(page.getByRole('button', { name: '全部', exact: true })).toHaveClass(/active/)
await expect(page.locator('.message.located')).toContainText('望和公园南园')
const locatedPosition = await page.locator('.message.located').evaluate((element) => {
const messageRect = element.getBoundingClientRect()
const listRect = element.parentElement!.getBoundingClientRect()
return {
messageTop: messageRect.top,
messageBottom: messageRect.bottom,
listTop: listRect.top,
listBottom: listRect.bottom
}
})
expect(locatedPosition.messageBottom).toBeGreaterThan(locatedPosition.listTop)
expect(locatedPosition.messageTop).toBeLessThan(locatedPosition.listBottom)
await page.locator('#messages').evaluate((element) => {
element.scrollTop = 0
})
await page.screenshot({
path: testInfo.outputPath('structured-archive-1440.png'),
fullPage: true
})
await page.getByRole('button', { name: '系统', exact: true }).click()
await expect(page.locator('.message')).toHaveCount(6)
await expect(page.getByText('我给你发了一个红包')).toBeVisible()
await expect(page.getByText('收到转账¥1000.00元')).toBeVisible()
await expect(page.getByText('通话时长 2分15秒')).toBeVisible()
const systemNotices = page.locator('.message.system')
await expect(systemNotices).toHaveCount(3)
const recallNotices = systemNotices.filter({ hasText: '撤回了一条消息' })
await expect(recallNotices).toHaveCount(2)
await expect(recallNotices.locator('.avatar')).toHaveCount(0)
await expect(recallNotices.locator('.sender').first()).toBeHidden()
const locationNotice = systemNotices.filter({ hasText: '位置共享已经结束' })
await expect(locationNotice).toHaveCount(1)
await expect(locationNotice.locator('.avatar')).toHaveCount(0)
const recallAlignment = await recallNotices.first().evaluate((element) => {
const messageRect = element.getBoundingClientRect()
const bubbleRect = element.querySelector('.bubble')!.getBoundingClientRect()
return Math.abs(
messageRect.left + messageRect.width / 2 - (bubbleRect.left + bubbleRect.width / 2)
)
})
expect(recallAlignment).toBeLessThan(1)
await page.setViewportSize({ width: 390, height: 844 })
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
await page.screenshot({
path: testInfo.outputPath('structured-archive-390.png'),
fullPage: true
})
} finally {
rmSync(outputDir, { recursive: true, force: true })
}
})
+564 -66
View File
@@ -1,15 +1,39 @@
import { dirname, join } from 'path'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
unlinkSync,
utimesSync,
writeFileSync
} from 'fs'
import { tmpdir } from 'os'
import fsExtra from 'fs-extra'
import { execFileSync } from 'child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExportTarget } from '../../src/shared/export'
import type { Message } from '../../src/shared/types'
const state = vi.hoisted(() => ({
documents: '',
accountRoot: '',
videoPath: '',
selfAvatar: undefined as string | undefined,
avatarMap: {} as Record<string, string>,
messages: [] as Message[],
messagesByUser: {} as Record<string, Message[]>,
exportReads: [] as string[],
voiceLookups: [] as number[],
videoLookups: [] as {
createTime?: number
duration?: number
width?: number
height?: number
}[],
imageLookups: [] as {
allowThumbnail?: boolean
preferThumbnail?: boolean
@@ -22,18 +46,47 @@ const state = vi.hoisted(() => ({
vi.mock('electron', () => ({
app: { getPath: () => state.documents },
shell: { showItemInFolder: vi.fn() },
nativeImage: {
createFromBuffer: (buffer: Buffer) => {
const reversed = buffer.toString().includes('different-avatar')
const bitmap = Buffer.alloc(9 * 8 * 4)
for (let y = 0; y < 8; y += 1) {
for (let x = 0; x < 9; x += 1) {
const offset = (y * 9 + x) * 4
const value = reversed ? 240 - x * 20 : 40 + x * 20
bitmap[offset] = value
bitmap[offset + 1] = value
bitmap[offset + 2] = value
bitmap[offset + 3] = 255
}
}
return {
isEmpty: () => false,
resize: () => ({ toBitmap: () => bitmap })
}
}
},
BrowserWindow: class {}
}))
vi.mock('../../src/main/services/chat-service', () => ({
listMessages: () => structuredClone(state.messages),
listMessagesAsync: async () => structuredClone(state.messages),
listMessagesAsync: async (userMd5: string) =>
structuredClone(state.messagesByUser[userMd5] || state.messages),
listMessagesForExport: async (userMd5: string) => {
state.exportReads.push(userMd5)
return structuredClone(state.messagesByUser[userMd5] || state.messages)
},
getChatDb: () => ({
getWcdb4Client: () => ({ getAccountRoot: () => state.accountRoot })
getWcdb4Client: () => ({
getAccountRoot: () => state.accountRoot,
getUsernameByMd5: (userMd5: string) => `wxid_${userMd5}`
})
}),
getContactAvatars: () => ({}),
getContactAvatars: () => ({ ...state.avatarMap }),
getSelfAccountInfoAsync: async () => ({
wxid: 'a969409112',
nickname: '濑岛田井卫',
avatar: state.selfAvatar,
accountRoot: state.accountRoot
})
}))
@@ -50,6 +103,7 @@ vi.mock('../../src/main/voice-service', () => ({
_sessionId: string,
localId: number
): Promise<{ success: boolean; data?: string; error?: string }> {
state.voiceLookups.push(localId)
return localId === 1
? {
success: true,
@@ -107,7 +161,11 @@ vi.mock('../../src/main/image-decrypt-service', () => ({
}))
vi.mock('../../src/main/video-asset-service', () => ({
VideoAssetService: class {
resolve(): { success: boolean; url: string } {
resolve(
_hashes: string[],
options?: { createTime?: number; duration?: number; width?: number; height?: number }
): { success: boolean; url: string } {
state.videoLookups.push(options || {})
return { success: true, url: 'wxe-media://local/fixture-video' }
}
pathForUrl(): string {
@@ -130,17 +188,26 @@ const message = (overrides: Partial<Message>): Message => ({
...overrides
})
const readArchive = (outputPath: string): { sourceId: string; messages: Message[] } => {
const target = (userMd5 = 'fixture-user', name = '脱敏会话'): ExportTarget => ({
userMd5,
name,
type: 'user'
})
const readArchive = (
outputPath: string
): {
version: 2
conversations: { id: string; name: string; avatarUrl?: string; messageCount: number }[]
messages: Message[]
} => {
const source = readFileSync(join(dirname(outputPath), 'data', 'messages.js'), 'utf8')
return JSON.parse(
source
.slice(source.indexOf('=') + 1)
.trim()
.replace(/;\s*$/, '')
) as {
sourceId: string
messages: Message[]
}
)
}
describe('media export flow', () => {
@@ -148,11 +215,17 @@ describe('media export flow', () => {
state.documents = mkdtempSync(join(tmpdir(), 'wxe-export-fixture-'))
state.accountRoot = join(state.documents, 'fixture-account')
state.videoPath = join(state.documents, 'fixture.mp4')
state.selfAvatar = undefined
state.avatarMap = {}
writeFileSync(
state.videoPath,
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
)
state.imageLookups = []
state.videoLookups = []
state.messagesByUser = {}
state.exportReads = []
state.voiceLookups = []
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
mkdirSync(fileMonth, { recursive: true })
writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容')
@@ -180,7 +253,13 @@ describe('media export flow', () => {
message({
id: 'video',
type: '视频',
contentData: { type: 'video', md5: 'b'.repeat(32) }
contentData: {
type: 'video',
md5: 'b'.repeat(32),
duration: 68,
width: 279,
height: 630
}
}),
message({
id: 'file',
@@ -202,26 +281,16 @@ describe('media export flow', () => {
const result = await runExport(
{
jobId: 'fixture-export',
userMd5: 'fixture-user',
name: '脱敏会话',
targets: [target()],
format: 'html',
outputName: 'fixture',
kinds: ['voice', 'image', 'video', 'file'],
includeMedia: true,
includeVoiceTranscripts: true,
preferOriginal: true,
fallbackThumbnail: true,
keepMissing: true
},
win as never,
{
recognize: vi.fn().mockResolvedValue({
success: true,
transcript: '这是导出的固定语音转写',
language: 'zh',
cached: true
})
} as never
win as never
)
expect(result.success).toBe(true)
@@ -239,11 +308,11 @@ describe('media export flow', () => {
'ftyp'
)
expect(readFileSync(join(outputDir, file.exportMediaUrl!), 'utf8')).toBe('附件内容')
expect(html).toContain('<script src="data/messages.js"></script>')
expect(html).toContain("dataScript.src = 'data/messages.js'")
expect(html).toContain('id="archive-loading"')
expect(voice.voiceDataUrl).toMatch(/^voices\/voice_[0-9a-f]{16}\.wav$/)
expect(voice.voiceTranscript).toBe('这是导出的固定语音转写')
expect(video.exportMediaUrl).toMatch(/^media\/video_[0-9a-f]{16}\.mp4$/)
expect(file.exportMediaUrl).toMatch(/^media\/file_[0-9a-f]{16}_测试附件\.txt$/)
expect(file.exportMediaUrl).toMatch(/^files\/file_[0-9a-f]{16}_测试附件\.txt$/)
expect(missingVoice.exportMediaError).toBe('语音文件缺失:本地未找到语音数据')
expect(state.imageLookups[0]).toMatchObject({
allowThumbnail: false,
@@ -252,7 +321,76 @@ describe('media export flow', () => {
sessionMd5: 'fixture-user',
createTime: 1_785_549_600
})
expect(state.videoLookups[0]).toEqual({
createTime: 1_785_549_600,
duration: 68,
width: 279,
height: 630
})
expect(progress.length).toBeGreaterThan(0)
expect(state.exportReads).toEqual(['fixture-user'])
})
it('uses the customized file name as the HTML archive title', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
state.messages = [message({ id: 'custom-title', content: '标题测试' })]
const result = await runExport(
{
jobId: 'custom-title',
targets: [target('fixture-user', '联系人原名')],
format: 'html',
outputName: '我修改后的文件名',
kinds: ['text'],
includeMedia: false
},
win as never
)
expect(result.success).toBe(true)
const html = readFileSync(result.outputPath!, 'utf8')
const archive = readArchive(result.outputPath!)
expect(html).toContain('<title>我修改后的文件名 - 聊天记录</title>')
expect(html).toContain('<span class="title" id="archive-title">我修改后的文件名</span>')
expect(html).not.toContain('<title>联系人原名 - 聊天记录</title>')
expect(archive.name).toBe('我修改后的文件名')
expect(archive.conversations[0].name).toBe('联系人原名')
})
it('keeps one-to-one sender sides and fills both display names', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
state.messages = [
message({ id: 'peer-message', content: '对方消息', isSender: false, name: '', senderId: '' }),
message({ id: 'self-message', content: '我的消息', isSender: true, name: '', senderId: '' })
]
const result = await runExport(
{
jobId: 'one-to-one-identity',
targets: [target('jamie', 'Jamie')],
format: 'html',
outputName: 'one-to-one-identity',
kinds: ['text'],
includeMedia: false
},
win as never
)
expect(result.success, result.error).toBe(true)
const archive = readArchive(result.outputPath!)
expect(
archive.messages.map(({ id, isSender, name, senderId }) => ({
id,
isSender,
name,
senderId
}))
).toEqual([
{ id: 'peer-message', isSender: false, name: 'Jamie', senderId: 'wxid_jamie' },
{ id: 'self-message', isSender: true, name: '濑岛田井卫', senderId: 'a969409112' }
])
})
it('incrementally merges the same HTML archive, deduplicates messages, and keeps old media', async () => {
@@ -260,8 +398,7 @@ describe('media export flow', () => {
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
const request = {
jobId: 'incremental-first',
userMd5: 'fixture-user',
name: '增量会话',
targets: [target('fixture-user', '增量会话')],
format: 'html' as const,
outputName: 'incremental-fixture',
kinds: ['voice', 'text'] as const,
@@ -282,6 +419,19 @@ describe('media export flow', () => {
expect(first.success).toBe(true)
const firstArchive = readArchive(first.outputPath!)
const oldVoiceUrl = firstArchive.messages.find((item) => item.id === 'voice-old')!.voiceDataUrl
const outputDir = dirname(first.outputPath!)
firstArchive.messages.find((item) => item.id === 'voice-old')!.img =
'data:image/jpeg;base64,bGVnYWN5LWlubGluZS1hdmF0YXI='
writeFileSync(
join(outputDir, 'data', 'messages.js'),
`window.__WECHAT_EXPORT__ = ${JSON.stringify(firstArchive)};\n`,
'utf8'
)
writeFileSync(join(outputDir, 'voices', 'voice_orphan.wav'), 'orphan voice')
writeFileSync(join(outputDir, 'media', 'image_orphan.png'), 'orphan image')
writeFileSync(join(outputDir, 'media', 'file_orphan.txt'), 'legacy orphan file')
writeFileSync(join(outputDir, 'files', 'file_orphan.txt'), 'orphan file')
writeFileSync(join(outputDir, 'avatars', 'avatar_orphan.png'), 'orphan avatar')
state.messages = [
message({ id: 'text-old', content: '同一条消息已更新', createTime: 1_785_549_660 }),
@@ -310,53 +460,399 @@ describe('media export flow', () => {
expect(secondArchive.messages.find((item) => item.id === 'voice-old')?.voiceDataUrl).toBe(
oldVoiceUrl
)
expect(secondArchive.messages.every((item) => item.img == null)).toBe(true)
expect(existsSync(join(outputDir, oldVoiceUrl!))).toBe(true)
expect(existsSync(join(outputDir, 'voices', 'voice_orphan.wav'))).toBe(false)
expect(existsSync(join(outputDir, 'media', 'image_orphan.png'))).toBe(false)
expect(existsSync(join(outputDir, 'media', 'file_orphan.txt'))).toBe(false)
expect(existsSync(join(outputDir, 'files', 'file_orphan.txt'))).toBe(false)
expect(existsSync(join(outputDir, 'avatars', 'avatar_orphan.png'))).toBe(false)
expect(existsSync(join(dirname(second.outputPath!), 'data', 'messages.js.bak'))).toBe(true)
})
it('reuses existing video and file assets when Windows rejects an overwrite', async () => {
it('reuses unchanged resources and retries only missing or unresolved media', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
const request = {
userMd5: 'fixture-user',
name: '媒体复用会话',
targets: [target('fixture-user', '资源复用会话')],
format: 'html' as const,
outputName: 'reused-media-fixture',
kinds: ['video', 'file'] as const,
outputName: 'resource-reuse-fixture',
kinds: ['voice', 'image', 'video', 'file'] as const,
includeMedia: true,
keepMissing: true
}
const first = await runExport(
{ ...request, jobId: 'media-reuse-first', kinds: [...request.kinds] },
{ ...request, jobId: 'resource-reuse-first', kinds: [...request.kinds] },
win as never
)
expect(first.success).toBe(true)
const originalCopyFile = fsExtra.copyFile.bind(fsExtra)
const copyFile = vi
.spyOn(fsExtra, 'copyFile')
.mockRejectedValueOnce(
Object.assign(new Error('EPERM: operation not permitted, copyfile'), { code: 'EPERM' })
)
.mockRejectedValueOnce(
Object.assign(new Error('EPERM: operation not permitted, copyfile'), { code: 'EPERM' })
)
.mockImplementation(originalCopyFile)
expect(first.success, first.error).toBe(true)
const firstArchive = readArchive(first.outputPath!)
const outputDir = dirname(first.outputPath!)
const voicePath = join(
outputDir,
firstArchive.messages.find((item) => item.id === 'voice-ok')!.voiceDataUrl!
)
const imagePath = join(
outputDir,
firstArchive.messages.find((item) => item.id === 'image')!.exportMediaUrl!
)
const videoPath = join(
outputDir,
firstArchive.messages.find((item) => item.id === 'video')!.exportMediaUrl!
)
const filePath = join(
outputDir,
firstArchive.messages.find((item) => item.id === 'file')!.exportMediaUrl!
)
const oldTimestamp = new Date(1_000_000)
utimesSync(videoPath, oldTimestamp, oldTimestamp)
utimesSync(filePath, oldTimestamp, oldTimestamp)
const second = await runExport(
{ ...request, jobId: 'media-reuse-second', kinds: [...request.kinds] },
{ ...request, jobId: 'resource-reuse-second', kinds: [...request.kinds] },
win as never
)
copyFile.mockRestore()
expect(second.success, second.error).toBe(true)
expect(state.imageLookups).toHaveLength(1)
expect(state.videoLookups).toHaveLength(1)
expect(state.voiceLookups).toEqual([1, 2, 2])
expect(statSync(videoPath).mtimeMs).toBe(oldTimestamp.getTime())
expect(statSync(filePath).mtimeMs).toBe(oldTimestamp.getTime())
unlinkSync(voicePath)
unlinkSync(imagePath)
const third = await runExport(
{ ...request, jobId: 'resource-reuse-third', kinds: [...request.kinds] },
win as never
)
expect(third.success, third.error).toBe(true)
expect(state.imageLookups).toHaveLength(2)
expect(state.videoLookups).toHaveLength(1)
expect(state.voiceLookups).toEqual([1, 2, 2, 1, 2])
expect(existsSync(voicePath)).toBe(true)
expect(existsSync(imagePath)).toBe(true)
})
it('keeps historical avatars and creates a new version only after a real visual change', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
const encodedAvatar = (value: string): string =>
`data:image/jpeg;base64,${Buffer.from(value).toString('base64')}`
const request = {
targets: [target('fixture-user', '头像版本会话')],
format: 'html' as const,
outputName: 'avatar-version-fixture',
kinds: ['text'] as const,
includeMedia: false,
includeAvatars: true
}
const oldMessage = message({
id: 'avatar-old',
isSender: true,
senderId: 'a969409112',
content: '历史消息',
createTime: 1_785_549_600
})
const sameAvatarFirstEncoding = encodedAvatar('same-visual-encoding-one')
state.selfAvatar = sameAvatarFirstEncoding
state.avatarMap = { a969409112: sameAvatarFirstEncoding }
state.messages = [oldMessage]
const first = await runExport(
{ ...request, jobId: 'avatar-version-first', kinds: [...request.kinds] },
win as never
)
expect(first.success, first.error).toBe(true)
const firstAvatarUrl = readArchive(first.outputPath!).messages[0].exportAvatarUrl
const newMessageBeforeChange = message({
id: 'avatar-new-same',
isSender: true,
senderId: 'a969409112',
content: '头像未变时的新消息',
createTime: 1_785_549_700
})
const sameAvatarSecondEncoding = encodedAvatar('same-visual-encoding-two')
state.selfAvatar = sameAvatarSecondEncoding
state.avatarMap = { a969409112: sameAvatarSecondEncoding }
state.messages = [oldMessage, newMessageBeforeChange]
const second = await runExport(
{ ...request, jobId: 'avatar-version-second', kinds: [...request.kinds] },
win as never
)
expect(second.success, second.error).toBe(true)
expect(readArchive(second.outputPath!).messages.map((item) => item.exportAvatarUrl)).toEqual([
firstAvatarUrl,
firstAvatarUrl
])
const newMessageAfterChange = message({
id: 'avatar-new-changed',
isSender: true,
senderId: 'a969409112',
content: '真正换头像后的新消息',
createTime: 1_785_549_800
})
const changedAvatar = encodedAvatar('different-avatar')
state.selfAvatar = changedAvatar
state.avatarMap = { a969409112: changedAvatar }
state.messages = [oldMessage, newMessageBeforeChange, newMessageAfterChange]
const third = await runExport(
{ ...request, jobId: 'avatar-version-third', kinds: [...request.kinds] },
win as never
)
expect(third.success, third.error).toBe(true)
const thirdArchive = readArchive(third.outputPath!)
expect(thirdArchive.messages.slice(0, 2).map((item) => item.exportAvatarUrl)).toEqual([
firstAvatarUrl,
firstAvatarUrl
])
expect(thirdArchive.messages[2].exportAvatarUrl).not.toBe(firstAvatarUrl)
expect(
readdirSync(join(dirname(third.outputPath!), 'avatars')).filter((name) =>
name.startsWith('avatar_')
)
).toHaveLength(2)
})
it('keeps copied videos writable and can replace a legacy read-only video incrementally', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
chmodSync(state.videoPath, 0o444)
state.messages = [
message({
id: 'read-only-video',
type: '视频',
contentData: { type: 'video', md5: 'b'.repeat(32) }
})
]
const request = {
targets: [target('fixture-user', '只读视频会话')],
format: 'html' as const,
outputName: 'read-only-video-fixture',
kinds: ['video'] as const,
includeMedia: true
}
const first = await runExport(
{ ...request, jobId: 'read-only-video-first', kinds: [...request.kinds] },
win as never
)
expect(first.success, first.error).toBe(true)
const firstArchive = readArchive(first.outputPath!)
const videoPath = join(
dirname(first.outputPath!),
firstArchive.messages[0].exportMediaUrl as string
)
expect(statSync(videoPath).mode & 0o777).toBe(0o644)
chmodSync(videoPath, 0o444)
const second = await runExport(
{ ...request, jobId: 'read-only-video-second', kinds: [...request.kinds] },
win as never
)
expect(second.success, second.error).toBe(true)
expect(second.outputPath).toBe(first.outputPath)
expect(statSync(videoPath).mode & 0o777).toBe(0o644)
})
it('merges two conversations in stable order without colliding identical message ids or media', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
state.messagesByUser = {
alpha: [
message({
id: 'same-id',
type: '图片',
createTime: 100,
contentData: { type: 'image', md5: 'a'.repeat(32), datName: 'same.dat' }
}),
message({ id: 'alpha-later', content: 'A2', createTime: 200 })
],
beta: [
message({
id: 'same-id',
type: '图片',
createTime: 100,
contentData: { type: 'image', md5: 'a'.repeat(32), datName: 'same.dat' }
}),
message({ id: 'beta-later', content: 'B2', createTime: 150 })
]
}
const result = await runExport(
{
jobId: 'multi-conversation',
targets: [target('alpha', '聊天 A'), target('beta', '聊天 B')],
format: 'html',
outputName: 'multi-conversation',
kinds: ['text', 'image'],
includeMedia: true
},
win as never
)
expect(result.success).toBe(true)
const archive = readArchive(result.outputPath!)
expect(archive.version).toBe(2)
expect(archive.conversations.map(({ id, messageCount }) => ({ id, messageCount }))).toEqual([
{ id: 'alpha', messageCount: 2 },
{ id: 'beta', messageCount: 2 }
])
expect(
archive.messages.map((item) => [item.exportConversationId, item.id, item.createTime])
).toEqual([
['alpha', 'same-id', 100],
['beta', 'same-id', 100],
['beta', 'beta-later', 150],
['alpha', 'alpha-later', 200]
])
expect(state.exportReads).toEqual(['alpha', 'beta'])
const imagePaths = archive.messages
.filter((item) => item.id === 'same-id')
.map((item) => item.exportMediaUrl)
expect(new Set(imagePaths).size).toBe(1)
for (const imagePath of imagePaths) {
expect(existsSync(join(dirname(result.outputPath!), imagePath!))).toBe(true)
}
})
it('creates a replaceable ZIP containing the complete top-level archive folder', async () => {
const { runExport } = await import('../../src/main/export-service')
const progress: unknown[][] = []
const win = {
isDestroyed: () => false,
webContents: { send: (...args: unknown[]) => progress.push(args) }
}
state.messages = [
message({
id: 'zip-image',
type: '图片',
contentData: { type: 'image', md5: 'a'.repeat(32), datName: 'fixture.dat' }
})
]
const request = {
targets: [
{
...target('fixture-user', 'ZIP 会话'),
avatarUrl:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
}
],
format: 'html' as const,
outputName: 'zip-fixture',
kinds: ['image'] as const,
includeMedia: true,
includeAvatars: true,
zip: true
}
const first = await runExport(
{ ...request, jobId: 'zip-first', kinds: [...request.kinds] },
win as never
)
expect(first.success, first.error).toBe(true)
const firstSize = readFileSync(first.outputPath!).length
const second = await runExport(
{ ...request, jobId: 'zip-second', kinds: [...request.kinds] },
win as never
)
expect(first.success).toBe(true)
expect(second.success).toBe(true)
const archive = readArchive(second.outputPath!)
expect(archive.messages.find((item) => item.id === 'video')?.exportMediaUrl).toMatch(
/^media\/video_/
expect(second.outputPath).toBe(first.outputPath)
expect(firstSize).toBeGreaterThan(0)
expect(readFileSync(second.outputPath!).subarray(0, 2).toString()).toBe('PK')
const entries = execFileSync('unzip', ['-Z1', second.outputPath!], { encoding: 'utf8' })
const htmlPath = join(state.documents, 'WechatExplorer', '导出', 'zip-fixture', 'index.html')
const archive = readArchive(htmlPath)
expect(entries).toContain('zip-fixture/index.html')
expect(entries).toContain('zip-fixture/data/messages.js')
const avatarEntries = entries
.split('\n')
.filter((entry) => /zip-fixture\/avatars\/avatar_[0-9a-f]{16}\.png$/.test(entry))
expect(avatarEntries).toHaveLength(1)
expect(archive.conversations[0].avatarUrl).toBe(archive.messages[0].exportAvatarUrl)
expect(entries).toMatch(/zip-fixture\/media\/image_[0-9a-f]{16}\.png/)
expect(progress.some((args) => (args[1] as { phase?: string })?.phase === 'compressing')).toBe(
true
)
expect(archive.messages.find((item) => item.id === 'file')?.exportMediaUrl).toMatch(
/^media\/file_/
expect(
readdirSync(join(state.documents, 'WechatExplorer', '导出')).some((name) =>
name.startsWith('zip-fixture.zip.tmp-')
)
).toBe(false)
})
it('keeps the last complete ZIP when a replacement is cancelled during compression', async () => {
const { cancelExport, runExport } = await import('../../src/main/export-service')
state.messages = [message({ id: 'zip-cancel', content: '保留完整压缩包' })]
const request = {
targets: [target('fixture-user', '取消压缩会话')],
format: 'html' as const,
outputName: 'zip-cancel-fixture',
kinds: ['text'] as const,
includeMedia: false,
zip: true
}
const silentWindow = { isDestroyed: () => true, webContents: { send: vi.fn() } }
const first = await runExport(
{ ...request, jobId: 'zip-cancel-first', kinds: [...request.kinds] },
silentWindow as never
)
expect(first.success, first.error).toBe(true)
const completeZip = readFileSync(first.outputPath!)
const cancellingWindow = {
isDestroyed: () => false,
webContents: {
send: (_channel: string, progress: { phase: string }): void => {
if (progress.phase === 'compressing') cancelExport('zip-cancel-second')
}
}
}
const cancelled = await runExport(
{ ...request, jobId: 'zip-cancel-second', kinds: [...request.kinds] },
cancellingWindow as never
)
expect(cancelled).toEqual({ success: false, error: '已取消' })
expect(readFileSync(first.outputPath!)).toEqual(completeZip)
expect(
readdirSync(join(state.documents, 'WechatExplorer', '导出')).some((name) =>
name.startsWith('zip-cancel-fixture.zip.tmp-')
)
).toBe(false)
})
it('normalizes a legacy v1 single-chat archive into v2', async () => {
const { readHtmlArchive } = await import('../../src/main/export-service')
const outputDir = join(state.documents, 'legacy-archive')
mkdirSync(join(outputDir, 'data'), { recursive: true })
writeFileSync(
join(outputDir, 'data', 'messages.js'),
`window.__WECHAT_EXPORT__ = ${JSON.stringify({
version: 1,
sourceId: 'legacy-user',
name: '旧档案',
exportedAt: '2026-08-01T00:00:00.000Z',
messages: [message({ id: 'legacy-message', content: '旧消息' })]
})};\n`,
'utf8'
)
const archive = await readHtmlArchive(outputDir, [target('legacy-user', '旧档案')], '旧档案')
expect(archive.version).toBe(2)
expect(archive.conversations).toEqual([
expect.objectContaining({ id: 'legacy-user', name: '旧档案', messageCount: 1 })
])
expect(archive.messages[0]).toMatchObject({
id: 'legacy-message',
exportConversationId: 'legacy-user',
exportConversationName: '旧档案'
})
})
it('refuses to merge a different conversation into an existing named archive', async () => {
@@ -365,8 +861,7 @@ describe('media export flow', () => {
state.messages = [message({ id: 'text', content: 'fixture' })]
const baseRequest = {
jobId: 'source-first',
userMd5: 'first-user',
name: '第一个会话',
targets: [target('first-user', '第一个会话')],
format: 'html' as const,
outputName: 'same-name',
kinds: ['text'] as const,
@@ -377,8 +872,7 @@ describe('media export flow', () => {
{
...baseRequest,
jobId: 'source-second',
userMd5: 'second-user',
name: '第二个会话',
targets: [target('second-user', '第二个会话')],
kinds: [...baseRequest.kinds]
},
win as never
@@ -386,8 +880,10 @@ describe('media export flow', () => {
expect(first.success).toBe(true)
expect(second.success).toBe(false)
expect(second.error).toContain('另一个会话')
expect(readArchive(first.outputPath!).sourceId).toBe('first-user')
expect(second.error).toContain('聊天集合不同')
expect(readArchive(first.outputPath!).conversations.map((item) => item.id)).toEqual([
'first-user'
])
})
it('uses message content as the stable fallback when the database supplies a random id', async () => {
@@ -414,13 +910,16 @@ describe('media export flow', () => {
const result = await runExport(
{
jobId: 'self-name',
userMd5: 'fixture-user',
name: '本人昵称',
targets: [
{
...target('fixture-user', '本人昵称'),
nameMap: { a969409112: 'a969409112' }
}
],
format: 'html',
outputName: 'self-name',
kinds: ['text'],
includeMedia: false,
nameMap: { a969409112: 'a969409112' }
includeMedia: false
},
win as never
)
@@ -433,8 +932,7 @@ describe('media export flow', () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
const request = {
userMd5: 'fixture-user',
name: '增量昵称',
targets: [target('fixture-user', '增量昵称')],
format: 'html' as const,
outputName: 'incremental-self-name',
kinds: ['text'] as const,
+259 -4
View File
@@ -25,10 +25,16 @@ describe('export media', () => {
const html = renderExportPage('脱敏导出')
expect(EXPORT_PAGE_SIZE).toBe(240)
expect(html).toContain('<script src="data/messages.js"></script>')
expect(html).toContain("dataScript.src = 'data/messages.js'")
expect(html).toContain('id="archive-loading"')
expect(html).toContain('正在加载聊天档案')
expect(html).toContain('requestAnimationFrame(() => window.setTimeout(loadArchiveData, 0))')
expect(html).toContain('aria-label="聊天时间轴"')
expect(html).toContain('aria-expanded="')
expect(html).toContain('setExpandedTimelineYear')
expect(html).toContain('data-kind="media"')
expect(html).toContain('placeholder="搜索发送者或消息内容…"')
expect(html).toContain('font-size: 16px;')
expect(html).toContain('filtered.slice(windowStart, windowEnd)')
expect(html).toContain('windowStart = Math.max(0, windowEnd - PAGE_SIZE)')
expect(html).toContain('scheduleWindowSlide')
@@ -68,7 +74,7 @@ describe('export media', () => {
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(EXPORT_PAGE_SIZE)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 240 / 筛选结果 500 / 全部 500'
'已显示 240 / 筛选 500 / 全部 500'
)
const list = dom.window.document.querySelector('#messages')!
expect(list.querySelector('.message')?.getAttribute('data-index')).toBe('260')
@@ -90,8 +96,227 @@ describe('export media', () => {
search.value = 'needle'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(5)
expect(dom.window.document.querySelector('#count')?.textContent).toBe('筛选结果 5 / 全部 500')
expect(dom.window.document.querySelector('#count')?.textContent).toContain('筛选 5')
expect(dom.window.document.querySelectorAll('.search-highlight')).toHaveLength(5)
expect(dom.window.document.querySelectorAll('.locate-all')).toHaveLength(5)
expect(dom.window.document.querySelectorAll('.timeline-month').length).toBeGreaterThan(1)
expect(
dom.window.document.querySelectorAll('.timeline-year[aria-expanded="true"]')
).toHaveLength(1)
expect(dom.window.document.querySelectorAll('.timeline-months:not([hidden])')).toHaveLength(1)
dom.window.close()
})
it('does not match hidden sender ids when searching visible message text', () => {
const html = renderExportPage('搜索测试')
const dom = new JSDOM(html, { runScripts: 'outside-only' })
const messages: Message[] = [
{
id: 'hidden-sender-id-match',
from: 'user',
type: '普通文本',
datetime: '',
content: '这条消息不应命中',
name: 'Jamie',
senderId: 'wxid_fixture_member',
isSender: false,
createTime: 1_767_225_600
},
{
id: 'visible-content-match',
from: 'user',
type: '普通文本',
datetime: '',
content: 'https://example.com/xi',
name: 'Cherry',
senderId: 'wxid_fixture_self',
isSender: true,
createTime: 1_767_225_601
}
]
Object.assign(dom.window, {
__WECHAT_EXPORT__: {
version: 1,
sourceId: 'fixture',
name: '搜索测试',
exportedAt: '2026-08-05T00:00:00.000Z',
messages
}
})
dom.window.eval(inlineScriptOf(html))
const search = dom.window.document.querySelector('#query') as HTMLInputElement
search.value = 'xi'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1)
expect(dom.window.document.querySelector('.message')?.textContent).toContain(
'https://example.com/xi'
)
expect(dom.window.document.querySelectorAll('.search-highlight')).toHaveLength(1)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 1 / 筛选 1 / 全部 2'
)
dom.window.close()
})
it('filters a v2 merged archive by conversation before search and month counts', () => {
const html = renderExportPage('合并档案')
const dom = new JSDOM(html, { runScripts: 'outside-only' })
Object.assign(dom.window, {
__WECHAT_EXPORT__: {
version: 2,
name: '合并档案',
exportedAt: '2026-08-04T00:00:00.000Z',
conversations: [
{ id: 'alpha', name: '聊天 A', type: 'user', messageCount: 2 },
{ id: 'beta', name: '聊天 B', type: 'group', messageCount: 1 }
],
messages: [
messageForArchive('alpha-1', 'alpha', '聊天 A', '共同关键词', 1_767_225_600),
messageForArchive('beta-1', 'beta', '聊天 B', '共同关键词', 1_769_904_000),
messageForArchive('alpha-2', 'alpha', '聊天 A', '仅 A 可见', 1_769_990_400)
]
}
})
dom.window.eval(inlineScriptOf(html))
const filter = dom.window.document.querySelector('#conversation-filter')!
const trigger = dom.window.document.querySelector('#conversation-trigger') as HTMLButtonElement
const menu = dom.window.document.querySelector('#conversation-menu') as HTMLElement
expect(filter.hasAttribute('hidden')).toBe(false)
expect(filter.parentElement?.classList.contains('archive-heading')).toBe(true)
expect((dom.window.document.querySelector('#archive-title') as HTMLElement).hidden).toBe(true)
expect(trigger.textContent).toContain('全部聊天')
expect(menu.querySelectorAll('[data-conversation-id]')).toHaveLength(3)
expect(dom.window.document.querySelectorAll('.conversation-source')).toHaveLength(3)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 3 / 筛选 3 / 全部 3'
)
trigger.click()
;(menu.querySelector('[data-conversation-id="alpha"]') as HTMLButtonElement).click()
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(2)
expect(dom.window.document.querySelectorAll('.conversation-source')).toHaveLength(0)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 2 / 筛选 2 / 当前聊天 2'
)
expect(dom.window.document.querySelectorAll('.timeline-month')).toHaveLength(2)
const search = dom.window.document.querySelector('#query') as HTMLInputElement
search.value = '共同关键词'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 1 / 筛选 1 / 当前聊天 2'
)
dom.window.close()
})
it('locates every filtered message kind in all messages, including outside the latest window', () => {
const html = renderExportPage('定位消息')
const dom = new JSDOM(html, { runScripts: 'outside-only' })
const categorized: Message[] = [
{
...messageForArchive('target-text', 'fixture', '定位消息', '目标文字', 1),
type: '普通文本'
},
{
...messageForArchive('target-media', 'fixture', '定位消息', '', 2),
type: '图片',
exportMediaType: 'image',
exportMediaUrl: 'media/target.jpg'
},
{
...messageForArchive('target-voice', 'fixture', '定位消息', '', 3),
type: '语音',
voiceDataUrl: 'voices/target.wav'
},
{
...messageForArchive('target-file', 'fixture', '定位消息', '', 4),
type: '文件',
exportMediaType: 'file',
exportMediaUrl: 'files/target.pdf'
},
{
...messageForArchive('target-share', 'fixture', '定位消息', '', 5),
type: '分享',
contentData: { type: 'share', typeVal: '5', title: '目标分享' }
},
{
...messageForArchive('target-system', 'fixture', '定位消息', '目标系统消息', 6),
from: 'system',
type: '系统消息',
contentData: { type: 'system', content: '目标系统消息' }
}
]
const laterMessages = Array.from({ length: EXPORT_PAGE_SIZE }, (_, index) => ({
...messageForArchive(
`later-${index}`,
'fixture',
'定位消息',
`稍后消息-${index}`,
100 + index
),
type: '普通文本'
}))
Object.assign(dom.window, {
__WECHAT_EXPORT__: {
version: 1,
sourceId: 'fixture',
name: '定位消息',
messages: [...categorized, ...laterMessages]
}
})
dom.window.eval(inlineScriptOf(html))
expect(dom.window.document.querySelectorAll('.locate-all')).toHaveLength(0)
for (const kind of ['media', 'voice', 'file', 'share', 'system']) {
const filterButton = dom.window.document.querySelector(`[data-kind="${kind}"]`) as HTMLElement
filterButton.click()
const locateButton = dom.window.document.querySelector('.locate-all') as HTMLElement
expect(locateButton?.getAttribute('aria-label')).toBe('定位到聊天位置')
expect(locateButton?.querySelector('.locate-icon')?.textContent).toBe('⌖')
expect(locateButton?.querySelector('.locate-label')?.textContent).toBe('定位到聊天位置')
locateButton.click()
expect(
dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active')
).toBe(true)
expect(
dom.window.document.querySelector('.message.located')?.getAttribute('data-index')
).toBe(String(categorized.findIndex((message) => kindOfFixture(message) === kind)))
}
const textFilter = dom.window.document.querySelector('[data-kind="text"]') as HTMLElement
textFilter.click()
const search = dom.window.document.querySelector('#query') as HTMLInputElement
search.value = '目标文字'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelector('.search-highlight')?.textContent).toBe('目标文字')
;(dom.window.document.querySelector('.locate-all') as HTMLElement).click()
expect(
dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active')
).toBe(true)
expect(dom.window.document.querySelector('.message.located')?.getAttribute('data-index')).toBe(
'0'
)
expect(dom.window.document.querySelector('.message.located')?.textContent).toContain('目标文字')
search.value = '稍后消息-137'
search.dispatchEvent(new dom.window.Event('input'))
expect(
dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active')
).toBe(true)
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1)
expect(dom.window.document.querySelector('.search-highlight')?.textContent).toBe('稍后消息-137')
;(dom.window.document.querySelector('.locate-all') as HTMLElement).click()
expect(search.value).toBe('')
expect(dom.window.document.querySelectorAll('.search-highlight')).toHaveLength(0)
expect(dom.window.document.querySelectorAll('.locate-all')).toHaveLength(0)
expect(dom.window.document.querySelector('.message.located')?.textContent).toContain(
'稍后消息-137'
)
dom.window.close()
})
@@ -158,6 +383,36 @@ describe('export media', () => {
expect(html).toContain('aria-label="关闭图片预览"')
expect(html).toContain("closeButton.addEventListener('click', closeLightbox)")
expect(html).toContain('if (event.target === box) closeLightbox()')
expect(html).toContain("if (event.key === 'Escape') closeLightbox()")
expect(html).toContain("if (event.key === 'Escape')")
expect(html).toContain('closeLightbox()')
})
})
function messageForArchive(
id: string,
conversationId: string,
conversationName: string,
content: string,
createTime: number
): Message {
return {
id,
from: 'user',
type: '普通文本',
datetime: '',
content,
isSender: false,
createTime,
exportConversationId: conversationId,
exportConversationName: conversationName
}
}
function kindOfFixture(message: Message): string {
if (message.exportMediaType === 'image') return 'media'
if (message.voiceDataUrl) return 'voice'
if (message.exportMediaType === 'file') return 'file'
if (message.contentData?.type === 'share') return 'share'
if (message.contentData?.type === 'system') return 'system'
return 'text'
}
+28
View File
@@ -28,6 +28,34 @@ describe('message parser', () => {
}
})
it.each([
['6', '测试附件.pdf'],
['74', '发送中的附件.zip']
])(
'keeps file app message type %s when attachment metadata contains record tags',
(typeVal, title) => {
const parsed = parseMessageContent(
`<appmsg><type>${typeVal}</type><title>${title}</title><des>1 MB</des><appattach><recorditem>legacy metadata</recorditem><dataitem datatype="8"><datatitle>${title}</datatitle></dataitem></appattach></appmsg>`,
49
)
expect(parsed).toMatchObject({
type: 'share',
title,
typeVal
})
}
)
it('does not classify empty incidental record metadata as a merged forward', () => {
const parsed = parseMessageContent(
'<appmsg><type>5</type><title>普通分享</title><recorditem>legacy metadata</recorditem></appmsg>',
49
)
expect(parsed).toMatchObject({ type: 'share', title: '普通分享', typeVal: '5' })
})
it('uses the quoted group member id instead of the chatroom id', () => {
const parsed = parseMessageContent(
'<appmsg><type>57</type><title>回复内容</title><refermsg><type>1</type><fromusr>123456789@chatroom</fromusr><chatusr>wxid_fixture_member</chatusr><content>被引用内容</content></refermsg></appmsg>',
+78
View File
@@ -0,0 +1,78 @@
import crypto from 'crypto'
import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import type { Message } from '../../src/shared/types'
const state = vi.hoisted(() => ({ userData: '' }))
vi.mock('electron', () => ({
app: { getPath: () => state.userData }
}))
const message = (createTime: number, serverId?: string): Message => ({
id: `message-${createTime}`,
from: 'user',
isSender: false,
type: '普通文本',
datetime: new Date(createTime * 1000).toISOString(),
content: String(createTime),
img: '',
name: 'Jamie',
sessionId: 'fixture-user',
localId: 1,
serverId,
createTime
})
describe('recall archive message identity', () => {
beforeAll(() => {
state.userData = mkdtempSync(join(tmpdir(), 'wxe-recall-identity-'))
})
it('does not collapse messages whose local ids repeat across database shards', async () => {
const accountRoot = '/fixture/account'
const sessionMd5 = 'fixture-session'
const archiveDir = join(state.userData, 'recall-archive')
const archiveName = crypto
.createHash('sha1')
.update(`${process.platform}:${accountRoot}`)
.digest('hex')
.slice(0, 16)
mkdirSync(archiveDir, { recursive: true })
writeFileSync(
join(archiveDir, `${archiveName}.json`),
JSON.stringify({
version: 1,
accountRoot,
updatedAt: Date.now(),
sessions: {
[sessionMd5]: {
username: 'fixture-user',
updatedAt: Date.now(),
messages: [],
recalls: []
}
}
})
)
const { configureRecallArchive, mergeRecallArchiveMessages, messageIdentity } =
await import('../../src/main/services/recall-archive-service')
configureRecallArchive(accountRoot)
const oldMessage = message(1_731_327_263)
const newMessage = message(1_765_000_000)
expect(messageIdentity(oldMessage)).not.toBe(messageIdentity(newMessage))
expect(mergeRecallArchiveMessages(sessionMd5, [oldMessage, newMessage])).toEqual([
oldMessage,
newMessage
])
})
it('prefers the globally unique server id when one is available', async () => {
const { messageIdentity } = await import('../../src/main/services/recall-archive-service')
expect(messageIdentity(message(1_731_327_263, 'server-2024'))).toBe('server:server-2024')
})
})
+23 -3
View File
@@ -5,9 +5,16 @@ import { dirname, join, resolve } from 'path'
import { afterAll, describe, expect, it } from 'vitest'
const nodeRequire = createRequire(import.meta.url)
const { validateFfmpegRuntime, validateSherpaRuntime, validateSilkWasmRuntime } = nodeRequire(
'../../scripts/after-pack.cjs'
) as {
const asar = nodeRequire('@electron/asar') as {
createPackage: (source: string, destination: string) => Promise<void>
}
const {
validateAsarRuntimeDependencies,
validateFfmpegRuntime,
validateSherpaRuntime,
validateSilkWasmRuntime
} = nodeRequire('../../scripts/after-pack.cjs') as {
validateAsarRuntimeDependencies: (runtimeResources: string) => void
validateFfmpegRuntime: (runtimeResources: string, platform?: NodeJS.Platform) => void
validateSherpaRuntime: (runtimeResources: string, platform: NodeJS.Platform, arch: string) => void
validateSilkWasmRuntime: (runtimeResources: string) => void
@@ -33,6 +40,19 @@ describe('production runtime packaging', () => {
expect(config).toContain('node_modules/silk-wasm/**')
})
it('rejects an app archive with missing runtime dependencies', async () => {
const resources = join(root, 'asar-resources')
const source = join(root, 'asar-source')
mkdirSync(source, { recursive: true })
writeFileSync(join(source, 'package.json'), '{}')
mkdirSync(resources, { recursive: true })
await asar.createPackage(source, join(resources, 'app.asar'))
expect(() => validateAsarRuntimeDependencies(resources)).toThrow(
/Missing packaged runtime dependencies:.*@electron-toolkit\/utils/
)
})
it('requires and unpacks the bundled ffmpeg-static executable', () => {
const resources = join(root, 'ffmpeg-resources')
const ffmpegPath = join(
+140
View File
@@ -0,0 +1,140 @@
import { createHash } from 'crypto'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { VideoAssetService } from '../../src/main/video-asset-service'
const temporaryDirectories: string[] = []
const box = (type: string, payload: Buffer): Buffer => {
const header = Buffer.alloc(8)
header.writeUInt32BE(header.length + payload.length, 0)
header.write(type, 4, 4, 'ascii')
return Buffer.concat([header, payload])
}
const mp4Fixture = (durationSeconds: number, marker: string): Buffer => {
const movieHeader = Buffer.alloc(20)
movieHeader.writeUInt32BE(1000, 12)
movieHeader.writeUInt32BE(Math.round(durationSeconds * 1000), 16)
return Buffer.concat([
box('ftyp', Buffer.from('isom0000', 'ascii')),
box('moov', box('mvhd', movieHeader)),
box('mdat', Buffer.from(marker, 'utf8'))
])
}
const jpegFixture = (width: number, height: number): Buffer =>
Buffer.from([
0xff,
0xd8,
0xff,
0xc0,
0x00,
0x11,
0x08,
(height >> 8) & 0xff,
height & 0xff,
(width >> 8) & 0xff,
width & 0xff,
0x03,
0x01,
0x11,
0x00,
0x02,
0x11,
0x00,
0x03,
0x11,
0x00,
0xff,
0xd9
])
const createService = (): {
accountRoot: string
service: VideoAssetService
} => {
const accountRoot = mkdtempSync(join(tmpdir(), 'wxe-video-assets-'))
temporaryDirectories.push(accountRoot)
return {
accountRoot,
service: new VideoAssetService({
getAccountRoot: () => accountRoot,
resolveVideoHardlink: () => null
} as never)
}
}
const monthTimestamp = (year: number, month: number): number =>
Math.floor(new Date(year, month - 1, 15, 12).getTime() / 1000)
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
describe('VideoAssetService local fallback', () => {
it('finds a video by its content MD5 when the hardlink mapping is absent', async () => {
const { accountRoot, service } = createService()
const month = join(accountRoot, 'msg', 'video', '2026-07')
mkdirSync(month, { recursive: true })
const content = mp4Fixture(22, 'content-md5-match')
const filePath = join(month, `${'2'.repeat(32)}.mp4`)
writeFileSync(filePath, content)
const contentHash = createHash('md5').update(content).digest('hex')
const result = await service.resolve([contentHash], {
createTime: monthTimestamp(2026, 7)
})
expect(result.success).toBe(true)
expect(service.pathForUrl(result.url!)).toBe(filePath)
})
it('finds a uniquely matching video by month, thumbnail size, and duration', async () => {
const { accountRoot, service } = createService()
const month = join(accountRoot, 'msg', 'video', '2025-11')
mkdirSync(month, { recursive: true })
const stem = '66cecd68e095d87175fb5ed138de3cef'
const filePath = join(month, `${stem}.mp4`)
const posterPath = join(month, `${stem}_thumb.jpg`)
writeFileSync(filePath, mp4Fixture(68.441, 'metadata-match'))
writeFileSync(posterPath, jpegFixture(279, 630))
const result = await service.resolve(
['c92c54c8eae4471be9cc18396daf8015', '021e8a18a765ce14f4c54f40065db98e'],
{
createTime: monthTimestamp(2025, 11),
duration: 68,
width: 279,
height: 630
}
)
expect(result.success).toBe(true)
expect(service.pathForUrl(result.url!)).toBe(filePath)
expect(service.pathForUrl(result.poster!)).toBe(posterPath)
})
it('does not guess when multiple files match the same metadata', async () => {
const { accountRoot, service } = createService()
const month = join(accountRoot, 'msg', 'video', '2026-02')
mkdirSync(month, { recursive: true })
for (const stem of ['a'.repeat(32), 'b'.repeat(32)]) {
writeFileSync(join(month, `${stem}.mp4`), mp4Fixture(12, stem))
writeFileSync(join(month, `${stem}_thumb.jpg`), jpegFixture(224, 398))
}
const result = await service.resolve(['c'.repeat(32)], {
createTime: monthTimestamp(2026, 2),
duration: 12,
width: 224,
height: 398
})
expect(result).toEqual({ success: false, error: '本地未找到该视频文件' })
})
})
+17 -2
View File
@@ -1,9 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { Wcdb4Client, type Wcdb4Message } from '../../src/main/wcdb4-client'
const message = (id: string, year: number): Wcdb4Message => ({
const message = (id: string, year: number, serverId = `server-${id}`): Wcdb4Message => ({
mesLocalID: id,
serverId: `server-${id}`,
serverId,
mesDes: 0,
messageType: '1',
msgCreateTime: String(Math.floor(Date.UTC(year, 0, 1) / 1000)),
@@ -12,6 +12,21 @@ const message = (id: string, year: number): Wcdb4Message => ({
})
describe('WCDB message shard pagination', () => {
it('keeps messages whose local ids repeat across database shards', async () => {
const cursor = vi.fn(async () => [
message('1', 2024, 'server-2024'),
message('1', 2025, 'server-2025')
])
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
getMessagesByCursorAsync: cursor
}) as Wcdb4Client
const result = await client.getMessagesAsync('fixture@chatroom')
expect(result).toHaveLength(2)
expect(result.map((item) => item.serverId)).toEqual(['server-2024', 'server-2025'])
})
it('merges cursor and all-store rows for a bounded cross-year page', async () => {
const cursor = vi.fn(async () => [message('2025', 2025)])
const tableScan = vi.fn(async () => [message('2017', 2017), message('2025', 2025)])
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest'
import { WechatDb, type WechatMessage } from '../../src/main/wechat-db'
describe('WechatDb normalized messages', () => {
it('keeps normalized identity fields when raw table columns conflict', async () => {
const normalized = {
mesLocalID: '1',
serverId: 'server-1',
mesDes: 0,
messageType: '1',
msgCreateTime: '1731327263',
msgContent: 'fixture',
sender: 'wxid_self',
senderNickname: 'Nanin',
raw: {
mesDes: 1,
sender: '',
senderNickname: ''
}
}
const client = { getMessagesAsync: vi.fn(async () => [normalized]) }
const db = Object.assign(Object.create(WechatDb.prototype), {
wcdb4Client: client,
chatMd5ToUsername: new Map([['fixture-md5', 'fixture-user']]),
ensureChatTableMapping: vi.fn()
}) as WechatDb
const start = Math.floor(new Date(2024, 10, 11).getTime() / 1000)
const end = Math.floor(new Date(2024, 11, 1).getTime() / 1000)
const messages = await db.getUserMessagesForExport('fixture-md5', start, end)
expect(messages[0]).toMatchObject({
mesDes: 0,
sender: 'wxid_self',
senderNickname: 'Nanin'
})
})
it('scans without time bounds, then filters, deduplicates and sorts in application code', async () => {
const row = (id: string, createTime: number): WechatMessage => ({
mesLocalID: id,
serverId: `server-${id}`,
mesDes: 0,
messageType: '1',
msgCreateTime: String(createTime),
msgContent: id,
raw: {}
})
const start = Math.floor(new Date(2025, 0, 1).getTime() / 1000)
const end = Math.floor(new Date(2025, 0, 4).getTime() / 1000)
const shardBoundaryMessage = row('jan-2-boundary', start + 32 * 60 * 60)
const getMessagesAsync = vi.fn(async () => [
row('before-range', start - 1),
row('newest', start + 48 * 60 * 60),
shardBoundaryMessage,
{ ...shardBoundaryMessage },
row('after-range', end + 1)
])
const db = Object.assign(Object.create(WechatDb.prototype), {
wcdb4Client: { getMessagesAsync },
chatMd5ToUsername: new Map([['fixture-md5', 'fixture-user']]),
ensureChatTableMapping: vi.fn()
}) as WechatDb
const messages = await db.getUserMessagesForExport('fixture-md5', start, end)
expect(getMessagesAsync).toHaveBeenCalledOnce()
expect(getMessagesAsync).toHaveBeenCalledWith('fixture-user')
expect(messages.map((message) => message.mesLocalID)).toEqual(['jan-2-boundary', 'newest'])
})
})