mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
Merge branch 'nanin/develop' into develop
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+311
-6
@@ -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'}
|
||||
|
||||
@@ -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
|
||||
|
||||
+1163
-56
File diff suppressed because it is too large
Load Diff
+654
-102
@@ -1,14 +1,16 @@
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { app, BrowserWindow, nativeImage, shell } from 'electron'
|
||||
import { createHash } from 'crypto'
|
||||
import { promises as fs } from 'fs'
|
||||
import { createReadStream, createWriteStream, promises as fs } from 'fs'
|
||||
import { extname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { ZipArchive, type Archiver } from 'archiver'
|
||||
import * as chat from './services/chat-service'
|
||||
import type {
|
||||
ExportJobProgress,
|
||||
ExportMessageKind,
|
||||
ExportRequest,
|
||||
ExportResult
|
||||
ExportResult,
|
||||
ExportTarget
|
||||
} from '../shared/export'
|
||||
import type { Message } from '../shared/types'
|
||||
import { VoiceService } from './voice-service'
|
||||
@@ -23,8 +25,18 @@ import { mergeCachedSelfInfo } from './services/bootstrap-cache'
|
||||
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
|
||||
|
||||
const jobs = new Set<string>()
|
||||
const activeArchives = new Map<string, Archiver>()
|
||||
const safeFilePart = (value: string): string =>
|
||||
value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案'
|
||||
const copyWritableExportFile = async (source: string, destination: string): Promise<void> => {
|
||||
try {
|
||||
await fs.chmod(destination, 0o644)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
await fs.copyFile(source, destination)
|
||||
await fs.chmod(destination, 0o644)
|
||||
}
|
||||
const exportStamp = (): string => {
|
||||
const date = new Date()
|
||||
const pad = (value: number): string => String(value).padStart(2, '0')
|
||||
@@ -32,38 +44,21 @@ const exportStamp = (): string => {
|
||||
}
|
||||
const imageKeys = new ImageKeyConfigService()
|
||||
|
||||
const copyExportAsset = async (
|
||||
source: string,
|
||||
destination: string
|
||||
): Promise<{ success: true } | { success: false; error: string }> => {
|
||||
try {
|
||||
await fs.copyFile(source, destination)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
try {
|
||||
const [sourceStat, destinationStat] = await Promise.all([
|
||||
fs.stat(source),
|
||||
fs.stat(destination)
|
||||
])
|
||||
if (
|
||||
sourceStat.isFile() &&
|
||||
destinationStat.isFile() &&
|
||||
sourceStat.size > 0 &&
|
||||
sourceStat.size === destinationStat.size
|
||||
) {
|
||||
return { success: true }
|
||||
}
|
||||
} catch {
|
||||
// The original copy error below is more useful than a secondary stat error.
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
export interface HtmlExportConversation {
|
||||
id: string
|
||||
name: string
|
||||
type: 'user' | 'group'
|
||||
avatarUrl?: string
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
export interface HtmlExportArchive {
|
||||
interface HtmlExportAvatarVersion {
|
||||
sourceHash: string
|
||||
visualHash?: string
|
||||
avatarUrl: string
|
||||
}
|
||||
|
||||
interface HtmlExportArchiveV1 {
|
||||
version: 1
|
||||
sourceId: string
|
||||
name: string
|
||||
@@ -71,20 +66,166 @@ export interface HtmlExportArchive {
|
||||
messages: Message[]
|
||||
}
|
||||
|
||||
export interface HtmlExportArchive {
|
||||
version: 2
|
||||
name: string
|
||||
exportedAt: string
|
||||
conversations: HtmlExportConversation[]
|
||||
messages: Message[]
|
||||
avatarVersions?: Record<string, HtmlExportAvatarVersion>
|
||||
}
|
||||
|
||||
const htmlArchiveResourcePrefixes = {
|
||||
voices: ['voice_'],
|
||||
media: ['image_', 'video_', 'sticker_', 'file_'],
|
||||
files: ['file_'],
|
||||
avatars: ['avatar_', 'conversation_']
|
||||
} as const
|
||||
type HtmlArchiveResourceDirectory = keyof typeof htmlArchiveResourcePrefixes
|
||||
|
||||
const archiveDataPrefix = 'window.__WECHAT_EXPORT__ = '
|
||||
const hashPart = (value: string, length = 16): string =>
|
||||
createHash('sha1').update(value).digest('hex').slice(0, length)
|
||||
const bufferHashPart = (buffer: Buffer, length = 16): string =>
|
||||
createHash('sha1').update(buffer).digest('hex').slice(0, length)
|
||||
const fileHashPart = async (filePath: string, length = 16): Promise<string> => {
|
||||
const hash = createHash('sha1')
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const input = createReadStream(filePath)
|
||||
input.on('data', (chunk) => hash.update(chunk))
|
||||
input.once('end', resolve)
|
||||
input.once('error', reject)
|
||||
})
|
||||
return hash.digest('hex').slice(0, length)
|
||||
}
|
||||
const avatarFileName = (buffer: Buffer, extension: string): string =>
|
||||
`avatar_${bufferHashPart(buffer)}.${extension}`
|
||||
const avatarSourceHash = (source: string): string => hashPart(source, 24)
|
||||
const avatarIdentityKey = (conversationId: string, message: Message): string =>
|
||||
`${conversationId}:${
|
||||
message.senderId ||
|
||||
(message.isSender ? '__self__' : message.from || message.name || '__unknown__')
|
||||
}`
|
||||
|
||||
const avatarVisualHash = (buffer: Buffer): string | null => {
|
||||
try {
|
||||
const image = nativeImage.createFromBuffer(buffer)
|
||||
if (image.isEmpty()) return null
|
||||
const bitmap = image.resize({ width: 9, height: 8, quality: 'good' }).toBitmap()
|
||||
if (bitmap.length < 9 * 8 * 4) return null
|
||||
let hash = 0n
|
||||
let red = 0
|
||||
let green = 0
|
||||
let blue = 0
|
||||
for (let y = 0; y < 8; y += 1) {
|
||||
for (let x = 0; x < 8; x += 1) {
|
||||
const left = (y * 9 + x) * 4
|
||||
const right = left + 4
|
||||
blue += bitmap[left]
|
||||
green += bitmap[left + 1]
|
||||
red += bitmap[left + 2]
|
||||
const leftLuma = bitmap[left + 2] * 3 + bitmap[left + 1] * 6 + bitmap[left]
|
||||
const rightLuma = bitmap[right + 2] * 3 + bitmap[right + 1] * 6 + bitmap[right]
|
||||
hash = (hash << 1n) | (leftLuma > rightLuma ? 1n : 0n)
|
||||
}
|
||||
}
|
||||
const averageHex = [red, green, blue]
|
||||
.map((total) =>
|
||||
Math.round(total / 64)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
)
|
||||
.join('')
|
||||
return `${hash.toString(16).padStart(16, '0')}:${averageHex}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const parseAvatarVisualHash = (value: string): { shape: bigint; color: number[] } | null => {
|
||||
const match = /^([0-9a-f]{16}):([0-9a-f]{6})$/i.exec(value)
|
||||
if (!match) return null
|
||||
return {
|
||||
shape: BigInt(`0x${match[1]}`),
|
||||
color: [0, 2, 4].map((offset) => Number.parseInt(match[2].slice(offset, offset + 2), 16))
|
||||
}
|
||||
}
|
||||
|
||||
const avatarHashDistance = (left: bigint, right: bigint): number => {
|
||||
let difference = left ^ right
|
||||
let distance = 0
|
||||
while (difference) {
|
||||
difference &= difference - 1n
|
||||
distance += 1
|
||||
}
|
||||
return distance
|
||||
}
|
||||
|
||||
const avatarsLookTheSame = (left?: string | null, right?: string | null): boolean => {
|
||||
if (!left || !right) return false
|
||||
const leftHash = parseAvatarVisualHash(left)
|
||||
const rightHash = parseAvatarVisualHash(right)
|
||||
if (!leftHash || !rightHash || avatarHashDistance(leftHash.shape, rightHash.shape) > 5) {
|
||||
return false
|
||||
}
|
||||
return leftHash.color.every((channel, index) => Math.abs(channel - rightHash.color[index]) <= 12)
|
||||
}
|
||||
|
||||
const htmlArchiveSourceSignature = (message: Message): string =>
|
||||
JSON.stringify([
|
||||
message.type,
|
||||
message.content,
|
||||
message.contentData || null,
|
||||
message.localId || null,
|
||||
message.serverId || null,
|
||||
message.createTime || null,
|
||||
message.sessionId || null,
|
||||
message.senderId || null,
|
||||
message.isSender,
|
||||
message.recalled || false,
|
||||
message.recalledBy || null
|
||||
])
|
||||
|
||||
const htmlArchiveResourcePath = (outputDir: string, value?: string): string | null => {
|
||||
if (!value) return null
|
||||
const normalized = value.replace(/\\/g, '/').split(/[?#]/, 1)[0]
|
||||
const [directory, fileName, ...rest] = normalized.split('/')
|
||||
if (
|
||||
!directory ||
|
||||
!fileName ||
|
||||
rest.length > 0 ||
|
||||
!Object.prototype.hasOwnProperty.call(htmlArchiveResourcePrefixes, directory)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return join(outputDir, directory, fileName)
|
||||
}
|
||||
|
||||
const htmlArchiveResourceExists = async (outputDir: string, value?: string): Promise<boolean> => {
|
||||
const filePath = htmlArchiveResourcePath(outputDir, value)
|
||||
if (!filePath) return false
|
||||
try {
|
||||
const stat = await fs.stat(filePath)
|
||||
if (!stat.isFile()) return false
|
||||
if ((stat.mode & 0o200) === 0) await fs.chmod(filePath, 0o644)
|
||||
return true
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const exportMessageKey = (message: Message, sourceId = ''): string => {
|
||||
const conversationId = message.exportConversationId || sourceId
|
||||
const sessionId = message.sessionId || sourceId
|
||||
if (message.localId && message.createTime) {
|
||||
return `${sessionId}:local:${message.localId}:${message.createTime}`
|
||||
return `${conversationId}:${sessionId}:local:${message.localId}:${message.createTime}`
|
||||
}
|
||||
if (message.serverId) return `${sessionId}:server:${message.serverId}`
|
||||
if (message.serverId) return `${conversationId}:${sessionId}:server:${message.serverId}`
|
||||
if (message.id && message.createTime && !/^0\.\d+$/.test(message.id)) {
|
||||
return `${sessionId}:id:${message.id}:${message.createTime}`
|
||||
return `${conversationId}:${sessionId}:id:${message.id}:${message.createTime}`
|
||||
}
|
||||
return `${sessionId}:fallback:${hashPart(
|
||||
return `${conversationId}:${sessionId}:fallback:${hashPart(
|
||||
JSON.stringify([
|
||||
message.createTime || 0,
|
||||
message.senderId || '',
|
||||
@@ -123,7 +264,8 @@ const mergeArchiveMessage = (previous: Message, current: Message): Message => {
|
||||
export function mergeHtmlArchiveMessages(
|
||||
previous: Message[],
|
||||
current: Message[],
|
||||
sourceId = ''
|
||||
sourceId = '',
|
||||
conversationOrder: string[] = []
|
||||
): Message[] {
|
||||
const merged = new Map<string, Message>()
|
||||
for (const message of previous) merged.set(exportMessageKey(message, sourceId), message)
|
||||
@@ -135,6 +277,10 @@ export function mergeHtmlArchiveMessages(
|
||||
return Array.from(merged.values()).sort((left, right) => {
|
||||
const byTime = Number(left.createTime || 0) - Number(right.createTime || 0)
|
||||
if (byTime !== 0) return byTime
|
||||
const byConversation =
|
||||
conversationOrder.indexOf(left.exportConversationId || sourceId) -
|
||||
conversationOrder.indexOf(right.exportConversationId || sourceId)
|
||||
if (byConversation !== 0) return byConversation
|
||||
return Number(left.localId || 0) - Number(right.localId || 0)
|
||||
})
|
||||
}
|
||||
@@ -159,9 +305,18 @@ export function normalizeHtmlArchiveSelfNames(
|
||||
})
|
||||
}
|
||||
|
||||
export function stripHtmlArchiveInlineAvatars(messages: Message[]): Message[] {
|
||||
return messages.map((message) => {
|
||||
if (message.img == null) return message
|
||||
const archiveMessage = { ...message }
|
||||
delete archiveMessage.img
|
||||
return archiveMessage
|
||||
})
|
||||
}
|
||||
|
||||
export async function readHtmlArchive(
|
||||
outputDir: string,
|
||||
sourceId: string,
|
||||
targets: ExportTarget[],
|
||||
name: string
|
||||
): Promise<HtmlExportArchive> {
|
||||
const dataPath = join(outputDir, 'data', 'messages.js')
|
||||
@@ -170,7 +325,18 @@ export async function readHtmlArchive(
|
||||
source = await fs.readFile(dataPath, 'utf8')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { version: 1, sourceId, name, exportedAt: new Date(0).toISOString(), messages: [] }
|
||||
return {
|
||||
version: 2,
|
||||
name,
|
||||
exportedAt: new Date(0).toISOString(),
|
||||
conversations: targets.map((target) => ({
|
||||
id: target.userMd5,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
messageCount: 0
|
||||
})),
|
||||
messages: []
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -180,21 +346,101 @@ export async function readHtmlArchive(
|
||||
.slice(assignment + 1)
|
||||
.trim()
|
||||
.replace(/;\s*$/, '')
|
||||
let archive: HtmlExportArchive
|
||||
let parsed: HtmlExportArchive | HtmlExportArchiveV1
|
||||
try {
|
||||
archive = JSON.parse(json) as HtmlExportArchive
|
||||
parsed = JSON.parse(json) as HtmlExportArchive | HtmlExportArchiveV1
|
||||
} catch {
|
||||
throw new Error('现有 HTML 档案数据已损坏,请从 messages.js.bak 恢复或更换导出名称')
|
||||
}
|
||||
if (archive.sourceId && archive.sourceId !== sourceId) {
|
||||
throw new Error('同名导出目录已属于另一个会话,请修改文件名称后重试')
|
||||
const expectedIds = targets.map((target) => target.userMd5).sort()
|
||||
if (parsed.version === 1) {
|
||||
if (targets.length !== 1 || parsed.sourceId !== targets[0].userMd5) {
|
||||
throw new Error('同名导出目录的聊天集合不同,请修改文件名称后重试')
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
sourceId,
|
||||
name: archive.name || name,
|
||||
exportedAt: archive.exportedAt || new Date(0).toISOString(),
|
||||
messages: Array.isArray(archive.messages) ? archive.messages : []
|
||||
version: 2,
|
||||
name: parsed.name || name,
|
||||
exportedAt: parsed.exportedAt || new Date(0).toISOString(),
|
||||
conversations: [
|
||||
{
|
||||
id: targets[0].userMd5,
|
||||
name: targets[0].name,
|
||||
type: targets[0].type,
|
||||
messageCount: Array.isArray(parsed.messages) ? parsed.messages.length : 0
|
||||
}
|
||||
],
|
||||
messages: (Array.isArray(parsed.messages) ? parsed.messages : []).map((message) => ({
|
||||
...message,
|
||||
exportConversationId: targets[0].userMd5,
|
||||
exportConversationName: targets[0].name
|
||||
}))
|
||||
}
|
||||
}
|
||||
const actualIds = (Array.isArray(parsed.conversations) ? parsed.conversations : [])
|
||||
.map((conversation) => conversation.id)
|
||||
.sort()
|
||||
if (actualIds.join('|') !== expectedIds.join('|')) {
|
||||
throw new Error('同名导出目录的聊天集合不同,请修改文件名称后重试')
|
||||
}
|
||||
return {
|
||||
version: 2,
|
||||
name: parsed.name || name,
|
||||
exportedAt: parsed.exportedAt || new Date(0).toISOString(),
|
||||
conversations: parsed.conversations,
|
||||
messages: Array.isArray(parsed.messages) ? parsed.messages : [],
|
||||
avatarVersions: parsed.avatarVersions
|
||||
}
|
||||
}
|
||||
|
||||
async function writeZipArchive(
|
||||
outputDir: string,
|
||||
zipPath: string,
|
||||
folderName: string,
|
||||
jobId: string
|
||||
): Promise<void> {
|
||||
const temporaryPath = `${zipPath}.tmp-${process.pid}-${Date.now()}`
|
||||
await fs.rm(temporaryPath, { force: true })
|
||||
const output = createWriteStream(temporaryPath)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
output.once('open', () => resolve())
|
||||
output.once('error', reject)
|
||||
})
|
||||
const archive = new ZipArchive({ zlib: { level: 6 } })
|
||||
activeArchives.set(jobId, archive)
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false
|
||||
const finish = (error?: Error): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
}
|
||||
output.on('close', () => finish())
|
||||
output.on('error', finish)
|
||||
archive.on('warning', (error) => {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') finish(error)
|
||||
})
|
||||
archive.on('error', finish)
|
||||
archive.pipe(output)
|
||||
archive.directory(outputDir, safeFilePart(folderName))
|
||||
void archive.finalize().catch(finish)
|
||||
})
|
||||
if (!jobs.has(jobId)) throw new Error('已取消')
|
||||
try {
|
||||
await fs.rename(temporaryPath, zipPath)
|
||||
} catch (error) {
|
||||
if (!['EEXIST', 'EPERM'].includes((error as NodeJS.ErrnoException).code || '')) throw error
|
||||
await fs.rm(zipPath, { force: true })
|
||||
await fs.rename(temporaryPath, zipPath)
|
||||
}
|
||||
} finally {
|
||||
activeArchives.delete(jobId)
|
||||
if (!output.closed) {
|
||||
output.destroy()
|
||||
await new Promise<void>((resolve) => output.once('close', () => resolve()))
|
||||
}
|
||||
await fs.rm(temporaryPath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +471,54 @@ export async function writeHtmlArchive(
|
||||
}
|
||||
}
|
||||
|
||||
export async function pruneHtmlArchiveResources(
|
||||
outputDir: string,
|
||||
archive: HtmlExportArchive
|
||||
): Promise<void> {
|
||||
const referenced = Object.fromEntries(
|
||||
Object.keys(htmlArchiveResourcePrefixes).map((directory) => [directory, new Set<string>()])
|
||||
) as Record<HtmlArchiveResourceDirectory, Set<string>>
|
||||
const addReference = (value?: string): void => {
|
||||
if (!value) return
|
||||
const path = value.replace(/\\/g, '/').split(/[?#]/, 1)[0]
|
||||
const separator = path.indexOf('/')
|
||||
if (separator <= 0 || path.indexOf('/', separator + 1) >= 0) return
|
||||
const directory = path.slice(0, separator) as HtmlArchiveResourceDirectory
|
||||
const fileName = path.slice(separator + 1)
|
||||
if (!referenced[directory] || !fileName) return
|
||||
referenced[directory].add(fileName)
|
||||
}
|
||||
|
||||
for (const conversation of archive.conversations) addReference(conversation.avatarUrl)
|
||||
for (const message of archive.messages) {
|
||||
addReference(message.voiceDataUrl)
|
||||
addReference(message.exportMediaUrl)
|
||||
addReference(message.exportAvatarUrl)
|
||||
}
|
||||
|
||||
for (const [directory, prefixes] of Object.entries(htmlArchiveResourcePrefixes) as [
|
||||
HtmlArchiveResourceDirectory,
|
||||
readonly string[]
|
||||
][]) {
|
||||
let entries
|
||||
try {
|
||||
entries = await fs.readdir(join(outputDir, directory), { withFileTypes: true })
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
|
||||
throw error
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
entry.isFile() &&
|
||||
prefixes.some((prefix) => entry.name.startsWith(prefix)) &&
|
||||
!referenced[directory].has(entry.name)
|
||||
) {
|
||||
await fs.unlink(join(outputDir, directory, entry.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const keepMediaError = (request: ExportRequest, message: Message, error: string): void => {
|
||||
if (request.keepMissing !== false) message.exportMediaError = error
|
||||
}
|
||||
@@ -336,13 +630,59 @@ export async function runExport(
|
||||
if (!win.isDestroyed()) win.webContents.send('export:progress', p)
|
||||
}
|
||||
try {
|
||||
const targets = request.targets || []
|
||||
if (targets.length < 1 || targets.length > 5) {
|
||||
throw new Error('一次导出必须选择 1 到 5 个聊天')
|
||||
}
|
||||
if (new Set(targets.map((target) => target.userMd5)).size !== targets.length) {
|
||||
throw new Error('导出聊天不能重复')
|
||||
}
|
||||
if (targets.length > 1 && request.format !== 'html') {
|
||||
throw new Error('多聊天合并仅支持 HTML 格式')
|
||||
}
|
||||
const archiveName = safeFilePart(request.outputName)
|
||||
const targetById = new Map(targets.map((target) => [target.userMd5, target]))
|
||||
send({ jobId: request.jobId, phase: 'reading', processed: 0, total: 100, percent: 0 })
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
const messages = (
|
||||
await chat.listMessagesAsync(request.userMd5, request.startTime, request.endTime)
|
||||
const messageEntries: { message: Message; targetOrder: number; messageOrder: number }[] = []
|
||||
for (const [targetOrder, target] of targets.entries()) {
|
||||
if (!jobs.has(request.jobId)) {
|
||||
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 5 })
|
||||
return { success: false, error: '已取消' }
|
||||
}
|
||||
const targetMessages = (
|
||||
await chat.listMessagesForExport(target.userMd5, request.startTime, request.endTime)
|
||||
).filter((message) => request.kinds.includes(kindOf(message)))
|
||||
for (const [messageOrder, message] of targetMessages.entries()) {
|
||||
messageEntries.push({
|
||||
message: {
|
||||
...message,
|
||||
exportConversationId: target.userMd5,
|
||||
exportConversationName: target.name
|
||||
},
|
||||
targetOrder,
|
||||
messageOrder
|
||||
})
|
||||
}
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'reading',
|
||||
processed: targetOrder + 1,
|
||||
total: targets.length,
|
||||
percent: Math.max(1, Math.round(((targetOrder + 1) / targets.length) * 10))
|
||||
})
|
||||
}
|
||||
const messages = messageEntries
|
||||
.sort((left, right) => {
|
||||
const byTime = Number(left.message.createTime || 0) - Number(right.message.createTime || 0)
|
||||
if (byTime !== 0) return byTime
|
||||
if (left.targetOrder !== right.targetOrder) return left.targetOrder - right.targetOrder
|
||||
return left.messageOrder - right.messageOrder
|
||||
})
|
||||
.map((entry) => entry.message)
|
||||
const rawSelfInfo = await chat.getSelfAccountInfoAsync()
|
||||
const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null
|
||||
const client = chat.getChatDb()?.getWcdb4Client()
|
||||
const isUsableSelfName = (value: string | undefined): value is string => {
|
||||
const name = String(value || '').trim()
|
||||
if (!name) return false
|
||||
@@ -351,6 +691,9 @@ export async function runExport(
|
||||
return true
|
||||
}
|
||||
for (const message of messages) {
|
||||
const target = targetById.get(message.exportConversationId || '')
|
||||
const peerUsername = target ? client?.getUsernameByMd5(target.userMd5) || '' : ''
|
||||
const isGroupChat = target?.type === 'group' || peerUsername.endsWith('@chatroom')
|
||||
message.exportMediaUrl = undefined
|
||||
message.exportMediaType = undefined
|
||||
message.exportMediaName = undefined
|
||||
@@ -359,8 +702,22 @@ export async function runExport(
|
||||
message.voiceTranscript = undefined
|
||||
message.voiceTranscriptError = undefined
|
||||
message.exportShowAvatar = request.includeAvatars !== false
|
||||
const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined
|
||||
if (mappedName && (!message.isSender || isUsableSelfName(mappedName))) {
|
||||
if (!isGroupChat && !message.senderId) {
|
||||
message.senderId = message.isSender ? selfInfo?.wxid : peerUsername || message.sessionId
|
||||
}
|
||||
const mappedName = message.senderId ? target?.nameMap?.[message.senderId] : undefined
|
||||
if (!isGroupChat) {
|
||||
message.name = message.isSender
|
||||
? isUsableSelfName(selfInfo?.nickname)
|
||||
? selfInfo.nickname
|
||||
: isUsableSelfName(message.name)
|
||||
? message.name
|
||||
: '我'
|
||||
: target?.name || message.name || '联系人'
|
||||
if (!message.img) {
|
||||
message.img = message.isSender ? selfInfo?.avatar : target?.avatarUrl
|
||||
}
|
||||
} else if (mappedName && (!message.isSender || isUsableSelfName(mappedName))) {
|
||||
message.name = mappedName
|
||||
} else if (message.isSender && isUsableSelfName(selfInfo?.nickname)) {
|
||||
message.name = selfInfo.nickname
|
||||
@@ -397,11 +754,45 @@ export async function runExport(
|
||||
? join(outputDir, 'index.html')
|
||||
: join(root, `${outputFolder}.${ext}`)
|
||||
if (request.format === 'html') {
|
||||
const previousArchive = await readHtmlArchive(outputDir, request.userMd5, request.name)
|
||||
const previousArchive = await readHtmlArchive(outputDir, targets, archiveName)
|
||||
const previousMessagesByKey = new Map(
|
||||
previousArchive.messages.map((message) => [exportMessageKey(message), message])
|
||||
)
|
||||
const latestPreviousAvatarUrls = new Map<string, string>()
|
||||
for (const message of previousArchive.messages) {
|
||||
if (!message.exportAvatarUrl) continue
|
||||
const conversationId = message.exportConversationId || targets[0].userMd5
|
||||
latestPreviousAvatarUrls.set(
|
||||
avatarIdentityKey(conversationId, message),
|
||||
message.exportAvatarUrl
|
||||
)
|
||||
}
|
||||
const reusablePreviousMessages = new Map<Message, Message>()
|
||||
for (const message of messages) {
|
||||
const previous = previousMessagesByKey.get(exportMessageKey(message))
|
||||
if (
|
||||
previous &&
|
||||
htmlArchiveSourceSignature(previous) === htmlArchiveSourceSignature(message)
|
||||
) {
|
||||
reusablePreviousMessages.set(message, previous)
|
||||
}
|
||||
}
|
||||
const resourceExistence = new Map<string, Promise<boolean>>()
|
||||
const resourceExists = (value?: string): Promise<boolean> => {
|
||||
if (!value) return Promise.resolve(false)
|
||||
const existing = resourceExistence.get(value)
|
||||
if (existing) return existing
|
||||
const pending = htmlArchiveResourceExists(outputDir, value)
|
||||
resourceExistence.set(value, pending)
|
||||
return pending
|
||||
}
|
||||
const markResourceExists = (value: string): void => {
|
||||
resourceExistence.set(value, Promise.resolve(true))
|
||||
}
|
||||
await fs.mkdir(join(outputDir, 'voices'), { recursive: true })
|
||||
await fs.mkdir(join(outputDir, 'media'), { recursive: true })
|
||||
await fs.mkdir(join(outputDir, 'files'), { recursive: true })
|
||||
await fs.mkdir(join(outputDir, 'avatars'), { recursive: true })
|
||||
const client = chat.getChatDb()?.getWcdb4Client()
|
||||
const avatarUsernames = Array.from(
|
||||
new Set(
|
||||
messages
|
||||
@@ -409,10 +800,14 @@ export async function runExport(
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
)
|
||||
const requestedAvatarUrls = Object.assign(
|
||||
{},
|
||||
...targets.map((target) => target.avatarUrls || {})
|
||||
) as Record<string, string>
|
||||
const avatarMap =
|
||||
request.includeAvatars === false
|
||||
? {}
|
||||
: { ...chat.getContactAvatars(avatarUsernames), ...(request.avatarUrls || {}) }
|
||||
: { ...(await chat.getContactAvatars(avatarUsernames)), ...requestedAvatarUrls }
|
||||
const imageConfig = imageKeys.getConfig()
|
||||
const imageService =
|
||||
client && imageConfig.aesKey
|
||||
@@ -422,13 +817,75 @@ export async function runExport(
|
||||
const stickerService = client ? new StickerService(client) : null
|
||||
const fileService = client ? new FileAssetService(client) : null
|
||||
const exportedAvatars = new Map<string, string>()
|
||||
const previousAvatarVersions = previousArchive.avatarVersions || {}
|
||||
const avatarVersions: Record<string, HtmlExportAvatarVersion> = {
|
||||
...previousAvatarVersions
|
||||
}
|
||||
const resolveExportAvatar = async (
|
||||
identity: string,
|
||||
source: string,
|
||||
legacyAvatarUrl?: string
|
||||
): Promise<string | undefined> => {
|
||||
const sourceHash = avatarSourceHash(source)
|
||||
const previousVersion = previousAvatarVersions[identity]
|
||||
const resolved = await readAvatarAsset(source)
|
||||
if (!resolved?.buffer) return undefined
|
||||
const visualHash = avatarVisualHash(resolved.buffer)
|
||||
const previousAvatarUrl = previousVersion?.avatarUrl || legacyAvatarUrl
|
||||
if (previousAvatarUrl && (await resourceExists(previousAvatarUrl))) {
|
||||
const previousVisualHash =
|
||||
previousVersion?.visualHash ||
|
||||
avatarVisualHash(await fs.readFile(join(outputDir, previousAvatarUrl)))
|
||||
if (avatarsLookTheSame(previousVisualHash, visualHash)) {
|
||||
avatarVersions[identity] = {
|
||||
sourceHash,
|
||||
visualHash: visualHash || previousVisualHash || undefined,
|
||||
avatarUrl: previousAvatarUrl
|
||||
}
|
||||
return previousAvatarUrl
|
||||
}
|
||||
}
|
||||
|
||||
const avatarName = avatarFileName(resolved.buffer, resolved.extension || 'jpg')
|
||||
const avatarUrl = `avatars/${avatarName}`
|
||||
if (!(await resourceExists(avatarUrl))) {
|
||||
await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer)
|
||||
markResourceExists(avatarUrl)
|
||||
}
|
||||
avatarVersions[identity] = {
|
||||
sourceHash,
|
||||
visualHash: visualHash || undefined,
|
||||
avatarUrl
|
||||
}
|
||||
return avatarUrl
|
||||
}
|
||||
const conversationAvatarUrls = new Map<string, string>()
|
||||
if (request.includeAvatars !== false) {
|
||||
for (const target of targets) {
|
||||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||||
if (!target.avatarUrl) continue
|
||||
const avatarUrl = await resolveExportAvatar(
|
||||
`conversation:${target.userMd5}`,
|
||||
target.avatarUrl,
|
||||
previousArchive.conversations.find((item) => item.id === target.userMd5)?.avatarUrl
|
||||
)
|
||||
if (avatarUrl) conversationAvatarUrls.set(target.userMd5, avatarUrl)
|
||||
}
|
||||
}
|
||||
const voiceService =
|
||||
request.includeMedia && chat.getChatDb()
|
||||
? new VoiceService(chat.getChatDb()!.getWcdb4Client())
|
||||
: null
|
||||
if (voiceService) {
|
||||
for (const message of messages) {
|
||||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||||
if (kindOf(message) !== 'voice') continue
|
||||
const previous = reusablePreviousMessages.get(message)
|
||||
if (previous?.voiceDataUrl && (await resourceExists(previous.voiceDataUrl))) {
|
||||
message.voiceDataUrl = previous.voiceDataUrl
|
||||
message.voiceDuration = previous.voiceDuration
|
||||
continue
|
||||
}
|
||||
if (!message.sessionId || message.localId == null || !message.createTime) {
|
||||
keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
|
||||
continue
|
||||
@@ -450,10 +907,14 @@ export async function runExport(
|
||||
keepMediaError(request, message, reason)
|
||||
continue
|
||||
}
|
||||
const voiceName = `voice_${hashPart(exportMessageKey(message, request.userMd5))}.wav`
|
||||
const audioBuffer = Buffer.from(voice.data, 'base64')
|
||||
const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav`
|
||||
const voiceUrl = `voices/${voiceName}`
|
||||
if (!(await resourceExists(voiceUrl))) {
|
||||
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
|
||||
message.voiceDataUrl = `voices/${voiceName}`
|
||||
markResourceExists(voiceUrl)
|
||||
}
|
||||
message.voiceDataUrl = voiceUrl
|
||||
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
|
||||
if (request.includeVoiceTranscripts) {
|
||||
if (!voiceRecognition) {
|
||||
@@ -488,22 +949,41 @@ export async function runExport(
|
||||
}
|
||||
}
|
||||
for (const [index, message] of messages.entries()) {
|
||||
if (!jobs.has(request.jobId)) {
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'cancelled',
|
||||
processed: index,
|
||||
total: messages.length,
|
||||
percent: 15 + Math.round((index / Math.max(messages.length, 1)) * 75)
|
||||
})
|
||||
return { success: false, error: '已取消' }
|
||||
}
|
||||
const conversationId = message.exportConversationId || targets[0].userMd5
|
||||
message.exportShowAvatar = request.includeAvatars !== false
|
||||
const previous = reusablePreviousMessages.get(message)
|
||||
const previousMessage = previousMessagesByKey.get(exportMessageKey(message))
|
||||
const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img
|
||||
const resolvedAvatar = avatar ? await readAvatarAsset(avatar) : null
|
||||
const avatarBuffer = resolvedAvatar?.buffer || null
|
||||
const avatarExtension = resolvedAvatar?.extension || 'jpg'
|
||||
if (avatarBuffer) {
|
||||
const avatarKey =
|
||||
message.senderId || avatar || `message_${exportMessageKey(message, request.userMd5)}`
|
||||
let avatarName = exportedAvatars.get(avatarKey)
|
||||
if (!avatarName) {
|
||||
avatarName = `avatar_${hashPart(avatarKey)}.${avatarExtension}`
|
||||
await fs.writeFile(join(outputDir, 'avatars', avatarName), avatarBuffer)
|
||||
exportedAvatars.set(avatarKey, avatarName)
|
||||
const avatarKey = avatarIdentityKey(conversationId, message)
|
||||
let avatarUrl: string | undefined
|
||||
if (
|
||||
request.includeAvatars !== false &&
|
||||
previousMessage?.exportAvatarUrl &&
|
||||
(await resourceExists(previousMessage.exportAvatarUrl))
|
||||
) {
|
||||
avatarUrl = previousMessage.exportAvatarUrl
|
||||
} else if (request.includeAvatars !== false && avatar) {
|
||||
avatarUrl = exportedAvatars.get(avatarKey)
|
||||
if (!avatarUrl) {
|
||||
avatarUrl = await resolveExportAvatar(
|
||||
avatarKey,
|
||||
avatar,
|
||||
latestPreviousAvatarUrls.get(avatarKey)
|
||||
)
|
||||
if (avatarUrl) exportedAvatars.set(avatarKey, avatarUrl)
|
||||
}
|
||||
message.exportAvatarUrl = `avatars/${avatarName}`
|
||||
}
|
||||
if (avatarUrl) message.exportAvatarUrl = avatarUrl
|
||||
if (!request.includeMedia || !message.contentData) {
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
@@ -514,6 +994,32 @@ export async function runExport(
|
||||
})
|
||||
continue
|
||||
}
|
||||
const reusableMediaType =
|
||||
message.contentData.type === 'image' ||
|
||||
message.contentData.type === 'video' ||
|
||||
message.contentData.type === 'sticker'
|
||||
? message.contentData.type
|
||||
: message.contentData.type === 'share' && message.contentData.typeVal === '6'
|
||||
? 'file'
|
||||
: null
|
||||
if (
|
||||
reusableMediaType &&
|
||||
previous?.exportMediaUrl &&
|
||||
(!previous.exportMediaType || previous.exportMediaType === reusableMediaType) &&
|
||||
(await resourceExists(previous.exportMediaUrl))
|
||||
) {
|
||||
message.exportMediaUrl = previous.exportMediaUrl
|
||||
message.exportMediaType = reusableMediaType
|
||||
message.exportMediaName = previous.exportMediaName
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'writing',
|
||||
processed: index + 1,
|
||||
total: messages.length,
|
||||
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (message.contentData.type === 'image') {
|
||||
if (!imageService) {
|
||||
keepMediaError(request, message, '未配置图片解密密钥,无法导出图片')
|
||||
@@ -529,7 +1035,7 @@ export async function runExport(
|
||||
allowThumbnail: attempt.allowThumbnail,
|
||||
preferThumbnail: attempt.preferThumbnail,
|
||||
sessionId: message.sessionId,
|
||||
sessionMd5: request.userMd5,
|
||||
sessionMd5: conversationId,
|
||||
createTime: message.createTime
|
||||
}
|
||||
)
|
||||
@@ -554,9 +1060,13 @@ export async function runExport(
|
||||
}
|
||||
const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null
|
||||
if (decoded) {
|
||||
const name = `image_${hashPart(exportMessageKey(message, request.userMd5))}.${decoded.extension}`
|
||||
const name = `image_${bufferHashPart(decoded.buffer)}.${decoded.extension}`
|
||||
const mediaUrl = `media/${name}`
|
||||
if (!(await resourceExists(mediaUrl))) {
|
||||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
markResourceExists(mediaUrl)
|
||||
}
|
||||
message.exportMediaUrl = mediaUrl
|
||||
message.exportMediaType = 'image'
|
||||
if (usedFallback) {
|
||||
keepMediaError(request, message, '原图不可用,已降级使用缩略图')
|
||||
@@ -584,21 +1094,26 @@ export async function runExport(
|
||||
} else if (hashes.length === 0) {
|
||||
keepMediaError(request, message, '视频标识不完整,无法定位本地视频')
|
||||
} else {
|
||||
const resolved = videoService.resolve(hashes)
|
||||
const resolved = await videoService.resolve(hashes, {
|
||||
createTime: message.createTime,
|
||||
duration: message.contentData.duration,
|
||||
width: message.contentData.width,
|
||||
height: message.contentData.height
|
||||
})
|
||||
const source = resolved.url ? videoService.pathForUrl(resolved.url) : undefined
|
||||
if (!resolved.success || !source) {
|
||||
keepMediaError(request, message, resolved.error || '视频文件缺失或已移动')
|
||||
} else if (extname(source).toLowerCase() !== '.mp4') {
|
||||
keepMediaError(request, message, '视频格式不支持,仅支持本地 MP4 文件')
|
||||
} else {
|
||||
const name = `video_${hashPart(exportMessageKey(message, request.userMd5))}.mp4`
|
||||
const copied = await copyExportAsset(source, join(outputDir, 'media', name))
|
||||
if (copied.success) {
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'video'
|
||||
} else {
|
||||
keepMediaError(request, message, `视频复制失败:${copied.error}`)
|
||||
const name = `video_${await fileHashPart(source)}.mp4`
|
||||
const mediaUrl = `media/${name}`
|
||||
if (!(await resourceExists(mediaUrl))) {
|
||||
await copyWritableExportFile(source, join(outputDir, 'media', name))
|
||||
markResourceExists(mediaUrl)
|
||||
}
|
||||
message.exportMediaUrl = mediaUrl
|
||||
message.exportMediaType = 'video'
|
||||
}
|
||||
}
|
||||
} else if (message.contentData.type === 'sticker' && stickerService) {
|
||||
@@ -610,9 +1125,13 @@ export async function runExport(
|
||||
? await readAvatarAsset(stickerSource)
|
||||
: null
|
||||
if (decoded) {
|
||||
const name = `sticker_${hashPart(exportMessageKey(message, request.userMd5))}.${decoded.extension}`
|
||||
const name = `sticker_${bufferHashPart(decoded.buffer)}.${decoded.extension}`
|
||||
const mediaUrl = `media/${name}`
|
||||
if (!(await resourceExists(mediaUrl))) {
|
||||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
markResourceExists(mediaUrl)
|
||||
}
|
||||
message.exportMediaUrl = mediaUrl
|
||||
message.exportMediaType = 'sticker'
|
||||
} else {
|
||||
keepMediaError(request, message, result.error || '表情资源缺失或下载失败')
|
||||
@@ -625,18 +1144,15 @@ export async function runExport(
|
||||
if (!resolved.success || !resolved.filePath || !resolved.fileName) {
|
||||
keepMediaError(request, message, resolved.error || '本地文件附件缺失')
|
||||
} else {
|
||||
const name = `file_${hashPart(exportMessageKey(message, request.userMd5))}_${safeFilePart(resolved.fileName)}`
|
||||
const copied = await copyExportAsset(
|
||||
resolved.filePath,
|
||||
join(outputDir, 'media', name)
|
||||
)
|
||||
if (copied.success) {
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
const name = `file_${await fileHashPart(resolved.filePath)}_${safeFilePart(resolved.fileName)}`
|
||||
const mediaUrl = `files/${name}`
|
||||
if (!(await resourceExists(mediaUrl))) {
|
||||
await copyWritableExportFile(resolved.filePath, join(outputDir, 'files', name))
|
||||
markResourceExists(mediaUrl)
|
||||
}
|
||||
message.exportMediaUrl = mediaUrl
|
||||
message.exportMediaType = 'file'
|
||||
message.exportMediaName = message.contentData.title || resolved.fileName
|
||||
} else {
|
||||
keepMediaError(request, message, `附件复制失败:${copied.error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -651,26 +1167,57 @@ export async function runExport(
|
||||
const mergedMessages = mergeHtmlArchiveMessages(
|
||||
previousArchive.messages,
|
||||
messages,
|
||||
request.userMd5
|
||||
'',
|
||||
targets.map((target) => target.userMd5)
|
||||
)
|
||||
const normalizedMessages = stripHtmlArchiveInlineAvatars(
|
||||
normalizeHtmlArchiveSelfNames(mergedMessages, selfInfo)
|
||||
)
|
||||
const archive: HtmlExportArchive = {
|
||||
version: 1,
|
||||
sourceId: request.userMd5,
|
||||
name: request.name,
|
||||
version: 2,
|
||||
name: archiveName,
|
||||
exportedAt: new Date().toISOString(),
|
||||
messages: normalizeHtmlArchiveSelfNames(mergedMessages, selfInfo)
|
||||
conversations: targets.map((target) => ({
|
||||
id: target.userMd5,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
avatarUrl:
|
||||
conversationAvatarUrls.get(target.userMd5) ||
|
||||
previousArchive.conversations.find((item) => item.id === target.userMd5)?.avatarUrl,
|
||||
messageCount: normalizedMessages.filter(
|
||||
(message) => message.exportConversationId === target.userMd5
|
||||
).length
|
||||
})),
|
||||
messages: normalizedMessages,
|
||||
avatarVersions
|
||||
}
|
||||
await fs.writeFile(outputPath, renderExportPage(request.name), 'utf8')
|
||||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||||
await fs.writeFile(outputPath, renderExportPage(archiveName), 'utf8')
|
||||
await writeHtmlArchive(outputDir, archive)
|
||||
await pruneHtmlArchiveResources(outputDir, archive)
|
||||
let completedPath = outputPath
|
||||
if (request.zip) {
|
||||
if (!jobs.has(request.jobId)) return { success: false, error: '已取消' }
|
||||
const zipPath = join(root, `${outputFolder}.zip`)
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'compressing',
|
||||
processed: archive.messages.length,
|
||||
total: archive.messages.length,
|
||||
percent: 95
|
||||
})
|
||||
await writeZipArchive(outputDir, zipPath, outputFolder, request.jobId)
|
||||
completedPath = zipPath
|
||||
}
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'completed',
|
||||
processed: archive.messages.length,
|
||||
total: archive.messages.length,
|
||||
percent: 100,
|
||||
outputPath
|
||||
outputPath: completedPath
|
||||
})
|
||||
return { success: true, outputPath, messageCount: archive.messages.length }
|
||||
return { success: true, outputPath: completedPath, messageCount: archive.messages.length }
|
||||
} else {
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
@@ -680,7 +1227,7 @@ export async function runExport(
|
||||
percent: 90
|
||||
})
|
||||
}
|
||||
await fs.writeFile(outputPath, render(request.format, messages, request.name), 'utf8')
|
||||
await fs.writeFile(outputPath, render(request.format, messages, archiveName), 'utf8')
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'completed',
|
||||
@@ -692,6 +1239,10 @@ export async function runExport(
|
||||
return { success: true, outputPath, messageCount: messages.length }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (!jobs.has(request.jobId) || message === '已取消') {
|
||||
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, error: '已取消' })
|
||||
return { success: false, error: '已取消' }
|
||||
}
|
||||
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
@@ -700,6 +1251,7 @@ export async function runExport(
|
||||
}
|
||||
export function cancelExport(jobId: string): void {
|
||||
jobs.delete(jobId)
|
||||
activeArchives.get(jobId)?.abort()
|
||||
}
|
||||
export async function revealExport(path: string): Promise<void> {
|
||||
shell.showItemInFolder(path)
|
||||
|
||||
+10
-3
@@ -1231,14 +1231,21 @@ app.whenReady().then(async () => {
|
||||
return stickerService.resolveSticker(cdnUrl, md5)
|
||||
})
|
||||
|
||||
ipcMain.handle('db:getVideo', async (_, hashes: string[]) => {
|
||||
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 : [])
|
||||
})
|
||||
return videoAssetService.resolve(Array.isArray(hashes) ? hashes : [], options)
|
||||
}
|
||||
)
|
||||
|
||||
// -------- Settings & API service --------
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1286,10 +1286,12 @@ 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}`
|
||||
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
@@ -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 {
|
||||
|
||||
Vendored
+2
-1
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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,7 +129,9 @@ export function ExportPreviewPanel({
|
||||
<span style={{ width: `${progress?.percent ?? 0}%` }} />
|
||||
</div>
|
||||
<strong>
|
||||
{progress?.phase === 'writing'
|
||||
{progress?.phase === 'compressing'
|
||||
? `正在压缩资源包... ${progress.percent ?? 0}%`
|
||||
: progress?.phase === 'writing'
|
||||
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
|
||||
: `正在读取消息... ${progress?.percent ?? 0}%`}
|
||||
</strong>
|
||||
|
||||
@@ -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,16 +65,21 @@ export function ExportTaskCenter({
|
||||
{tasks.length === 0 ? (
|
||||
<p>暂无导出记录</p>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
tasks.map((task) => {
|
||||
const detail = taskDetail(task)
|
||||
return (
|
||||
<div className="export-task-row" key={task.jobId}>
|
||||
<span>
|
||||
<strong>{task.contactName}</strong>
|
||||
<strong>{task.targetLabel}</strong>
|
||||
<small>
|
||||
{task.format.toUpperCase()} · {task.progress.phase}
|
||||
{task.format.toUpperCase()} · {phaseLabels[task.progress.phase]}
|
||||
</small>
|
||||
{task.progress.error && (
|
||||
<small className="export-task-error" title={task.progress.error}>
|
||||
{task.progress.error}
|
||||
{detail && (
|
||||
<small
|
||||
className={`export-task-detail ${task.status}`}
|
||||
title={task.status === 'failed' ? detail : undefined}
|
||||
>
|
||||
{detail}
|
||||
</small>
|
||||
)}
|
||||
</span>
|
||||
@@ -74,7 +98,8 @@ export function ExportTaskCenter({
|
||||
</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,9 +135,14 @@ 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'
|
||||
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: '备注' },
|
||||
@@ -91,66 +153,41 @@ export function ExportWorkspace({
|
||||
{ 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 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] =
|
||||
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) exportAvatarUrls[member.wxid] = member.avatar
|
||||
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="" />
|
||||
<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="" />
|
||||
) : (
|
||||
activeName.slice(0, 1)
|
||||
displayName(contact).slice(0, 1)
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
<h1>导出设置</h1>
|
||||
<p>
|
||||
{activeName}
|
||||
{activeContact?.type === 'group' ? ' · 群聊' : ''}
|
||||
</p>
|
||||
))}
|
||||
</span>
|
||||
<span className="export-config-title">
|
||||
<h1>导出设置</h1>
|
||||
<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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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&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 })
|
||||
}
|
||||
})
|
||||
@@ -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' })
|
||||
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!
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('EPERM: operation not permitted, copyfile'), { code: 'EPERM' })
|
||||
const imagePath = join(
|
||||
outputDir,
|
||||
firstArchive.messages.find((item) => item.id === 'image')!.exportMediaUrl!
|
||||
)
|
||||
.mockImplementation(originalCopyFile)
|
||||
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,
|
||||
|
||||
@@ -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,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>',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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: '本地未找到该视频文件' })
|
||||
})
|
||||
})
|
||||
@@ -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)])
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user