diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5e5507c..440b60f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/package.json b/package.json index b1bc64c..9638455 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a41dc85..3348e1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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'} diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index d50c86c..3390cd7 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -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 diff --git a/src/main/export-html-template.ts b/src/main/export-html-template.ts index 98e2356..2f6453a 100644 --- a/src/main/export-html-template.ts +++ b/src/main/export-html-template.ts @@ -20,8 +20,50 @@ body { color: var(--text); font: 14px system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; } +.archive-loading { + position: fixed; + inset: 0; + z-index: 20; + display: grid; + place-content: center; + justify-items: center; + gap: 10px; + padding: 24px; + background: var(--page); + color: var(--text); + text-align: center; + transition: opacity .2s ease, visibility .2s ease; +} +.archive-loading.complete { + visibility: hidden; + opacity: 0; + pointer-events: none; +} +.archive-loading-indicator { + width: 34px; + height: 34px; + margin-bottom: 4px; + border: 3px solid #c9d9d1; + border-top-color: var(--accent); + border-radius: 50%; + animation: archive-loading-spin .8s linear infinite; +} +.archive-loading strong { font-size: 16px; } +.archive-loading span { color: var(--muted); font-size: 13px; } +.archive-loading.error .archive-loading-indicator { + display: grid; + border: 0; + animation: none; + place-items: center; + color: #a33b32; + font-size: 28px; +} +.archive-loading.error .archive-loading-indicator::before { content: '!'; } +@keyframes archive-loading-spin { to { transform: rotate(360deg); } } .page { + width: 100%; max-width: 1380px; + min-width: 0; height: 100vh; margin: auto; padding: 20px 24px; @@ -30,6 +72,8 @@ body { } .toolbar { display: grid; + width: 100%; + min-width: 0; grid-template-columns: minmax(220px, 1fr) auto; gap: 14px 24px; align-items: center; @@ -39,8 +83,14 @@ body { padding: 16px 20px; box-shadow: 0 8px 24px #29483b12; } +.archive-heading { + display: flex; + align-items: center; + gap: 8px 12px; + min-width: 0; + flex-wrap: wrap; +} .title { font-size: 18px; font-weight: 750; } -.meta { color: var(--muted); margin-left: 12px; font-size: 13px; } .controls { display: flex; gap: 8px; align-items: center; justify-content: flex-end; } .controls input, .filter-button { border: 1px solid var(--border); @@ -63,13 +113,165 @@ body { .count { margin-left: auto; color: var(--muted); font-size: 13px; } .archive-layout { display: grid; - grid-template-columns: 150px minmax(0, 1fr); + width: 100%; + max-width: 100%; + min-width: 0; + grid-template-columns: 168px minmax(0, 1fr); gap: 18px; min-height: 0; flex: 1; margin-top: 16px; } +.archive-layout.single-conversation { grid-template-columns: 168px minmax(0, 1fr); } +.archive-navigation { + display: flex; + width: 100%; + max-width: 100%; + flex-direction: column; + gap: 10px; + min-width: 0; + min-height: 0; +} +.conversation-filter { + position: relative; + display: inline-flex; + flex: 0 1 auto; + min-width: 0; + max-width: 100%; + height: 42px; + border-radius: 10px; + transition: background .15s ease; +} +.conversation-filter[hidden] { display: none; } +.conversation-trigger { + display: flex; + width: auto; + max-width: 280px; + min-width: 0; + align-items: center; + gap: 9px; + border: 0; + border-radius: 10px; + padding: 5px 9px 5px 6px; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: background .15s ease; +} +.conversation-trigger:hover, .conversation-trigger[aria-expanded=true] { background: #edf5f1; } +.conversation-trigger:focus-visible { outline: 2px solid #5b9785; outline-offset: 1px; } +.conversation-trigger-avatar { + position: relative; + display: grid; + width: 30px; + height: 30px; + flex: 0 0 30px; + overflow: hidden; + border-radius: 8px; + place-items: center; + background: #dcebe4; + color: var(--accent); + font-size: 12px; + font-weight: 700; +} +.conversation-trigger-avatar img { width: 100%; height: 100%; object-fit: cover; } +.conversation-trigger-avatar.multiple { overflow: visible; background: transparent; } +.conversation-trigger-avatar.multiple img { + position: absolute; + width: 21px; + height: 21px; + border: 2px solid #f7fbf9; + border-radius: 6px; +} +.conversation-trigger-avatar.multiple img:first-child { top: 0; left: 0; } +.conversation-trigger-avatar.multiple img:last-child { right: 0; bottom: 0; } +.conversation-trigger-name { + flex: 0 1 auto; + min-width: 0; + max-width: 220px; + overflow: hidden; + color: var(--text); + font-size: 15px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} +.conversation-switch-icon { + width: 14px; + height: 14px; + flex: 0 0 14px; + color: #5c6963; + stroke-width: 2; +} +.conversation-menu { + position: absolute; + z-index: 12; + top: calc(100% + 7px); + left: 0; + width: max(100%, 240px); + max-width: calc(100vw - 32px); + max-height: min(360px, 55vh); + overflow-y: auto; + border: 0; + border-radius: 10px; + padding: 6px; + background: #fff; + box-shadow: 0 10px 30px #19392d2b, 0 2px 8px #19392d14; +} +.conversation-menu[hidden] { display: none; } +.conversation-option { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + gap: 10px; + border: 0; + border-radius: 7px; + padding: 8px; + background: transparent; + color: var(--text); + font: inherit; + text-align: left; + cursor: pointer; +} +.conversation-option:hover, .conversation-option:focus-visible { background: #f0f6f3; outline: 0; } +.conversation-option[aria-selected=true] { background: var(--accent-soft); color: var(--accent); } +.conversation-option-avatar { + display: grid; + width: 30px; + height: 30px; + flex: 0 0 30px; + overflow: hidden; + border-radius: 8px; + place-items: center; + background: #dcebe4; + color: var(--accent); + font-size: 12px; + font-weight: 700; +} +.conversation-option-avatar img { width: 100%; height: 100%; object-fit: cover; } +.conversation-option-avatar.multiple { position: relative; overflow: visible; background: transparent; } +.conversation-option-avatar.multiple img { + position: absolute; + width: 21px; + height: 21px; + border: 2px solid #fff; + border-radius: 6px; +} +.conversation-option-avatar.multiple img:first-child { top: 0; left: 0; } +.conversation-option-avatar.multiple img:last-child { right: 0; bottom: 0; } +.conversation-option-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.conversation-option-check { width: 16px; flex: 0 0 16px; color: var(--accent); text-align: center; } .timeline { + flex: 1; overflow: auto; background: #f7faf8; border: 1px solid var(--border); @@ -77,7 +279,36 @@ body { padding: 12px 9px; } .timeline-empty { padding: 10px; color: var(--muted); font-size: 12px; } -.timeline-year { margin: 6px 7px 5px; color: var(--text); font-size: 13px; font-weight: 700; } +.timeline-year-group + .timeline-year-group { margin-top: 4px; } +.timeline-year { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + margin: 0; + padding: 7px 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text); + font: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; + text-align: left; +} +.timeline-year::after { + content: '⌄'; + color: var(--muted); + font-size: 16px; + font-weight: 400; + line-height: 1; + transform: rotate(-90deg); + transition: transform .15s ease; +} +.timeline-year:hover { background: #eaf2ee; } +.timeline-year[aria-expanded=true]::after { transform: rotate(0); } +.timeline-months[hidden] { display: none; } .timeline-month { width: 100%; display: flex; @@ -98,7 +329,7 @@ body { color: var(--accent); } .timeline-month small { color: inherit; } -.scroll { overflow: auto; min-width: 0; padding: 10px 8px 36px; } +.scroll { width: 100%; max-width: 100%; overflow: auto; min-width: 0; padding: 10px 8px 36px; } .lazy-hint { width: min(100%, 820px); margin: 0 auto 12px; @@ -114,16 +345,92 @@ body { flex-direction: column; gap: 6px; width: min(100%, 820px); + min-width: 0; margin: 0 auto 22px; } +.locate-all { + position: relative; + display: grid; + width: 28px; + height: 28px; + flex: 0 0 auto; + border: 1px solid #b8d4c7; + border-radius: 50%; + padding: 0; + place-items: center; + background: #fff; + color: var(--accent); + font: inherit; + cursor: pointer; + opacity: 0; + pointer-events: none; + box-shadow: 0 3px 10px #29483b1a; + transition: opacity .15s ease, background .15s ease; +} +.locate-icon { font-size: 16px; line-height: 1; } +.locate-label { + position: absolute; + z-index: 3; + top: 50%; + left: calc(100% + 7px); + border: 1px solid #b8d4c7; + border-radius: 7px; + padding: 5px 8px; + background: #fff; + color: var(--accent); + font-size: 11px; + font-weight: 650; + line-height: 1.4; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transform: translate(-2px, -50%); + box-shadow: 0 3px 10px #29483b1a; + transition: opacity .15s ease, transform .15s ease; +} +.message.sent .locate-label { right: calc(100% + 7px); left: auto; transform: translate(2px, -50%); } +.message:hover .locate-all, .locate-all:focus-visible { + opacity: 1; + pointer-events: auto; +} +.locate-all:hover, .locate-all:focus-visible { background: var(--accent-soft); outline: none; } +.locate-all:hover .locate-label, .locate-all:focus-visible .locate-label { + opacity: 1; + transform: translate(0, -50%); +} +@media (hover: none) { + .locate-all { opacity: 1; pointer-events: auto; } + .locate-label { display: none; } +} +.message.located .bubble { animation: locate-message 1.5s ease-out; } +@keyframes locate-message { + 0%, 32% { outline: 3px solid #36a477; outline-offset: 3px; } + 100% { outline: 3px solid transparent; outline-offset: 3px; } +} +@media (prefers-reduced-motion: reduce) { + .message.located .bubble { animation: none; outline: 3px solid #36a477; outline-offset: 3px; } + .archive-loading-indicator { animation: none; } +} .message.sent { align-items: flex-end; } .message.system { align-items: center; } -.message.system .row { justify-content: center; } +.message.system .row { + position: relative; + width: fit-content; + max-width: 92%; + justify-content: center; +} +.message.system .locate-all { + position: absolute; + top: 50%; + left: calc(100% + 10px); + transform: translateY(-50%); +} .message.system .avatar { display: none; } .message.system .message-stack { max-width: 92%; } .message.system .bubble { + max-width: 100%; padding: 5px 10px; border: 0; border-radius: 5px; @@ -135,7 +442,15 @@ body { } .message.system .sender { display: none; } .time { color: var(--muted); font-size: 11px; margin: 0 12px; } -.row { display: flex; gap: 12px; align-items: flex-end; max-width: 100%; } +.conversation-source { + display: inline-block; + margin-left: 8px; + padding: 2px 6px; + border-radius: 5px; + background: var(--accent-soft); + color: var(--accent); +} +.row { display: flex; width: 100%; min-width: 0; gap: 12px; align-items: flex-end; max-width: 100%; } .sent .row { flex-direction: row-reverse; } .avatar { width: 38px; @@ -167,6 +482,14 @@ body { .sender { color: var(--muted); font-size: 12px; margin-bottom: 5px; } .content { line-height: 1.7; word-break: break-word; white-space: pre-wrap; } .audio-wrap { width: 380px; max-width: 100%; min-width: 0; } +.search-highlight { + border-radius: 3px; + padding: 0 1px; + background: #ffe58f; + color: inherit; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} .audio { display: block; width: 100%; max-width: 100%; height: 38px; } .voice-transcript { width: 100%; @@ -217,6 +540,95 @@ body { } .quote-reference strong { font-weight: 650; color: var(--text); } .quote-reference span { white-space: pre-wrap; } +.structured-content { + display: grid; + gap: 7px; + width: min(420px, 100%); + min-width: min(240px, 100%); +} +.structured-kicker { + color: var(--accent); + font-size: 11px; + font-weight: 700; +} +.structured-title { + color: var(--text); + font-size: 15px; + font-weight: 700; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} +.structured-description { + color: var(--muted); + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} +.structured-footer { + display: flex; + gap: 6px; + align-items: center; + padding-top: 6px; + border-top: 1px solid var(--border); + color: var(--muted); + font-size: 11px; +} +.structured-link { color: inherit; text-decoration: none; } +.structured-link:hover .structured-title { color: var(--accent); } +.location-content { + gap: 8px; + padding-top: 8px; + border-top: 3px solid #3f8f76; +} +.location-content .structured-footer { justify-content: space-between; } +.location-coordinates { + color: var(--muted); + font-size: 11px; + font-variant-numeric: tabular-nums; +} +.location-action { color: var(--accent); font-weight: 650; } +.mini-program-content { border-top: 3px solid #4d9d75; padding-top: 8px; } +.mini-program-preview { + display: block; + width: 100%; + max-height: 260px; + border-radius: 8px; + object-fit: cover; + cursor: zoom-in; +} +.mini-program-icon { + width: 20px; + height: 20px; + flex: 0 0 auto; + border-radius: 4px; + object-fit: cover; +} +.forward-content details { margin-top: 2px; } +.forward-content summary { + color: var(--accent); + cursor: pointer; + font-size: 12px; + font-weight: 650; +} +.forward-list, .forward-list ol { + display: grid; + gap: 7px; + margin: 8px 0 0; + padding-left: 18px; +} +.forward-item { color: var(--muted); font-size: 12px; line-height: 1.5; } +.forward-item strong { color: var(--text); font-weight: 650; } +.forward-item time { margin-left: 6px; font-size: 10px; } +.forward-item span { display: block; white-space: pre-wrap; word-break: break-word; } +.payment-content { + gap: 4px; + padding-left: 12px; + border-left: 4px solid #d15246; +} +.payment-content.transfer { border-left-color: var(--accent); } +.payment-content .structured-title { font-size: 16px; } .media-image { display: block; max-width: 100%; @@ -264,15 +676,102 @@ body { cursor: pointer; } @media (max-width: 760px) { + html, body { width: 100%; max-width: 100%; overflow-x: hidden; overscroll-behavior-x: none; } .page { padding: 10px; } - .toolbar { grid-template-columns: 1fr; padding: 13px; } - .controls { justify-content: flex-start; } - .controls input[type=search] { width: 100%; } - .filters { grid-column: 1; } - .count { width: 100%; margin-left: 0; } - .archive-layout { grid-template-columns: 1fr; margin-top: 10px; } - .timeline { display: flex; gap: 6px; overflow: auto; padding: 8px; } - .timeline-year { display: none; } + .toolbar { + grid-template-columns: minmax(112px, .7fr) minmax(0, 1.3fr); + gap: 8px; + padding: 10px; + border-radius: 14px; + } + .archive-heading { + grid-column: 1; + min-height: 36px; + gap: 4px; + align-items: center; + flex-wrap: nowrap; + } + .archive-heading .title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .conversation-filter:not([hidden]) { + display: inline-flex; + width: 100%; + min-width: 0; + max-width: 100%; + flex: 1 1 auto; + height: 36px; + } + .conversation-trigger { width: 100%; max-width: none; gap: 6px; padding: 4px 6px 4px 4px; } + .conversation-trigger-avatar { width: 26px; height: 26px; flex-basis: 26px; border-radius: 7px; } + .conversation-trigger-avatar.multiple img { width: 18px; height: 18px; border-radius: 5px; } + .conversation-trigger-name { flex: 1; max-width: none; font-size: 12px; } + .conversation-switch-icon { width: 13px; height: 13px; flex-basis: 13px; } + .conversation-menu { + width: min(250px, calc(100vw - 20px)); + max-width: none; + } + .controls { grid-column: 2; min-width: 0; justify-content: flex-start; } + .controls input[type=search] { + width: 100%; + min-width: 0; + height: 36px; + padding: 0 10px; + font-size: 16px; + } + .filters { + grid-column: 1 / -1; + display: flex; + gap: 4px; + flex-wrap: wrap; + } + .filter-button { + min-height: 30px; + border-radius: 8px; + padding: 4px 6px; + font-size: 12px; + line-height: 1.2; + } + .count { + display: block; + width: 100%; + flex: 0 0 100%; + margin-left: 0; + color: var(--muted); + font-size: 11px; + line-height: 1.4; + } + .archive-layout { grid-template-columns: 1fr; align-content: start; margin-top: 10px; } + .archive-layout.single-conversation { grid-template-columns: 1fr; } + .archive-navigation { align-self: start; gap: 8px; } + .timeline { + align-self: stretch; + display: flex; + width: 100%; + max-width: 100%; + gap: 6px; + overflow: auto; + padding: 8px; + } + .timeline-year-group { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 6px; + } + .timeline-year-group + .timeline-year-group { margin-top: 0; } + .timeline-year { + flex: 0 0 auto; + width: auto; + gap: 6px; + padding: 7px 8px; + white-space: nowrap; + } + .timeline-months { display: flex; gap: 6px; } + .timeline-months[hidden] { display: none; } .timeline-month { flex: 0 0 auto; width: auto; @@ -283,8 +782,14 @@ body { border-left-color: transparent; border-bottom-color: var(--accent); } - .bubble { max-width: calc(100vw - 92px); } - .audio-wrap { width: min(260px, calc(100vw - 130px)); } + .scroll { + overflow-x: hidden; + overscroll-behavior-x: none; + touch-action: pan-y; + } + .message:not(.system) .bubble { max-width: calc(100% - 50px); } + .message:not(.system) .row.has-locate .bubble { max-width: calc(100% - 90px); } + .audio-wrap { width: min(260px, 100%); } } ` @@ -299,26 +804,67 @@ const safe = (value: unknown): string => const renderExportScript = (name: string): string => ` (() => { 'use strict' + const loadingOverlay = document.querySelector('#archive-loading') + const loadingTitle = document.querySelector('#archive-loading-title') + const loadingDetail = document.querySelector('#archive-loading-detail') + const finishLoading = () => { + if (!loadingOverlay) return + loadingOverlay.setAttribute('aria-busy', 'false') + loadingOverlay.classList.add('complete') + window.setTimeout(() => loadingOverlay.remove(), 250) + } + const failLoading = (message) => { + if (!loadingOverlay) return + loadingOverlay.setAttribute('aria-busy', 'false') + loadingOverlay.setAttribute('role', 'alert') + loadingOverlay.classList.add('error') + if (loadingTitle) loadingTitle.textContent = '无法加载聊天档案' + if (loadingDetail) loadingDetail.textContent = message + } + const initializeArchive = () => { + try { const PAGE_SIZE = ${EXPORT_PAGE_SIZE} const WINDOW_STEP = Math.floor(PAGE_SIZE / 2) const archive = window.__WECHAT_EXPORT__ || { name: ${JSON.stringify(name)}, messages: [] } - const allMessages = Array.isArray(archive.messages) ? archive.messages : [] + const legacyConversation = archive.sourceId + ? [{ id: archive.sourceId, name: archive.name || ${JSON.stringify(name)}, type: 'user', messageCount: 0 }] + : [] + const conversations = Array.isArray(archive.conversations) && archive.conversations.length + ? archive.conversations + : legacyConversation + const allMessages = (Array.isArray(archive.messages) ? archive.messages : []).map((message) => + message.exportConversationId || conversations.length !== 1 + ? message + : Object.assign({}, message, { + exportConversationId: conversations[0].id, + exportConversationName: conversations[0].name + }) + ) const list = document.querySelector('#messages') + const layout = document.querySelector('.archive-layout') + const conversationFilter = document.querySelector('#conversation-filter') + const conversationTrigger = document.querySelector('#conversation-trigger') + const conversationTriggerAvatar = document.querySelector('#conversation-trigger-avatar') + const conversationTriggerName = document.querySelector('#conversation-trigger-name') + const conversationMenu = document.querySelector('#conversation-menu') const timeline = document.querySelector('#timeline') const query = document.querySelector('#query') const count = document.querySelector('#count') - const meta = document.querySelector('#archive-meta') const title = document.querySelector('#archive-title') const filters = document.querySelector('#filters') const box = document.querySelector('#lightbox') const preview = document.querySelector('#lightbox-image') const closeButton = document.querySelector('#lightbox-close') let activeKind = 'all' + let activeConversation = 'all' let filtered = [] let windowStart = 0 let windowEnd = 0 let scrollLoadPending = false let scrollLoadSuppressed = false + let activeMonthUpdatePending = false + let expandedTimelineYear = '' + const tabPositions = new Map() let lastScrollTop = 0 let zoom = 1 @@ -334,6 +880,37 @@ const renderExportScript = (name: string): string => ` /[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] || character ) + const decodeEntities = (value) => { + const textarea = document.createElement('textarea') + textarea.innerHTML = String(value ?? '') + return textarea.value + } + const displayText = (value) => esc(decodeEntities(value)) + const externalUrl = (value) => { + const decoded = decodeEntities(value).trim() + if (!decoded) return '' + try { + const parsed = new URL(decoded) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? esc(decoded) : '' + } catch { + return '' + } + } + const imageUrl = (value) => { + const decoded = decodeEntities(value).trim() + if (decoded.toLowerCase().startsWith('data:image/')) return esc(decoded) + return '' + } + const urlHost = (value) => { + const decoded = decodeEntities(value).trim() + if (!decoded) return '' + try { + const parsed = new URL(decoded) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.hostname : '' + } catch { + return '' + } + } const pad = (value) => String(value).padStart(2, '0') const fullTime = (message) => { const timestamp = Number(message.createTime || 0) @@ -351,32 +928,191 @@ const renderExportScript = (name: string): string => ` } const kindOf = (message) => { const data = message.contentData || {} - if (message.exportMediaType === 'file' || (data.type === 'share' && String(data.typeVal) === '6')) return 'file' + const shareType = String(data.typeVal || '') + if ( + message.exportMediaType === 'file' || + (data.type === 'share' && (shareType === '6' || shareType === '74')) + ) return 'file' if ( message.exportMediaType === 'image' || message.exportMediaType === 'video' || message.exportMediaType === 'sticker' || data.type === 'image' || data.type === 'video' || data.type === 'sticker' ) return 'media' if (message.voiceDataUrl || data.type === 'voice' || message.type === '语音') return 'voice' + if ( + data.type === 'system' || data.type === 'unknown' || data.type === 'redPacket' || + data.type === 'voip' || data.type === 'card' || message.from === 'system' || + (data.type === 'share' && shareType === '2000') || + message.type === '微信红包' || message.type === '转账' + ) return 'system' if ( data.type === 'share' || data.type === 'location' || data.type === 'miniProgram' || data.type === 'forwardBundle' ) return 'share' - if (data.type === 'system' || data.type === 'unknown' || message.from === 'system') return 'system' return 'text' } + const forwardedSearchText = (items) => Array.isArray(items) + ? items.flatMap((item) => [ + item.sender, + item.sentAt, + item.text, + forwardedSearchText(item.nested) + ]).flat().filter(Boolean) + : [] const searchText = (message) => [ + message.exportConversationName, message.name, - message.senderId, message.content, message.type, message.contentData && message.contentData.title, + message.contentData && message.contentData.des, + message.contentData && message.contentData.description, + message.contentData && message.contentData.appname, + message.contentData && message.contentData.appName, message.contentData && message.contentData.quotedSender, message.contentData && message.contentData.quotedContent, message.voiceTranscript, + message.contentData && message.contentData.nickname, + message.contentData && message.contentData.username, + message.contentData && message.contentData.poiname, + message.contentData && message.contentData.label, + message.contentData && message.contentData.status, + message.contentData && forwardedSearchText(message.contentData.items), message.exportMediaName ].filter(Boolean).join(' ').toLowerCase() + const shareLabel = (typeVal) => { + if (String(typeVal) === '5') return '公众号链接' + if (String(typeVal) === '51') return '视频号' + return '分享' + } + const renderShareContent = (data) => { + const label = shareLabel(data.typeVal) + let title = decodeEntities(data.title || label) + let description = decodeEntities(data.des || '') + if (String(data.typeVal) === '51' && /当前微信版本不支持展示该内容/.test(title) && description) { + const lines = description.split(/\\n+/).map((line) => line.trim()).filter(Boolean) + title = lines.shift() || label + description = lines.join('\\n') + } + const href = externalUrl(data.url) + const host = urlHost(data.url) + const appName = displayText(data.appname || label) + const titleMarkup = '
' + esc(title) + '
' + return '
' + + '
' + displayText(label) + '
' + + (href + ? '' + titleMarkup + '' + : titleMarkup) + + (description ? '
' + esc(description) + '
' : '') + + '
' + } + const renderMiniProgramContent = (data) => { + const previewUrl = imageUrl(data.thumbDataUrl) + const iconUrl = imageUrl(data.iconUrl) + return '
' + + '
小程序
' + + '
' + displayText(data.title || '小程序') + '
' + + (data.description + ? '
' + displayText(data.description) + '
' + : '') + + (previewUrl + ? '' + : '') + + '
' + } + const renderForwardItems = (items, depth = 0) => { + if (!Array.isArray(items) || !items.length || depth > 4) return '' + return '
    ' + items.map((item) => + '
  1. ' + + (item.sender ? '' + displayText(item.sender) + '' : '') + + (item.sentAt ? '' : '') + + '' + displayText(item.text || '[消息]') + '' + + renderForwardItems(item.nested, depth + 1) + '
  2. ' + ).join('') + '
