diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f5066f6..1a11681 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,6 +33,10 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Install Playwright Chromium + if: runner.os == 'macOS' + run: pnpm exec playwright install chromium + - name: Type check run: pnpm typecheck diff --git a/package.json b/package.json index 67fe05f..e6419f4 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,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", @@ -79,6 +80,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 414630e..a7bd9a5 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 @@ -56,6 +58,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 @@ -77,6 +80,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 @@ -1513,6 +1517,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 @@ -1650,6 +1661,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: @@ -1911,6 +1928,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: @@ -2047,6 +2071,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==} @@ -2176,7 +2219,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==} @@ -2194,13 +2236,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==} @@ -2234,6 +2349,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} @@ -2246,6 +2368,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==} @@ -2256,6 +2383,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'} @@ -2496,6 +2630,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 @@ -2523,8 +2668,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==} @@ -2533,6 +2682,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'} @@ -3247,6 +3404,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'} @@ -3270,6 +3445,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 @@ -3538,7 +3717,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 @@ -3745,7 +3924,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==} @@ -3967,6 +4145,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'} @@ -4016,6 +4199,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 @@ -4207,6 +4394,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'} @@ -4391,6 +4585,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: @@ -4538,6 +4739,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'} @@ -4834,6 +5040,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'} @@ -4914,6 +5129,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'} @@ -4922,6 +5149,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'} @@ -5066,6 +5311,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==} @@ -5321,6 +5570,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'} @@ -5397,6 +5657,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: @@ -5459,6 +5725,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'} @@ -5471,6 +5750,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: @@ -5478,6 +5766,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: @@ -6035,6 +6331,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/src/main/export-html-template.ts b/src/main/export-html-template.ts index 38bb627..c753011 100644 --- a/src/main/export-html-template.ts +++ b/src/main/export-html-template.ts @@ -39,8 +39,16 @@ 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; } +.archive-heading .meta { margin-left: 0; } .controls { display: flex; gap: 8px; align-items: center; justify-content: flex-end; } .controls input, .filter-button { border: 1px solid var(--border); @@ -63,13 +71,66 @@ body { .count { margin-left: auto; color: var(--muted); font-size: 13px; } .archive-layout { display: grid; - grid-template-columns: 150px minmax(0, 1fr); + 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; + flex-direction: column; + gap: 10px; + min-width: 0; + min-height: 0; +} +.conversation-filter { + position: relative; + display: inline-flex; + flex: 0 1 320px; + min-width: 210px; + max-width: 100%; +} +.conversation-filter[hidden] { display: none; } +.conversation-filter::after { + content: ''; + position: absolute; + right: 15px; + top: 50%; + width: 8px; + height: 8px; + border-right: 2px solid var(--accent); + border-bottom: 2px solid var(--accent); + transform: translateY(-65%) rotate(45deg); + pointer-events: none; +} +.conversation-select { + appearance: none; + width: 100%; + min-width: 0; + height: 42px; + padding: 0 42px 0 14px; + border: 1px solid #b8cec5; + border-radius: 10px; + outline: 0; + background: #f7fbf9; + color: var(--text); + font: inherit; + font-size: 17px; + font-weight: 700; + cursor: pointer; + box-shadow: 0 2px 8px #29483b12; + transition: border-color .15s ease, background .15s ease, box-shadow .15s ease; +} +.conversation-select:hover { border-color: #70a392; background: #fff; } +.conversation-select:focus { + border-color: var(--accent); + background: #fff; + box-shadow: 0 0 0 3px #176b571c; +} .timeline { + flex: 1; overflow: auto; background: #f7faf8; border: 1px solid var(--border); @@ -133,6 +194,14 @@ body { } .message.system .sender { display: none; } .time { color: var(--muted); font-size: 11px; margin: 0 12px; } +.conversation-source { + display: inline-block; + margin-left: 8px; + padding: 2px 6px; + border-radius: 5px; + background: var(--accent-soft); + color: var(--accent); +} .row { display: flex; gap: 12px; align-items: flex-end; max-width: 100%; } .sent .row { flex-direction: row-reverse; } .avatar { @@ -249,9 +318,19 @@ body { .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; } + .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: start; display: flex; gap: 6px; overflow: auto; padding: 8px; } + .archive-heading { align-items: flex-start; } + .conversation-filter { flex-basis: 100%; width: 100%; } + .timeline-year { + flex: 0 0 auto; + align-self: center; + margin: 0 2px 0 0; + padding: 7px 5px; + white-space: nowrap; + } .timeline-month { flex: 0 0 auto; width: auto; @@ -281,8 +360,24 @@ const renderExportScript = (name: string): string => ` 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 conversationSelect = document.querySelector('#conversation-select') const timeline = document.querySelector('#timeline') const query = document.querySelector('#query') const count = document.querySelector('#count') @@ -293,6 +388,7 @@ const renderExportScript = (name: string): string => ` 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 @@ -345,6 +441,7 @@ const renderExportScript = (name: string): string => ` return 'text' } const searchText = (message) => [ + message.exportConversationName, message.name, message.senderId, message.content, @@ -389,14 +486,42 @@ const renderExportScript = (name: string): string => ` : avatarFallback) + '' const text = message.content || (data.type === 'quote' ? data.title : '') const content = esc(text || (!media && !audio && !quote ? '[' + (message.type || '消息') + ']' : '')) + const source = conversations.length > 1 && activeConversation === 'all' + ? '' + esc(message.exportConversationName || '聊天') + '' + : '' return '
' + - '
' + esc(fullTime(message)) + '
' + + '
' + esc(fullTime(message)) + source + '
' + (isSystem ? '' : avatar) + '
' + (isSystem ? '' : esc(sender)) + '
' + media + audio + quote + '
' + content + '
' + mediaStatus + '
' } + const renderConversations = () => { + if (conversations.length <= 1) { + conversationFilter.hidden = true + title.hidden = false + layout.classList.add('single-conversation') + return + } + const counts = new Map() + for (const message of allMessages) { + const id = message.exportConversationId || '' + counts.set(id, (counts.get(id) || 0) + 1) + } + const option = (id, label, total) => + '' + title.hidden = true + conversationFilter.hidden = false + conversationSelect.innerHTML = option('all', '全部聊天', allMessages.length) + + conversations.map((conversation) => option( + conversation.id, + conversation.name, + counts.get(conversation.id) || 0 + )).join('') + conversationSelect.value = activeConversation + } + const renderTimeline = () => { if (filtered.length === 0) { timeline.innerHTML = '
没有可跳转的月份
' @@ -431,7 +556,11 @@ const renderExportScript = (name: string): string => ` } const updateCount = () => { const shown = Math.max(0, windowEnd - windowStart) - count.textContent = '已显示 ' + shown + ' / 筛选 ' + 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,6 +601,7 @@ const renderExportScript = (name: string): string => ` const applyFilters = () => { const term = query.value.trim().toLowerCase() filtered = allMessages.filter((message) => + (activeConversation === 'all' || message.exportConversationId === activeConversation) && (activeKind === 'all' || kindOf(message) === activeKind) && (!term || searchText(message).includes(term)) ) @@ -540,6 +670,10 @@ const renderExportScript = (name: string): string => ` filters.querySelectorAll('[data-kind]').forEach((item) => item.classList.toggle('active', item === button)) applyFilters() }) + conversationSelect.addEventListener('change', () => { + activeConversation = conversationSelect.value + applyFilters() + }) timeline.addEventListener('click', (event) => { const button = event.target.closest('[data-month]') if (button) jumpToMonth(button.dataset.month) @@ -577,10 +711,12 @@ const renderExportScript = (name: string): string => ` }) title.textContent = archive.name || ${JSON.stringify(name)} - meta.textContent = allMessages.length.toLocaleString() + ' 条消息 · 更新于 ' + - (archive.exportedAt - ? new Date(archive.exportedAt).toLocaleString('zh-CN', { hour12: false }) - : '未知时间') + const updatedAt = archive.exportedAt + ? new Date(archive.exportedAt).toLocaleString('zh-CN', { hour12: false }) + : '未知时间' + meta.textContent = (conversations.length > 1 ? '' : allMessages.length.toLocaleString() + ' 条消息 · ') + + '更新于 ' + updatedAt + renderConversations() applyFilters() })() ` @@ -597,8 +733,11 @@ export function renderExportPage(name: string): string {
-
+
${safe(name)} + 正在读取消息…
@@ -616,7 +755,9 @@ export function renderExportPage(name: string): string {
- +
正在加载聊天档案…
diff --git a/src/main/export-service.ts b/src/main/export-service.ts index 461d85b..bfdf78c 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -1,14 +1,16 @@ import { app, BrowserWindow, shell } from 'electron' import { createHash } from 'crypto' -import { promises as fs } from 'fs' +import { 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' @@ -22,8 +24,18 @@ import { FileAssetService } from './file-asset-service' import { mergeCachedSelfInfo } from './services/bootstrap-cache' 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') @@ -31,7 +43,15 @@ const exportStamp = (): string => { } const imageKeys = new ImageKeyConfigService() -export interface HtmlExportArchive { +export interface HtmlExportConversation { + id: string + name: string + type: 'user' | 'group' + avatarUrl?: string + messageCount: number +} + +interface HtmlExportArchiveV1 { version: 1 sourceId: string name: string @@ -39,20 +59,29 @@ export interface HtmlExportArchive { messages: Message[] } +export interface HtmlExportArchive { + version: 2 + name: string + exportedAt: string + conversations: HtmlExportConversation[] + messages: Message[] +} + const archiveDataPrefix = 'window.__WECHAT_EXPORT__ = ' const hashPart = (value: string, length = 16): string => createHash('sha1').update(value).digest('hex').slice(0, length) 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 || '', @@ -89,7 +118,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) @@ -101,6 +131,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) }) } @@ -127,7 +161,7 @@ export function normalizeHtmlArchiveSelfNames( export async function readHtmlArchive( outputDir: string, - sourceId: string, + targets: ExportTarget[], name: string ): Promise { const dataPath = join(outputDir, 'data', 'messages.js') @@ -136,7 +170,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 } @@ -146,21 +191,100 @@ 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 : [] + } +} + +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 }) } } @@ -298,11 +422,57 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro 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 = + targets.length > 1 ? `${targets[0].name} 等 ${targets.length} 个聊天` : targets[0].name + 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.listMessagesAsync(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 isUsableSelfName = (value: string | undefined): value is string => { @@ -313,13 +483,14 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro return true } for (const message of messages) { + const target = targetById.get(message.exportConversationId || '') message.exportMediaUrl = undefined message.exportMediaType = undefined message.exportMediaName = undefined message.exportMediaError = undefined message.voiceDataUrl = undefined message.exportShowAvatar = request.includeAvatars !== false - const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined + const mappedName = message.senderId ? target?.nameMap?.[message.senderId] : undefined if (mappedName && (!message.isSender || isUsableSelfName(mappedName))) { message.name = mappedName } else if (message.isSender && isUsableSelfName(selfInfo?.nickname)) { @@ -357,7 +528,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro ? 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) await fs.mkdir(join(outputDir, 'voices'), { recursive: true }) await fs.mkdir(join(outputDir, 'media'), { recursive: true }) await fs.mkdir(join(outputDir, 'avatars'), { recursive: true }) @@ -369,10 +540,14 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro .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 @@ -382,13 +557,27 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro const stickerService = client ? new StickerService(client) : null const fileService = client ? new FileAssetService(client) : null const exportedAvatars = new Map() + 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 resolved = await readAvatarAsset(target.avatarUrl) + if (!resolved) continue + const avatarName = `conversation_${hashPart(target.userMd5)}.${resolved.extension}` + await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer) + conversationAvatarUrls.set(target.userMd5, `avatars/${avatarName}`) + } + } 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 conversationId = message.exportConversationId || targets[0].userMd5 if (!message.sessionId || message.localId == null || !message.createTime) { keepMediaError(request, message, '语音标识不完整,无法定位本地语音') continue @@ -410,7 +599,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro keepMediaError(request, message, reason) continue } - const voiceName = `voice_${hashPart(exportMessageKey(message, request.userMd5))}.wav` + const voiceName = `voice_${hashPart(exportMessageKey(message, conversationId))}.wav` const audioBuffer = Buffer.from(voice.data, 'base64') await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer) message.voiceDataUrl = `voices/${voiceName}` @@ -431,14 +620,26 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro } } for (const [index, message] of messages.entries()) { + if (!jobs.has(request.jobId)) { + send({ + jobId: request.jobId, + phase: 'cancelled', + processed: index, + total: messages.length, + percent: 15 + Math.round((index / Math.max(messages.length, 1)) * 75) + }) + return { success: false, error: '已取消' } + } + const conversationId = message.exportConversationId || targets[0].userMd5 message.exportShowAvatar = request.includeAvatars !== false const 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)}` + const avatarKey = `${conversationId}:${ + message.senderId || avatar || `message_${exportMessageKey(message, conversationId)}` + }` let avatarName = exportedAvatars.get(avatarKey) if (!avatarName) { avatarName = `avatar_${hashPart(avatarKey)}.${avatarExtension}` @@ -472,7 +673,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro allowThumbnail: attempt.allowThumbnail, preferThumbnail: attempt.preferThumbnail, sessionId: message.sessionId, - sessionMd5: request.userMd5, + sessionMd5: conversationId, createTime: message.createTime } ) @@ -497,7 +698,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro } const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null if (decoded) { - const name = `image_${hashPart(exportMessageKey(message, request.userMd5))}.${decoded.extension}` + const name = `image_${hashPart(exportMessageKey(message, conversationId))}.${decoded.extension}` await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) message.exportMediaUrl = `media/${name}` message.exportMediaType = 'image' @@ -534,8 +735,8 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro } else if (extname(source).toLowerCase() !== '.mp4') { keepMediaError(request, message, '视频格式不支持,仅支持本地 MP4 文件') } else { - const name = `video_${hashPart(exportMessageKey(message, request.userMd5))}.mp4` - await fs.copyFile(source, join(outputDir, 'media', name)) + const name = `video_${hashPart(exportMessageKey(message, conversationId))}.mp4` + await copyWritableExportFile(source, join(outputDir, 'media', name)) message.exportMediaUrl = `media/${name}` message.exportMediaType = 'video' } @@ -549,7 +750,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro ? await readAvatarAsset(stickerSource) : null if (decoded) { - const name = `sticker_${hashPart(exportMessageKey(message, request.userMd5))}.${decoded.extension}` + const name = `sticker_${hashPart(exportMessageKey(message, conversationId))}.${decoded.extension}` await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) message.exportMediaUrl = `media/${name}` message.exportMediaType = 'sticker' @@ -564,8 +765,8 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro if (!resolved.success || !resolved.filePath || !resolved.fileName) { keepMediaError(request, message, resolved.error || '本地文件附件缺失') } else { - const name = `file_${hashPart(exportMessageKey(message, request.userMd5))}_${safeFilePart(resolved.fileName)}` - await fs.copyFile(resolved.filePath, join(outputDir, 'media', name)) + const name = `file_${hashPart(exportMessageKey(message, conversationId))}_${safeFilePart(resolved.fileName)}` + await copyWritableExportFile(resolved.filePath, join(outputDir, 'media', name)) message.exportMediaUrl = `media/${name}` message.exportMediaType = 'file' message.exportMediaName = message.contentData.title || resolved.fileName @@ -583,26 +784,53 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro const mergedMessages = mergeHtmlArchiveMessages( previousArchive.messages, messages, - request.userMd5 + '', + targets.map((target) => target.userMd5) ) + const normalizedMessages = 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 } - 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) + 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, @@ -612,7 +840,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro 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', @@ -624,6 +852,10 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro 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 { @@ -632,6 +864,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro } 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/renderer/src/App.tsx b/src/renderer/src/App.tsx index 37c3b25..65449cb 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 [] } @@ -341,10 +366,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 }, @@ -1220,16 +1249,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 [] } } @@ -1322,7 +1349,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) @@ -1702,11 +1728,10 @@ function App(): React.ReactElement { return ( 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}%`} - )} -
- )) + tasks.map((task) => { + const detail = taskDetail(task) + return ( +
+ + {task.targetLabel} + + {task.format.toUpperCase()} · {phaseLabels[task.progress.phase]} + + {detail && ( + + {detail} + + )} + + + + {task.progress.percent ?? 0}% + + {task.status === 'running' && ( + + )} +
+ ) + }) )}
)} diff --git a/src/renderer/src/components/export/ExportWorkspace.tsx b/src/renderer/src/components/export/ExportWorkspace.tsx index 9486647..35bbf26 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, @@ -19,24 +22,33 @@ import { displayName, formatLabels, formatOrder, messageKinds } from './exportUt 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 [includeAvatars, setIncludeAvatars] = useState(true) const [preferOriginal, setPreferOriginal] = useState(true) @@ -49,6 +61,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() @@ -61,11 +94,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 || '') || @@ -75,79 +132,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') + } const toggleKind = (value: string): void => { setSelectedKinds((current) => { @@ -159,42 +196,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) @@ -212,9 +266,6 @@ export function ExportWorkspace({ fallbackThumbnail, keepMissing, includeAvatars, - avatarUrls: exportAvatarUrls, - nameMode, - nameMap: exportNameMap, zip } const result = await onStartExport(request) @@ -257,6 +308,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 @@ -270,13 +344,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} /> @@ -290,20 +368,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}

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

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

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

导出格式

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

保存设置

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