' + } + const renderForwardContent = (data) => { + const items = Array.isArray(data.items) ? data.items : [] + return '
' + + '
聊天记录
' + + '
' + displayText(data.title || '聊天记录') + '
' + + (!items.length && data.description + ? '
' + displayText(data.description) + '
' + : '') + + (items.length + ? '
展开 ' + items.length + ' 条消息' + renderForwardItems(items) + '
' + : '') + '
' + } + const renderLocationContent = (data) => { + const latitude = Number(data.lat) + const longitude = Number(data.lng) + const hasCoordinates = Number.isFinite(latitude) && Number.isFinite(longitude) && + (latitude !== 0 || longitude !== 0) + const title = data.poiname || data.label || '位置' + const address = data.label && data.label !== data.poiname ? data.label : '' + const coordinates = hasCoordinates + ? latitude.toFixed(6) + ', ' + longitude.toFixed(6) + : '' + const mapUrl = hasCoordinates + ? externalUrl('https://maps.apple.com/?q=' + encodeURIComponent(title) + + '&ll=' + latitude + ',' + longitude) + : '' + const card = '
' + + '
位置
' + + '
' + displayText(title) + '
' + + (address ? '
' + displayText(address) + '
' : '') + + (coordinates + ? '' + : '') + '
' + return mapUrl + ? '' + card + '' + : card + } + const renderPaymentContent = (data, kind) => { + const isTransfer = kind === 'transfer' + return '
' + + '
' + (isTransfer ? '微信转账' : '微信红包') + '
' + + '
' + displayText(data.title || (isTransfer ? '微信转账' : '微信红包')) + '
' + + '
' + + displayText(data.description || data.des || (isTransfer ? '转账消息' : '恭喜发财,大吉大利')) + + '
' + } + const renderVoipContent = (data) => { + const title = Number(data.roomType) === 1 ? '视频通话' : '语音通话' + const duration = Number(data.duration || 0) + const minutes = Math.floor(duration / 60) + const seconds = duration % 60 + const durationText = duration + ? (minutes ? minutes + '分' : '') + seconds + '秒' + : '' + return '
' + + '
通话
' + + '
' + title + '
' + + '
' + + displayText([data.status, durationText].filter(Boolean).join(' · ') || '通话消息') + + '
' + } + const renderStructuredContent = (data) => { + if (data.type === 'share') { + const typeVal = String(data.typeVal || '') + if (typeVal === '6' || typeVal === '74') return '' + if (typeVal === '2000') return renderPaymentContent(data, 'transfer') + return renderShareContent(data) + } + if (data.type === 'miniProgram') return renderMiniProgramContent(data) + if (data.type === 'forwardBundle') return renderForwardContent(data) + if (data.type === 'location') return renderLocationContent(data) + if (data.type === 'redPacket') return renderPaymentContent(data, 'redPacket') + if (data.type === 'voip') return renderVoipContent(data) + return '' + } + const renderMessage = (message, archiveIndex) => { const data = message.contentData || {} const mediaUrl = message.exportMediaUrl ? esc(message.exportMediaUrl) : '' @@ -406,7 +1142,8 @@ const renderExportScript = (name: string): string => ` const quote = data.type === 'quote' ? '
' + esc(data.quotedSender || '引用消息') + '' + esc(data.quotedContent || '[引用消息]') + '
' : '' - const isSystem = data.type === 'system' && data.pat + const structured = renderStructuredContent(data) + const isSystem = data.type === 'system' const sender = message.name || (message.isSender ? '我' : '联系人') const avatarFallback = esc(String(sender || '友').slice(0, 1)) const avatar = message.exportShowAvatar === false @@ -418,18 +1155,118 @@ const renderExportScript = (name: string): string => ` const text = kindOf(message) === 'voice' && /^\\[语音(?:消息)?\\]$/.test(String(rawText).trim()) ? '' : rawText - const content = esc(text || (!media && !audio && !quote ? '[' + (message.type || '消息') + ']' : '')) + const content = esc(text || (!media && !audio && !quote && !structured ? '[' + (message.type || '消息') + ']' : '')) const contentBlock = content ? '
' + content + '
' : '' + const source = conversations.length > 1 && activeConversation === 'all' + ? '' + esc(message.exportConversationName || '聊天') + '' + : '' + const locateAction = activeKind === 'all' && !query.value.trim() + ? '' + : '' return '
' + - '
' + esc(fullTime(message)) + '
' + + '
' + esc(fullTime(message)) + source + '
' + (isSystem ? '' : avatar) + '
' + (isSystem ? '' : esc(sender)) + '
' + media + audio + voiceTranscript + quote + - contentBlock + mediaStatus + '
' + structured + contentBlock + mediaStatus + '' + locateAction + '' } + const renderConversations = () => { + if (conversations.length <= 1) { + conversationFilter.hidden = true + title.hidden = false + layout.classList.add('single-conversation') + return + } + title.hidden = true + conversationFilter.hidden = false + const allAvatarUrls = conversations.map((item) => item.avatarUrl).filter(Boolean).slice(0, 2) + const avatar = (conversation, urls) => { + if (urls.length) { + return '' + + urls.map((url) => '').join('') + '' + } + return '' + + esc(conversation ? String(conversation.name || '聊').slice(0, 1) : '全') + '' + } + const option = (id, label, avatarMarkup) => + '' + conversationMenu.innerHTML = option('all', '全部聊天', avatar(null, allAvatarUrls)) + + conversations.map((conversation) => option( + conversation.id, + conversation.name, + avatar(conversation, conversation.avatarUrl ? [conversation.avatarUrl] : []) + )).join('') + updateConversationTrigger() + } + + const updateConversationTrigger = () => { + const conversation = conversations.find((item) => item.id === activeConversation) + const avatarUrls = activeConversation === 'all' + ? conversations.map((item) => item.avatarUrl).filter(Boolean).slice(0, 2) + : conversation && conversation.avatarUrl ? [conversation.avatarUrl] : [] + const label = conversation ? conversation.name : '全部聊天' + conversationTriggerName.textContent = label + conversationTriggerAvatar.classList.toggle('multiple', avatarUrls.length > 1) + if (avatarUrls.length) { + conversationTriggerAvatar.innerHTML = avatarUrls.map((url) => + '' + ).join('') + } else { + conversationTriggerAvatar.textContent = conversation + ? String(conversation.name || '聊').slice(0, 1) + : '全' + } + conversationMenu.querySelectorAll('[data-conversation-id]').forEach((option) => { + const selected = option.dataset.conversationId === activeConversation + option.setAttribute('aria-selected', String(selected)) + const check = option.querySelector('.conversation-option-check') + if (check) check.innerHTML = selected ? '✓' : '' + }) + } + + const setConversationMenuOpen = (open, focusSelected = false) => { + conversationMenu.hidden = !open + conversationTrigger.setAttribute('aria-expanded', String(open)) + if (open && focusSelected) { + nextFrame(() => { + const selected = conversationMenu.querySelector('[aria-selected=true]') + if (selected) selected.focus() + }) + } + } + + const selectConversation = (id) => { + if (id !== 'all' && !conversations.some((item) => item.id === id)) return + if (id !== activeConversation) { + rememberTabPosition() + activeConversation = id + updateConversationTrigger() + applyFilters(true) + } + setConversationMenuOpen(false) + conversationTrigger.focus() + } + + const setExpandedTimelineYear = (year) => { + expandedTimelineYear = year || '' + timeline.querySelectorAll('.timeline-year').forEach((button) => { + const expanded = button.dataset.year === expandedTimelineYear + button.setAttribute('aria-expanded', String(expanded)) + const months = button.nextElementSibling + if (months) months.hidden = !expanded + }) + } const renderTimeline = () => { if (filtered.length === 0) { + expandedTimelineYear = '' timeline.innerHTML = '
没有可跳转的月份
' return } @@ -439,32 +1276,92 @@ const renderExportScript = (name: string): string => ` if (key === 'unknown') continue groups.set(key, (groups.get(key) || 0) + 1) } - let currentYear = '' - let html = '' + const yearGroups = new Map() for (const [key, total] of groups) { const parts = key.split('-') - if (parts[0] !== currentYear) { - currentYear = parts[0] - html += '
' + esc(currentYear) + ' 年
' - } - html += '' + if (!yearGroups.has(parts[0])) yearGroups.set(parts[0], []) + yearGroups.get(parts[0]).push({ key, month: Number(parts[1]), total }) + } + const years = Array.from(yearGroups.keys()) + if (!years.includes(expandedTimelineYear)) { + expandedTimelineYear = years[years.length - 1] || '' + } + let html = '' + for (const year of years) { + const expanded = year === expandedTimelineYear + const monthsId = 'timeline-months-' + year + html += '
' + + '' + + '
' + + yearGroups.get(year).map((entry) => + '' + ).join('') + '
' } timeline.innerHTML = html || '
时间信息不可用
' } + const visibleMessages = () => { + const messages = Array.from(list.querySelectorAll('.message')) + const listBounds = list.getBoundingClientRect() + return messages.filter((message) => { + const bounds = message.getBoundingClientRect() + return bounds.bottom > listBounds.top && bounds.top < listBounds.bottom + }) + } const updateActiveMonth = () => { - const first = list.querySelector('.message') - const key = first && first.dataset.month + const messages = Array.from(list.querySelectorAll('.message')) + const visible = visibleMessages() + const listBounds = list.getBoundingClientRect() + const topAnchor = listBounds.top + Math.min(24, listBounds.height / 4) + const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= 2 + const anchoredMessage = messages.find((message) => { + const bounds = message.getBoundingClientRect() + return bounds.bottom > topAnchor && bounds.top < listBounds.bottom + }) + const activeMessage = atBottom + ? visible[visible.length - 1] || messages[messages.length - 1] + : anchoredMessage || visible[0] || messages[0] + const key = activeMessage && activeMessage.dataset.month + let activeButton timeline.querySelectorAll('.timeline-month').forEach((button) => { - button.classList.toggle('active', button.dataset.month === key) + const active = button.dataset.month === key + button.classList.toggle('active', active) + if (active) activeButton = button + }) + if (!activeButton) return + const activeYear = key.split('-')[0] + if (activeYear !== expandedTimelineYear) setExpandedTimelineYear(activeYear) + const timelineBounds = timeline.getBoundingClientRect() + const buttonBounds = activeButton.getBoundingClientRect() + if (buttonBounds.top < timelineBounds.top) { + timeline.scrollTop -= timelineBounds.top - buttonBounds.top + } else if (buttonBounds.bottom > timelineBounds.bottom) { + timeline.scrollTop += buttonBounds.bottom - timelineBounds.bottom + } + if (buttonBounds.left < timelineBounds.left) { + timeline.scrollLeft -= timelineBounds.left - buttonBounds.left + } else if (buttonBounds.right > timelineBounds.right) { + timeline.scrollLeft += buttonBounds.right - timelineBounds.right + } + } + const scheduleActiveMonthUpdate = () => { + if (activeMonthUpdatePending) return + activeMonthUpdatePending = true + nextFrame(() => { + updateActiveMonth() + activeMonthUpdatePending = false }) } const updateCount = () => { const shown = Math.max(0, windowEnd - windowStart) - count.textContent = shown < filtered.length - ? '已显示 ' + shown + ' / 筛选结果 ' + filtered.length + ' / 全部 ' + allMessages.length - : '筛选结果 ' + filtered.length + ' / 全部 ' + allMessages.length + const scopeTotal = activeConversation === 'all' + ? allMessages.length + : allMessages.filter((message) => message.exportConversationId === activeConversation).length + const scopeLabel = activeConversation === 'all' ? '全部' : '当前聊天' + count.textContent = '已显示 ' + shown + ' / 筛选 ' + filtered.length + ' / ' + scopeLabel + ' ' + scopeTotal } const setScrollTop = (value) => { scrollLoadSuppressed = true @@ -472,12 +1369,53 @@ const renderExportScript = (name: string): string => ` list.style.scrollBehavior = 'auto' list.scrollTop = value lastScrollTop = list.scrollTop + updateActiveMonth() nextFrame(() => { list.style.scrollBehavior = previousBehavior lastScrollTop = list.scrollTop + updateActiveMonth() scrollLoadSuppressed = false }) } + const scrollTopForTarget = (target, offset) => { + const targetBounds = target.getBoundingClientRect() + const listBounds = list.getBoundingClientRect() + return list.scrollTop + targetBounds.top - listBounds.top - offset + } + const highlightSearchMatches = () => { + const term = query.value.trim().toLowerCase() + if (!term) return + list.querySelectorAll('.message').forEach((message) => { + const walker = document.createTreeWalker(message, window.NodeFilter.SHOW_TEXT) + const matches = [] + while (walker.nextNode()) { + const node = walker.currentNode + const parent = node.parentElement + if ( + node.nodeValue && parent && !parent.closest('.locate-all, .search-highlight') && + node.nodeValue.toLowerCase().includes(term) + ) matches.push(node) + } + matches.forEach((node) => { + const text = node.nodeValue + const normalized = text.toLowerCase() + const fragment = document.createDocumentFragment() + let start = 0 + let index = normalized.indexOf(term, start) + while (index >= 0) { + fragment.append(text.slice(start, index)) + const mark = document.createElement('mark') + mark.className = 'search-highlight' + mark.textContent = text.slice(index, index + term.length) + fragment.append(mark) + start = index + term.length + index = normalized.indexOf(term, start) + } + fragment.append(text.slice(start)) + node.replaceWith(fragment) + }) + }) + } const renderWindow = (anchorIndex, anchorOffset) => { const visible = filtered.slice(windowStart, windowEnd) const before = windowStart > 0 ? '
向上滚动加载更早消息
' : '' @@ -485,9 +1423,10 @@ const renderExportScript = (name: string): string => ` list.innerHTML = visible.length ? before + visible.map((message, index) => renderMessage(message, windowStart + index)).join('') + after : '
没有符合条件的消息
可以更换筛选条件或关键词
' + highlightSearchMatches() if (Number.isInteger(anchorIndex)) { const anchor = list.querySelector('.message[data-index="' + anchorIndex + '"]') - if (anchor) setScrollTop(anchor.offsetTop - anchorOffset) + if (anchor) setScrollTop(scrollTopForTarget(anchor, anchorOffset)) } updateCount() updateActiveMonth() @@ -502,14 +1441,83 @@ const renderExportScript = (name: string): string => ` renderWindow() setScrollTop(preferLatest ? list.scrollHeight : 0) } - const applyFilters = () => { + const tabPositionKey = () => [ + activeConversation, + activeKind, + query.value.trim().toLowerCase() + ].join('\u0000') + const rememberTabPosition = () => { + const anchor = visibleMessages()[0] + const index = anchor ? Number(anchor.dataset.index) : -1 + const message = Number.isInteger(index) ? filtered[index] : undefined + if (!anchor || !message) return + tabPositions.set(tabPositionKey(), { + message, + offset: anchor.getBoundingClientRect().top - list.getBoundingClientRect().top + }) + } + const restoreTabPosition = () => { + const position = tabPositions.get(tabPositionKey()) + if (!position) return false + const index = filtered.indexOf(position.message) + if (index < 0) { + tabPositions.delete(tabPositionKey()) + return false + } + windowStart = Math.max(0, index - Math.floor(PAGE_SIZE / 2)) + windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE) + windowStart = Math.max(0, windowEnd - PAGE_SIZE) + renderWindow() + const target = list.querySelector('.message[data-index="' + index + '"]') + if (!target) return false + setScrollTop(Math.max(0, scrollTopForTarget(target, position.offset))) + return true + } + const matchingMessages = () => { const term = query.value.trim().toLowerCase() - filtered = allMessages.filter((message) => + return allMessages.filter((message) => + (activeConversation === 'all' || message.exportConversationId === activeConversation) && (activeKind === 'all' || kindOf(message) === activeKind) && (!term || searchText(message).includes(term)) ) + } + const applyFilters = (restorePosition = false) => { + filtered = matchingMessages() renderTimeline() - resetWindow(true) + if (!restorePosition || !restoreTabPosition()) resetWindow(true) + } + const setActiveKind = (kind) => { + activeKind = kind + filters.querySelectorAll('[data-kind]').forEach((item) => { + item.classList.toggle('active', item.dataset.kind === kind) + }) + } + const locateInAll = (sourceIndex) => { + const targetMessage = filtered[sourceIndex] + if (!targetMessage) return + rememberTabPosition() + query.value = '' + query.blur() + setActiveKind('all') + filtered = matchingMessages() + renderTimeline() + const targetIndex = filtered.indexOf(targetMessage) + if (targetIndex < 0) { + resetWindow(true) + return + } + windowStart = Math.max(0, targetIndex - Math.floor(PAGE_SIZE / 2)) + windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE) + windowStart = Math.max(0, windowEnd - PAGE_SIZE) + renderWindow() + const target = list.querySelector('.message[data-index="' + targetIndex + '"]') + if (!target) return + setScrollTop(Math.max( + 0, + scrollTopForTarget(target, Math.max(24, (list.clientHeight - target.offsetHeight) / 2)) + )) + target.classList.add('located') + window.setTimeout(() => target.classList.remove('located'), 1600) } const jumpToMonth = (key) => { const index = filtered.findIndex((message) => monthKey(message) === key) @@ -519,7 +1527,8 @@ const renderExportScript = (name: string): string => ` windowStart = Math.max(0, windowEnd - PAGE_SIZE) renderWindow() const target = list.querySelector('.message[data-index="' + index + '"]') - setScrollTop(target ? Math.max(0, target.offsetTop - 24) : 0) + setScrollTop(target ? Math.max(0, scrollTopForTarget(target, 24)) : 0) + setExpandedTimelineYear(key.split('-')[0]) timeline.querySelectorAll('.timeline-month').forEach((button) => { button.classList.toggle('active', button.dataset.month === key) }) @@ -529,7 +1538,9 @@ const renderExportScript = (name: string): string => ` const anchor = direction < 0 ? renderedMessages[0] : renderedMessages[renderedMessages.length - 1] const anchorIndex = anchor ? Number(anchor.dataset.index) : undefined - const anchorOffset = anchor ? anchor.offsetTop - list.scrollTop : 0 + const anchorOffset = anchor + ? anchor.getBoundingClientRect().top - list.getBoundingClientRect().top + : 0 if (direction < 0) { windowStart = Math.max(0, windowStart - WINDOW_STEP) windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE) @@ -556,6 +1567,7 @@ const renderExportScript = (name: string): string => ` const nearTop = currentTop < 180 const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240 lastScrollTop = currentTop + scheduleActiveMonthUpdate() if (scrollLoadSuppressed) return if (movingUp && nearTop) scheduleWindowSlide(-1) else if (!movingUp && nearBottom) scheduleWindowSlide(1) @@ -565,15 +1577,51 @@ const renderExportScript = (name: string): string => ` scheduleWindowSlide(-1) } }, { passive: true }) - query.addEventListener('input', applyFilters) + query.addEventListener('input', () => applyFilters()) filters.addEventListener('click', (event) => { const button = event.target.closest('[data-kind]') if (!button) return - activeKind = button.dataset.kind - filters.querySelectorAll('[data-kind]').forEach((item) => item.classList.toggle('active', item === button)) - applyFilters() + rememberTabPosition() + setActiveKind(button.dataset.kind) + applyFilters(true) + }) + conversationTrigger.addEventListener('click', () => { + setConversationMenuOpen(conversationMenu.hidden) + }) + conversationTrigger.addEventListener('keydown', (event) => { + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return + event.preventDefault() + setConversationMenuOpen(true, true) + }) + conversationMenu.addEventListener('click', (event) => { + const option = event.target.closest('[data-conversation-id]') + if (option) selectConversation(option.dataset.conversationId) + }) + conversationMenu.addEventListener('keydown', (event) => { + const options = Array.from(conversationMenu.querySelectorAll('[data-conversation-id]')) + const currentIndex = options.indexOf(document.activeElement) + let nextIndex = currentIndex + if (event.key === 'ArrowDown') nextIndex = Math.min(options.length - 1, currentIndex + 1) + else if (event.key === 'ArrowUp') nextIndex = Math.max(0, currentIndex - 1) + else if (event.key === 'Home') nextIndex = 0 + else if (event.key === 'End') nextIndex = options.length - 1 + else if (event.key === 'Escape') { + event.preventDefault() + setConversationMenuOpen(false) + conversationTrigger.focus() + return + } else return + event.preventDefault() + if (options[nextIndex]) options[nextIndex].focus() }) timeline.addEventListener('click', (event) => { + const yearButton = event.target.closest('[data-year]') + if (yearButton) { + setExpandedTimelineYear( + yearButton.dataset.year === expandedTimelineYear ? '' : yearButton.dataset.year + ) + return + } const button = event.target.closest('[data-month]') if (button) jumpToMonth(button.dataset.month) }) @@ -585,6 +1633,11 @@ const renderExportScript = (name: string): string => ` updateZoom() } list.addEventListener('click', (event) => { + const locateButton = event.target.closest('[data-locate-index]') + if (locateButton) { + locateInAll(Number(locateButton.dataset.locateIndex)) + return + } const image = event.target.closest('img[data-preview]') if (!image) return preview.src = image.src @@ -606,15 +1659,51 @@ const renderExportScript = (name: string): string => ` }) closeButton.addEventListener('click', closeLightbox) document.addEventListener('keydown', (event) => { - if (event.key === 'Escape') closeLightbox() + if (event.key === 'Escape') { + closeLightbox() + setConversationMenuOpen(false) + } + }) + document.addEventListener('click', (event) => { + if (!conversationFilter.contains(event.target)) setConversationMenuOpen(false) }) title.textContent = archive.name || ${JSON.stringify(name)} - meta.textContent = allMessages.length.toLocaleString() + ' 条消息 · 更新于 ' + - (archive.exportedAt - ? new Date(archive.exportedAt).toLocaleString('zh-CN', { hour12: false }) - : '未知时间') + renderConversations() applyFilters() + finishLoading() + } catch (error) { + console.error('初始化聊天档案失败', error) + failLoading('消息数据解析失败,请确认档案文件完整后重试') + } + } + + if (window.__WECHAT_EXPORT__) { + initializeArchive() + return + } + + const loadArchiveData = () => { + const dataScript = document.createElement('script') + dataScript.src = 'data/messages.js' + dataScript.onload = () => { + if (!window.__WECHAT_EXPORT__) { + failLoading('消息数据为空或格式不正确,请重新导出聊天档案') + return + } + initializeArchive() + } + dataScript.onerror = () => { + failLoading('未找到消息数据,请确认 data/messages.js 与当前页面在同一档案中') + } + document.body.appendChild(dataScript) + } + + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(() => window.setTimeout(loadArchiveData, 0)) + } else { + window.setTimeout(loadArchiveData, 0) + } })() ` @@ -628,11 +1717,28 @@ export function renderExportPage(name: string): string { +
+ + 正在加载聊天档案 + 消息较多时可能需要一些时间 +
-
+
${safe(name)} - 正在读取消息… +
@@ -644,12 +1750,14 @@ export function renderExportPage(name: string): string { - +
- +
正在加载聊天档案…
@@ -659,7 +1767,6 @@ export function renderExportPage(name: string): string { 预览 - ` diff --git a/src/main/export-service.ts b/src/main/export-service.ts index 98f3251..bd5e88f 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -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() +const activeArchives = new Map() const safeFilePart = (value: string): string => value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案' +const copyWritableExportFile = async (source: string, destination: string): Promise => { + 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 +} + +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 => { + const hash = createHash('sha1') + await new Promise((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 => { + 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() 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 { 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: 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: 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: parsed.conversations, + messages: Array.isArray(parsed.messages) ? parsed.messages : [], + avatarVersions: parsed.avatarVersions + } +} + +async function writeZipArchive( + outputDir: string, + zipPath: string, + folderName: string, + jobId: string +): Promise { + const temporaryPath = `${zipPath}.tmp-${process.pid}-${Date.now()}` + await fs.rm(temporaryPath, { force: true }) + const output = createWriteStream(temporaryPath) + await new Promise((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((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((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 { + const referenced = Object.fromEntries( + Object.keys(htmlArchiveResourcePrefixes).map((directory) => [directory, new Set()]) + ) as Record> + 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((resolve) => setImmediate(resolve)) - const messages = ( - await chat.listMessagesAsync(request.userMd5, request.startTime, request.endTime) - ).filter((message) => request.kinds.includes(kindOf(message))) + 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() + 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() + 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>() + const resourceExists = (value?: string): Promise => { + 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 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() + const previousAvatarVersions = previousArchive.avatarVersions || {} + const avatarVersions: Record = { + ...previousAvatarVersions + } + const resolveExportAvatar = async ( + identity: string, + source: string, + legacyAvatarUrl?: string + ): Promise => { + 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() + 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') - await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer) - message.voiceDataUrl = `voices/${voiceName}` + const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav` + const voiceUrl = `voices/${voiceName}` + if (!(await resourceExists(voiceUrl))) { + await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer) + 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()) { - message.exportShowAvatar = request.includeAvatars !== false - 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) - } - message.exportAvatarUrl = `avatars/${avatarName}` + 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 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) + } + } + 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}` - await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) - message.exportMediaUrl = `media/${name}` + 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) + 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}` - await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) - message.exportMediaUrl = `media/${name}` + 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) + 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}` - message.exportMediaType = 'file' - message.exportMediaName = message.contentData.title || resolved.fileName - } else { - keepMediaError(request, message, `附件复制失败:${copied.error}`) + 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 } } } @@ -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 { shell.showItemInFolder(path) diff --git a/src/main/index.ts b/src/main/index.ts index 5bf5a14..20444a0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1231,14 +1231,21 @@ app.whenReady().then(async () => { return stickerService.resolveSticker(cdnUrl, md5) }) - ipcMain.handle('db:getVideo', async (_, hashes: string[]) => { - if (!videoAssetService) { - const client = chat.getChatDb()?.getWcdb4Client() - if (!client) return { success: false, error: '数据库尚未连接' } - videoAssetService = new VideoAssetService(client) + ipcMain.handle( + 'db:getVideo', + async ( + _, + hashes: string[], + options?: { createTime?: number; duration?: number; width?: number; height?: number } + ) => { + if (!videoAssetService) { + const client = chat.getChatDb()?.getWcdb4Client() + if (!client) return { success: false, error: '数据库尚未连接' } + videoAssetService = new VideoAssetService(client) + } + return videoAssetService.resolve(Array.isArray(hashes) ? hashes : [], options) } - return videoAssetService.resolve(Array.isArray(hashes) ? hashes : []) - }) + ) // -------- Settings & API service -------- diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts index 5b095ca..4309492 100644 --- a/src/main/message-parser.ts +++ b/src/main/message-parser.ts @@ -430,9 +430,14 @@ function parseLocationMessage(content: string): ParsedContent { function parseShareMessage(content: string): ParsedContent { const appMsgType = extractAppMsgType(content) - if (appMsgType === '19' || / 0) return forwardBundle + } if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) { const sticker = parseStickerMessage(content) if (sticker.type === 'sticker') return sticker diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index 56343cf..84bb055 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -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 { + 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() diff --git a/src/main/services/recall-archive-service.ts b/src/main/services/recall-archive-service.ts index eb013be..8e0e702 100644 --- a/src/main/services/recall-archive-service.ts +++ b/src/main/services/recall-archive-service.ts @@ -40,12 +40,13 @@ let archivePath = '' let writeTimer: NodeJS.Timeout | null = null let writeQueue: Promise = 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}` } diff --git a/src/main/video-asset-service.ts b/src/main/video-asset-service.ts index 645f6de..2ea672d 100644 --- a/src/main/video-asset-service.ts +++ b/src/main/video-asset-service.ts @@ -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() private readonly fileTokens = new Map() + private readonly monthAssets = new Map() + private readonly fileHashes = new Map>() + private readonly videoDurations = new Map() + private readonly imageDimensions = new Map() private index: Map | 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() 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 { + 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 { + const cached = this.fileHashes.get(filePath) + if (cached) return cached + const pending = new Promise((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 + } } diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index 29576b8..5a47359 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -1286,11 +1286,13 @@ export class Wcdb4Client { const merged = new Map() for (const message of [...recovered, ...current]) { const recoveredRow = Boolean(message.raw?.['_wxe_recovered']) - const identity = message.mesLocalID - ? `local:${message.mesLocalID}` - : message.serverId - ? `server:${message.serverId}` - : `${message.msgCreateTime}:${message.msgContent}` + const serverId = String(message.serverId || '').trim() + const identity = + serverId && serverId !== '0' + ? `server:${serverId}` + : message.mesLocalID + ? `local:${message.mesLocalID}:${message.msgCreateTime || 0}` + : `${message.msgCreateTime}:${message.msgContent}` const key = recoveredRow ? `recovered:${identity}` : identity merged.set(key, message) } diff --git a/src/main/wechat-db.ts b/src/main/wechat-db.ts index bde5a9b..31afaf7 100644 --- a/src/main/wechat-db.ts +++ b/src/main/wechat-db.ts @@ -34,6 +34,24 @@ export interface GroupMemberInfo { m_nsHeadImgUrl: string } +function mergeExportMessages(messages: WechatMessage[]): WechatMessage[] { + const merged = new Map() + 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() @@ -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 { + 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 { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index c572461..f771725 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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, diff --git a/src/preload/index.ts b/src/preload/index.ts index 5ec48e0..49607cb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7e5c491..eda5299 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -212,7 +212,32 @@ function App(): React.ReactElement { const [exportTasks, setExportTasks] = useState(() => { 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 & { + 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 => { + 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 => { - setSelectedContact(contact) - selectedContactMd5Ref.current = contact.md5 + const loadExportPreviewMessages = async (contact: Contact): Promise => { 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 ( [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
{media.error || '视频加载中…'}
diff --git a/src/renderer/src/components/chat/MessageBubble.tsx b/src/renderer/src/components/chat/MessageBubble.tsx index 76943db..10c70b3 100644 --- a/src/renderer/src/components/chat/MessageBubble.tsx +++ b/src/renderer/src/components/chat/MessageBubble.tsx @@ -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 ? ( 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({ + {selectionMode && ( +
+ + 已选 {selectedContactIds.length} / {selectionLimit} 个 + + +
+ )} +
{filteredContacts.map((contact) => { const name = displayName(contact) + const selected = selectedContactIds.includes(contact.md5) + const atLimit = selectionMode && !selected && selectedContactIds.length >= selectionLimit return ( ) })} diff --git a/src/renderer/src/components/export/ExportPreviewPanel.tsx b/src/renderer/src/components/export/ExportPreviewPanel.tsx index 91d4be4..396146a 100644 --- a/src/renderer/src/components/export/ExportPreviewPanel.tsx +++ b/src/renderer/src/components/export/ExportPreviewPanel.tsx @@ -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({ <>
导出预览 - 仅预览最近 20 条 + + {selectedCount > 1 ? `${selectedCount} 个聊天 · 合并预览` : '仅预览最近 20 条'} +
最近消息
@@ -50,7 +54,7 @@ export function ExportPreviewPanel({ ] ).map((message) => (
+ {selectedCount > 1 && message.exportConversationName + ? `${message.exportConversationName} · ` + : ''} {message.name || (message.isSender ? '我' : '联系人')} ·{' '} {formatPreviewTime(message)} @@ -108,7 +115,11 @@ export function ExportPreviewPanel({
  1. 准备导出
  2. - {progress?.phase === 'writing' ? '生成档案' : '分批读取聊天记录'} + {progress?.phase === 'compressing' + ? '压缩 ZIP' + : progress?.phase === 'writing' + ? '生成档案' + : '分批读取聊天记录'}
  3. 解析消息内容
  4. 处理媒体资源
  5. @@ -118,9 +129,11 @@ export function ExportPreviewPanel({
- {progress?.phase === 'writing' - ? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%` - : `正在读取消息... ${progress?.percent ?? 0}%`} + {progress?.phase === 'compressing' + ? `正在压缩资源包... ${progress.percent ?? 0}%` + : progress?.phase === 'writing' + ? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%` + : `正在读取消息... ${progress?.percent ?? 0}%`} )} - - - - {task.progress.percent ?? 0}% - - {task.status === 'running' && ( - - )} - {task.status === 'failed' && ( - - )} -
- )) + {task.status === 'failed' && ( + + )} +
+ ) + }) )}
)} diff --git a/src/renderer/src/components/export/ExportWorkspace.tsx b/src/renderer/src/components/export/ExportWorkspace.tsx index 25b6179..a2c2c4f 100644 --- a/src/renderer/src/components/export/ExportWorkspace.tsx +++ b/src/renderer/src/components/export/ExportWorkspace.tsx @@ -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(initialSelection) + const previewLoadingRef = React.useRef(new Set()) const [contactFilter, setContactFilter] = useState('') const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all') + const [selectionMode, setSelectionMode] = useState(false) + const [selectedContacts, setSelectedContacts] = useState(() => + initialSelection ? [initialSelection] : [] + ) + const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '') + const [previewByContact, setPreviewByContact] = useState>({}) const [range, setRange] = useState('today') const [startDate, setStartDate] = useState('') const [endDate, setEndDate] = useState('') const [selectedKinds, setSelectedKinds] = useState>(() => new Set(['text'])) - const [nameMode, setNameMode] = useState('remark') - const [groupMembers, setGroupMembers] = useState([]) + const [nameMode, setNameMode] = useState( + initialSelection?.type === 'group' ? 'groupNickname' : 'remark' + ) const [includeMedia, setIncludeMedia] = useState(true) const [includeVoiceTranscripts, setIncludeVoiceTranscripts] = useState(true) const [voiceModelStatus, setVoiceModelStatus] = useState(null) @@ -52,6 +64,27 @@ export function ExportWorkspace({ const [jobId, setJobId] = useState('') const [progress, setProgress] = useState(null) const [taskCenterOpen, setTaskCenterOpen] = useState(false) + const selectionLimit = 5 + + React.useEffect(() => { + if (selectedContacts.length > 0) return + const candidate = initialContact || contacts[0] + if (!candidate) return + initialContactRef.current = candidate + setSelectedContacts([candidate]) + setActiveContactId(candidate.md5) + }, [contacts, initialContact, selectedContacts.length]) + + React.useEffect(() => { + for (const contact of selectedContacts) { + if (previewByContact[contact.md5] || previewLoadingRef.current.has(contact.md5)) continue + previewLoadingRef.current.add(contact.md5) + void loadPreviewMessages(contact).then((items) => { + previewLoadingRef.current.delete(contact.md5) + setPreviewByContact((current) => ({ ...current, [contact.md5]: items })) + }) + } + }, [loadPreviewMessages, previewByContact, selectedContacts]) const filteredContacts = useMemo(() => { const keyword = contactFilter.trim().toLowerCase() @@ -64,11 +97,35 @@ export function ExportWorkspace({ }) }, [contactFilter, contactType, contacts]) - const activeContact = selectedContact || filteredContacts[0] || contacts[0] || null - const currentTask = exportTasks.find((task) => task.contactId === activeContact?.md5) + const activeContact = + selectedContacts.find((contact) => contact.md5 === activeContactId) || + selectedContacts[0] || + null + const selectedTargetKey = selectedContacts + .map((contact) => contact.md5) + .sort() + .join('|') + const currentTask = exportTasks.find( + (task) => [...task.targetIds].sort().join('|') === selectedTargetKey + ) const taskCount = exportTasks.filter((task) => task.status === 'running').length const activeName = displayName(activeContact) - const preview = previewMessages.slice(-20) + const selectedNames = selectedContacts.map(displayName) + const selectedLabel = + selectedNames.length > 1 + ? `${selectedNames.join('、')} · 共 ${selectedNames.length} 个聊天` + : selectedNames[0] || '未选择聊天' + const preview = selectedContacts + .flatMap((contact) => + (previewByContact[contact.md5] || []).map((message) => ({ + ...message, + exportConversationId: contact.md5, + exportConversationName: displayName(contact), + exportConversationAvatarUrl: contact.avatar + })) + ) + .sort((left, right) => Number(left.createTime || 0) - Number(right.createTime || 0)) + .slice(-20) const previewMediaCount = preview.filter( (message) => ['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '') || @@ -78,79 +135,59 @@ export function ExportWorkspace({ (total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0), 0 ) - const outputName = fileName.trim() || `${activeName}_聊天档案` - const nameOptions: { value: ExportNameMode; label: string }[] = - activeContact?.type === 'group' - ? [ - { value: 'groupNickname', label: '群昵称' }, - { value: 'remark', label: '备注' }, - { value: 'wechatNickname', label: '微信名' } - ] - : [ - { value: 'remark', label: '备注' }, - { value: 'wechatNickname', label: '微信名' } - ] - - const nameMap = useMemo(() => { - const map: Record = {} - 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 = {} - if (activeContact?.m_nsUsrName && activeContact.avatar) { - map[activeContact.m_nsUsrName] = activeContact.avatar - } - for (const member of groupMembers) { - if (member.avatar) map[member.wxid] = member.avatar - } - if (selfInfo?.wxid && selfInfo.avatar) map[selfInfo.wxid] = selfInfo.avatar - return map - }, [activeContact, groupMembers, selfInfo]) + const defaultOutputName = + selectedContacts.length > 1 + ? `${selectedNames[0]}等${selectedContacts.length}个聊天_合并档案` + : `${activeName}_聊天档案` + const outputName = fileName.trim() || defaultOutputName + const nameOptions: { value: ExportNameMode; label: string }[] = selectedContacts.some( + (contact) => contact.type === 'group' + ) + ? [ + { value: 'groupNickname', label: '群昵称' }, + { value: 'remark', label: '备注' }, + { value: 'wechatNickname', label: '微信名' } + ] + : [ + { value: 'remark', label: '备注' }, + { value: 'wechatNickname', label: '微信名' } + ] const previewName = (message: Message): string => - (message.senderId && nameMap[message.senderId]) || message.name || (message.isSender ? selfInfo?.nickname : undefined) || (message.isSender ? '我' : '联系人') const previewAvatar = (message: Message): string | undefined => - (message.senderId && avatarUrls[message.senderId]) || - message.img || - (message.isSender ? selfInfo?.avatar : undefined) + message.img || (message.isSender ? selfInfo?.avatar : undefined) const previewItems = preview.map((message) => ({ ...message, name: previewName(message), img: previewAvatar(message) })) - React.useEffect(() => { - setNameMode(activeContact?.type === 'group' ? 'groupNickname' : 'remark') - setGroupMembers([]) - if (!activeContact || activeContact.type !== 'group') return - const timer = window.setTimeout(() => { - void window.api.getGroupSnapshot(activeContact.md5).then((snapshot) => { - setGroupMembers((snapshot?.members || []) as GroupMemberName[]) - }) - }, 300) - return () => window.clearTimeout(timer) - }, [activeContact]) + const handleSelectContact = (contact: Contact): void => { + if (!selectionMode) { + setSelectedContacts([contact]) + setActiveContactId(contact.md5) + setStatus('idle') + return + } + const selected = selectedContacts.some((item) => item.md5 === contact.md5) + if (selected) { + if (selectedContacts.length === 1) return + const next = selectedContacts.filter((item) => item.md5 !== contact.md5) + setSelectedContacts(next) + if (activeContactId === contact.md5) setActiveContactId(next[0].md5) + setStatus('idle') + return + } + if (selectedContacts.length >= selectionLimit) return + const next = [...selectedContacts, contact] + setSelectedContacts(next) + setActiveContactId(contact.md5) + setFormat('html') + setStatus('idle') + } React.useEffect(() => { let active = true @@ -173,42 +210,59 @@ export function ExportWorkspace({ } const handleStart = async (): Promise => { - if (!activeContact || status === 'running') return + if (!activeContact || selectedContacts.length === 0 || status === 'running') return // Runs only from the export button event; a fresh id is required for each job. - // eslint-disable-next-line react-hooks/purity const nextJobId = `export-${Date.now()}` setJobId(nextJobId) setProgress(null) setStatus('running') - let exportNameMap = nameMap - let exportAvatarUrls = avatarUrls - if (activeContact.type === 'group') { - const snapshot = await window.api.getGroupSnapshot(activeContact.md5) - const members = (snapshot?.members || []) as GroupMemberName[] - setGroupMembers(members) - exportNameMap = { ...nameMap } - exportAvatarUrls = { ...avatarUrls } - for (const member of members) { - exportNameMap[member.wxid] = - nameMode === 'groupNickname' - ? member.groupNickname || member.nickname || member.wxid - : nameMode === 'remark' - ? member.remark || member.wechatNickname || member.wxid - : member.wechatNickname || member.wxid - if (member.avatar) exportAvatarUrls[member.wxid] = member.avatar - } - } + const targets: ExportTarget[] = await Promise.all( + selectedContacts.map(async (contact) => { + const nameMap: Record = {} + const avatarUrls: Record = {} + if (contact.type === 'group') { + const snapshot = await window.api.getGroupSnapshot(contact.md5) + for (const member of (snapshot?.members || []) as GroupMemberName[]) { + nameMap[member.wxid] = + nameMode === 'groupNickname' + ? member.groupNickname || member.nickname || member.wxid + : nameMode === 'remark' + ? member.remark || member.wechatNickname || member.wxid + : member.wechatNickname || member.wxid + if (member.avatar) avatarUrls[member.wxid] = member.avatar + } + } else { + nameMap[contact.m_nsUsrName] = + nameMode === 'remark' + ? contact.remark || contact.m_nsNickName || contact.m_nsUsrName + : contact.wechatNickname || contact.m_nsUsrName + if (contact.avatar) avatarUrls[contact.m_nsUsrName] = contact.avatar + } + if (selfInfo?.wxid) { + nameMap[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid + if (selfInfo.avatar) avatarUrls[selfInfo.wxid] = selfInfo.avatar + } + return { + userMd5: contact.md5, + name: displayName(contact), + type: contact.type, + avatarUrl: contact.avatar, + nameMode, + nameMap, + avatarUrls + } + }) + ) const now = new Date() const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) const days = range === 'today' ? 1 : range === 'threeDays' ? 3 : range === 'sevenDays' ? 7 : 0 const startOfRange = days ? new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1) : null - const request = { + const request: ExportRequest = { jobId: nextJobId, - userMd5: activeContact.md5, - name: activeName, - format, + targets, + format: selectedContacts.length > 1 ? 'html' : format, outputName, startTime: startOfRange ? Math.floor(startOfRange.getTime() / 1000) @@ -232,9 +286,6 @@ export function ExportWorkspace({ fallbackThumbnail, keepMissing, includeAvatars, - avatarUrls: exportAvatarUrls, - nameMode, - nameMap: exportNameMap, zip } const result = await onStartExport(request) @@ -277,6 +328,29 @@ export function ExportWorkspace({ ) }, [currentTask]) + const resetDefaults = (): void => { + const contact = initialContactRef.current || contacts[0] || null + setSelectedContacts(contact ? [contact] : []) + setActiveContactId(contact?.md5 || '') + setSelectionMode(false) + setRange('today') + setStartDate('') + setEndDate('') + setSelectedKinds(new Set(['text'])) + setNameMode(contact?.type === 'group' ? 'groupNickname' : 'remark') + setIncludeMedia(true) + setIncludeAvatars(true) + setPreferOriginal(true) + setFallbackThumbnail(true) + setKeepMissing(true) + setFormat('csv') + setZip(false) + setFileName('') + setStatus('idle') + setJobId('') + setProgress(null) + } + const targetPath = format === 'html' ? zip @@ -290,13 +364,17 @@ export function ExportWorkspace({ contacts={contacts} filteredContacts={filteredContacts} activeContact={activeContact} + selectedContactIds={selectedContacts.map((contact) => contact.md5)} + selectionMode={selectionMode} + selectionLimit={selectionLimit} selfInfo={selfInfo} dbReady={dbReady} contactFilter={contactFilter} contactType={contactType} onContactFilterChange={setContactFilter} onContactTypeChange={setContactType} - onSelectContact={onSelectContact} + onSelectContact={handleSelectContact} + onCompleteSelection={() => setSelectionMode(false)} onOpenSettings={onOpenSettings} /> @@ -310,20 +388,28 @@ export function ExportWorkspace({ onCancel={(taskJobId) => void onCancelExport(taskJobId)} />
- - {activeContact?.avatar ? ( - - ) : ( - activeName.slice(0, 1) - )} + + {selectedContacts.slice(0, 3).map((contact) => ( + + {contact.avatar ? ( + + ) : ( + displayName(contact).slice(0, 1) + )} + + ))} - +

导出设置

-

- {activeName} - {activeContact?.type === 'group' ? ' · 群聊' : ''} -

+

{selectedLabel}

+
@@ -334,6 +420,7 @@ export function ExportWorkspace({ key={value} type="button" className={format === value ? 'active' : ''} + disabled={selectedContacts.length > 1 && value !== 'html'} onClick={() => setFormat(value)} > {formatLabels[value].label} @@ -342,7 +429,9 @@ export function ExportWorkspace({ ))}

- CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。 + {selectedContacts.length > 1 + ? '多聊天合并仅支持 HTML,会保留每条消息所属的聊天。' + : 'CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。'}

{format === 'html' && ( <> @@ -544,45 +633,6 @@ export function ExportWorkspace({
-
-

导出格式

-
- {formatOrder.map((value) => ( - - ))} -
- {format === 'html' && ( -
- - -
- )} -
-

保存设置

@@ -615,7 +665,7 @@ export function ExportWorkspace({ : '准备就绪'} 路径:{targetPath} -