commit 01641834de29a6ce2296f791fbc90cf6be9f6b65 Author: cc <98377878+hicccc77@users.noreply.github.com> Date: Sat Jan 10 13:01:37 2026 +0800 新的提交 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1fbead4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: Build and Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: windows-latest + + steps: + - name: Check out git repository + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install Dependencies + run: npm install + + - name: Build Frontend & Type Check + run: | + npx tsc + npx vite build + + - name: Package and Publish + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx electron-builder --publish always \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6877f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Dependencies +node_modules +dist +dist-electron +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Build output +out +release + +# Database +*.db +*.db-shm +*.db-wal + +# Environment +.env +.env.local +.env.production + +# OS +Thumbs.db + + + +# 忽略 Visual Studio 临时文件夹 +.vs/ +# 忽略 IntelliSense 缓存文件 +*.ipch +*.aps + +wcdb/ \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..9291011 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +registry=https://registry.npmmirror.com +electron_mirror=https://npmmirror.com/mirrors/electron/ +electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c956c6d --- /dev/null +++ b/LICENSE @@ -0,0 +1,141 @@ +Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International + +By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. + +Section 1 – Definitions. + +a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. + +b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. + +c. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. + +d. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. + +e. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. + +f. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License. + +g. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. + +h. Licensor means the individual(s) or entity(ies) granting rights under this Public License. + +i. NonCommercial means not intended for or directed towards commercial advantage or monetary compensation. For purposes of this Public License, the exchange of the Licensed Material for other material subject to Copyright and Similar Rights by digital file-sharing or similar means is NonCommercial provided there is no payment of monetary compensation in connection with the exchange. + +j. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. + +k. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. + +l. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. + +Section 2 – Scope. + +a. License grant. + +1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: +A. reproduce and Share the Licensed Material, in whole or in part, for NonCommercial purposes only; and +B. produce, reproduce, and Share Adapted Material for NonCommercial purposes only. + +2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. + +3. Term. The term of this Public License is specified in Section 6(a). + +4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material. + +5. Downstream recipients. +A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. +B. Additional offer from the Licensor – Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter's License You apply. +C. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. + +6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). + +b. Other rights. + +1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. + +2. Patent and trademark rights are not licensed under this Public License. + +3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties, including when the Licensed Material is used for other than NonCommercial purposes. + +Section 3 – License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the following conditions. + +a. Attribution. + +1. If You Share the Licensed Material (including in modified form), You must: +A. retain the following if it is supplied by the Licensor with the Licensed Material: +i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); +ii. a copyright notice; +iii. a notice that refers to this Public License; +iv. a notice that refers to the disclaimer of warranties; +v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; +B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and +C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. + +2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. + +3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. + +4. If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License. + +b. ShareAlike. + +In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply. + +1. The Adapter's License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-NC-SA Compatible License. + +2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material. + +3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply. + +Section 4 – Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: + +a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database for NonCommercial purposes only; + +b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and + +c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights and Sui Generis Database Rights apply to Your use of the Licensed Material. + +Section 5 – Disclaimer of Warranties and Limitation of Liability. + +a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + +b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + +c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. + +Section 6 – Term and Termination. + +a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. + +b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: + +1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or +2. upon express reinstatement by the Licensor. + +For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. + +c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. + +d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. + +Section 7 – Other Terms and Conditions. + +a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. + +b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. + +Section 8 – Interpretation. + +a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. + +b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. + +c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. + +d. Nothing in this Public License constitutes or may be interpreted as a limitation upon or waiver of any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bb52ce --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# WeFlow + +WeFlow 是一个**完全本地**的微信聊天记录查看与分析工具,支持聊天检索、统计分析、群聊画像与年度报告。所有数据均在本地处理,不会上传到任何服务器。 + +--- + +

+ WeFlow +

+ +--- + +

+ +Stargazers + + +Forks + + +Issues + + +License + +

+ +## 主要功能 + +- 本地查看与搜索聊天记录 +- 统计分析与群聊画像 +- 年度报告与可视化概览 +- 导出聊天记录为 HTML 等格式 +- 本地解密与数据库管理 + +## 快速开始 + +若你只想使用成品版本,可前往 Release 下载并解压运行。 + +## 面向开发者 + +如果你想从源码构建或为项目贡献代码,请遵循以下步骤: + +```bash +# 1. 克隆项目到本地 +git clone https://github.com/hicccc77/WeFlow.git +cd WeFlow + +# 2. 安装项目依赖 +npm install + +# 3. 运行应用(开发模式) +npm run dev + +# 4. 打包可执行文件 +npm run build +``` + +打包产物在 `release` 目录下。 + +## 技术栈 + +- **前端**: React 19 + TypeScript + Zustand +- **桌面**: Electron 39 +- **构建**: Vite + electron-builder +- **数据库**: better-sqlite3 + WCDB DLL +- **样式**: SCSS + CSS Variables + +## 项目结构 + +``` +WeFlow/ +├── electron/ # Electron 主进程 +│ ├── main.ts # 主进程入口 +│ ├── preload.ts # 预加载脚本 +│ └── services/ # 后端服务 +│ ├── chatService.ts # 聊天数据服务 +│ ├── wcdbService.ts # 数据库服务 +│ ├── decryptService.ts # 解密服务 +│ └── ... +├── src/ # React 前端 +│ ├── components/ # 通用组件 +│ ├── pages/ # 页面组件 +│ ├── stores/ # Zustand 状态管理 +│ ├── services/ # 前端服务 +│ └── types/ # TypeScript 类型定义 +├── public/ # 静态资源 +└── resources/ # 打包资源 +``` + +## 注意事项 + +- 仅支持 Windows 系统 +- 需要微信 4.x 版本 +- 所有数据仅在本地处理,不会上传到任何服务器 +- 请负责任地使用本工具,遵守相关法律法规 diff --git a/app.png b/app.png new file mode 100644 index 0000000..286f744 Binary files /dev/null and b/app.png differ diff --git a/electron/annualReportWorker.ts b/electron/annualReportWorker.ts new file mode 100644 index 0000000..37db57d --- /dev/null +++ b/electron/annualReportWorker.ts @@ -0,0 +1,43 @@ +import { parentPort, workerData } from 'worker_threads' +import { wcdbService } from './services/wcdbService' +import { annualReportService } from './services/annualReportService' + +interface WorkerConfig { + year: number + dbPath: string + decryptKey: string + myWxid: string + resourcesPath?: string + userDataPath?: string + logEnabled?: boolean +} + +const config = workerData as WorkerConfig +process.env.WEFLOW_WORKER = '1' +if (config.resourcesPath) { + process.env.WCDB_RESOURCES_PATH = config.resourcesPath +} + +wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '') +wcdbService.setLogEnabled(config.logEnabled === true) + +async function run() { + const result = await annualReportService.generateReportWithConfig({ + year: config.year, + dbPath: config.dbPath, + decryptKey: config.decryptKey, + wxid: config.myWxid, + onProgress: (status: string, progress: number) => { + parentPort?.postMessage({ + type: 'annualReport:progress', + data: { status, progress } + }) + } + }) + + parentPort?.postMessage({ type: 'annualReport:result', data: result }) +} + +run().catch((err) => { + parentPort?.postMessage({ type: 'annualReport:error', error: String(err) }) +}) diff --git a/electron/imageSearchWorker.ts b/electron/imageSearchWorker.ts new file mode 100644 index 0000000..2f99546 --- /dev/null +++ b/electron/imageSearchWorker.ts @@ -0,0 +1,156 @@ +import { parentPort, workerData } from 'worker_threads' +import { readdirSync, statSync } from 'fs' +import { join } from 'path' + +type WorkerPayload = { + root: string + datName: string + maxDepth: number + allowThumbnail: boolean + thumbOnly: boolean +} + +type Candidate = { score: number; path: string; isThumb: boolean; hasX: boolean } + +const payload = workerData as WorkerPayload + +function looksLikeMd5(value: string): boolean { + return /^[a-fA-F0-9]{16,32}$/.test(value) +} + +function hasXVariant(baseLower: string): boolean { + return /[._][a-z]$/.test(baseLower) +} + +function hasImageVariantSuffix(baseLower: string): boolean { + return /[._][a-z]$/.test(baseLower) +} + +function isLikelyImageDatBase(baseLower: string): boolean { + return hasImageVariantSuffix(baseLower) || looksLikeMd5(baseLower) +} + +function normalizeDatBase(name: string): string { + let base = name.toLowerCase() + if (base.endsWith('.dat') || base.endsWith('.jpg')) { + base = base.slice(0, -4) + } + while (/[._][a-z]$/.test(base)) { + base = base.slice(0, -2) + } + return base +} + +function matchesDatName(fileName: string, datName: string): boolean { + const lower = fileName.toLowerCase() + const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower + const normalizedBase = normalizeDatBase(base) + const normalizedTarget = normalizeDatBase(datName.toLowerCase()) + if (normalizedBase === normalizedTarget) return true + const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`) + if (pattern.test(lower)) return true + return lower.endsWith('.dat') && lower.includes(datName) +} + +function scoreDatName(fileName: string): number { + if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1 + if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1 + return 2 +} + +function isThumbnailDat(fileName: string): boolean { + return fileName.includes('.t.dat') || fileName.includes('_t.dat') +} + +function walkForDat( + root: string, + datName: string, + maxDepth = 4, + allowThumbnail = true, + thumbOnly = false +): { path: string | null; matchedBases: string[] } { + const stack: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }] + const candidates: Candidate[] = [] + const matchedBases = new Set() + + while (stack.length) { + const current = stack.pop() as { dir: string; depth: number } + let entries: string[] + try { + entries = readdirSync(current.dir) + } catch { + continue + } + for (const entry of entries) { + const entryPath = join(current.dir, entry) + let stat + try { + stat = statSync(entryPath) + } catch { + continue + } + if (stat.isDirectory()) { + if (current.depth < maxDepth) { + stack.push({ dir: entryPath, depth: current.depth + 1 }) + } + continue + } + const lower = entry.toLowerCase() + if (!lower.endsWith('.dat')) continue + const baseLower = lower.slice(0, -4) + if (!isLikelyImageDatBase(baseLower)) continue + if (!hasXVariant(baseLower)) continue + if (!matchesDatName(lower, datName)) continue + matchedBases.add(baseLower) + const isThumb = isThumbnailDat(lower) + if (!allowThumbnail && isThumb) continue + if (thumbOnly && !isThumb) continue + const score = scoreDatName(lower) + candidates.push({ + score, + path: entryPath, + isThumb, + hasX: hasXVariant(baseLower) + }) + } + } + if (!candidates.length) { + return { path: null, matchedBases: Array.from(matchedBases).slice(0, 20) } + } + + const withX = candidates.filter((item) => item.hasX) + const basePool = withX.length ? withX : candidates + const nonThumb = basePool.filter((item) => !item.isThumb) + const finalPool = thumbOnly ? basePool : (nonThumb.length ? nonThumb : basePool) + + let best: { score: number; path: string } | null = null + for (const item of finalPool) { + if (!best || item.score > best.score) { + best = { score: item.score, path: item.path } + } + } + return { path: best?.path ?? null, matchedBases: Array.from(matchedBases).slice(0, 20) } +} + +function run() { + const result = walkForDat( + payload.root, + payload.datName, + payload.maxDepth, + payload.allowThumbnail, + payload.thumbOnly + ) + parentPort?.postMessage({ + type: 'done', + path: result.path, + root: payload.root, + datName: payload.datName, + matchedBases: result.matchedBases + }) +} + +try { + run() +} catch (err) { + parentPort?.postMessage({ type: 'error', error: String(err) }) +} diff --git a/electron/main.ts b/electron/main.ts new file mode 100644 index 0000000..45b9071 --- /dev/null +++ b/electron/main.ts @@ -0,0 +1,703 @@ +import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron' +import { Worker } from 'worker_threads' +import { join } from 'path' +import { autoUpdater } from 'electron-updater' +import { readFile, writeFile, mkdir } from 'fs/promises' +import { existsSync } from 'fs' +import { ConfigService } from './services/config' +import { dbPathService } from './services/dbPathService' +import { wcdbService } from './services/wcdbService' +import { chatService } from './services/chatService' +import { imageDecryptService } from './services/imageDecryptService' +import { imagePreloadService } from './services/imagePreloadService' +import { analyticsService } from './services/analyticsService' +import { groupAnalyticsService } from './services/groupAnalyticsService' +import { annualReportService } from './services/annualReportService' +import { exportService, ExportOptions } from './services/exportService' +import { KeyService } from './services/keyService' + +// 配置自动更新 +autoUpdater.autoDownload = false +autoUpdater.autoInstallOnAppQuit = true +autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载 +const AUTO_UPDATE_ENABLED = + process.env.AUTO_UPDATE_ENABLED === 'true' || + process.env.AUTO_UPDATE_ENABLED === '1' || + (process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL) + +// 单例服务 +let configService: ConfigService | null = null + +// 协议窗口实例 +let agreementWindow: BrowserWindow | null = null +let onboardingWindow: BrowserWindow | null = null +const keyService = new KeyService() + +let mainWindowReady = false +let shouldShowMain = true + +function createWindow(options: { autoShow?: boolean } = {}) { + // 获取图标路径 - 打包后在 resources 目录 + const { autoShow = true } = options + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev + ? join(__dirname, '../public/icon.ico') + : join(process.resourcesPath, 'icon.ico') + + const win = new BrowserWindow({ + width: 1400, + height: 900, + minWidth: 1000, + minHeight: 700, + icon: iconPath, + webPreferences: { + preload: join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false + }, + titleBarStyle: 'hidden', + titleBarOverlay: { + color: '#00000000', + symbolColor: '#1a1a1a', + height: 40 + }, + show: false + }) + + // 窗口准备好后显示 + win.once('ready-to-show', () => { + mainWindowReady = true + if (autoShow || shouldShowMain) { + win.show() + } + }) + + // 开发环境加载 vite 服务器 + if (process.env.VITE_DEV_SERVER_URL) { + win.loadURL(process.env.VITE_DEV_SERVER_URL) + + // 开发环境下按 F12 或 Ctrl+Shift+I 打开开发者工具 + win.webContents.on('before-input-event', (event, input) => { + if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { + if (win.webContents.isDevToolsOpened()) { + win.webContents.closeDevTools() + } else { + win.webContents.openDevTools() + } + event.preventDefault() + } + }) + } else { + win.loadFile(join(__dirname, '../dist/index.html')) + } + + return win +} + +/** + * 创建用户协议窗口 + */ +function createAgreementWindow() { + // 如果已存在,聚焦 + if (agreementWindow && !agreementWindow.isDestroyed()) { + agreementWindow.focus() + return agreementWindow + } + + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev + ? join(__dirname, '../public/icon.ico') + : join(process.resourcesPath, 'icon.ico') + + const isDark = nativeTheme.shouldUseDarkColors + + agreementWindow = new BrowserWindow({ + width: 700, + height: 600, + minWidth: 500, + minHeight: 400, + icon: iconPath, + webPreferences: { + preload: join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false + }, + titleBarStyle: 'hidden', + titleBarOverlay: { + color: '#00000000', + symbolColor: isDark ? '#FFFFFF' : '#333333', + height: 32 + }, + show: false, + backgroundColor: isDark ? '#1A1A1A' : '#FFFFFF' + }) + + agreementWindow.once('ready-to-show', () => { + agreementWindow?.show() + }) + + if (process.env.VITE_DEV_SERVER_URL) { + agreementWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/agreement-window`) + } else { + agreementWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: '/agreement-window' }) + } + + agreementWindow.on('closed', () => { + agreementWindow = null + }) + + return agreementWindow +} + +/** + * 创建首次引导窗口 + */ +function createOnboardingWindow() { + if (onboardingWindow && !onboardingWindow.isDestroyed()) { + onboardingWindow.focus() + return onboardingWindow + } + + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev + ? join(__dirname, '../public/icon.ico') + : join(process.resourcesPath, 'icon.ico') + + onboardingWindow = new BrowserWindow({ + width: 1100, + height: 720, + minWidth: 900, + minHeight: 600, + frame: false, + transparent: true, + backgroundColor: '#00000000', + hasShadow: false, + icon: iconPath, + webPreferences: { + preload: join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false + }, + show: false + }) + + onboardingWindow.once('ready-to-show', () => { + onboardingWindow?.show() + }) + + if (process.env.VITE_DEV_SERVER_URL) { + onboardingWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/onboarding-window`) + } else { + onboardingWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: '/onboarding-window' }) + } + + onboardingWindow.on('closed', () => { + onboardingWindow = null + }) + + return onboardingWindow +} + +function showMainWindow() { + shouldShowMain = true + if (mainWindowReady) { + mainWindow?.show() + } +} + +// 注册 IPC 处理器 +function registerIpcHandlers() { + // 配置相关 + ipcMain.handle('config:get', async (_, key: string) => { + return configService?.get(key as any) + }) + + ipcMain.handle('config:set', async (_, key: string, value: any) => { + return configService?.set(key as any, value) + }) + + ipcMain.handle('config:clear', async () => { + configService?.clear() + return true + }) + + // 文件对话框 + ipcMain.handle('dialog:openFile', async (_, options) => { + const { dialog } = await import('electron') + return dialog.showOpenDialog(options) + }) + + ipcMain.handle('dialog:openDirectory', async (_, options) => { + const { dialog } = await import('electron') + return dialog.showOpenDialog({ + properties: ['openDirectory', 'createDirectory'], + ...options + }) + }) + + ipcMain.handle('dialog:saveFile', async (_, options) => { + const { dialog } = await import('electron') + return dialog.showSaveDialog(options) + }) + + ipcMain.handle('shell:openPath', async (_, path: string) => { + const { shell } = await import('electron') + return shell.openPath(path) + }) + + ipcMain.handle('shell:openExternal', async (_, url: string) => { + const { shell } = await import('electron') + return shell.openExternal(url) + }) + + ipcMain.handle('app:getDownloadsPath', async () => { + return app.getPath('downloads') + }) + + ipcMain.handle('app:getVersion', async () => { + return app.getVersion() + }) + + ipcMain.handle('log:getPath', async () => { + return join(app.getPath('userData'), 'logs', 'wcdb.log') + }) + + ipcMain.handle('log:read', async () => { + try { + const logPath = join(app.getPath('userData'), 'logs', 'wcdb.log') + const content = await readFile(logPath, 'utf8') + return { success: true, content } + } catch (e) { + return { success: false, error: String(e) } + } + }) + + ipcMain.handle('app:checkForUpdates', async () => { + if (!AUTO_UPDATE_ENABLED) { + return { hasUpdate: false } + } + try { + const result = await autoUpdater.checkForUpdates() + if (result && result.updateInfo) { + const currentVersion = app.getVersion() + const latestVersion = result.updateInfo.version + if (latestVersion !== currentVersion) { + return { + hasUpdate: true, + version: latestVersion, + releaseNotes: result.updateInfo.releaseNotes as string || '' + } + } + } + return { hasUpdate: false } + } catch (error) { + console.error('检查更新失败:', error) + return { hasUpdate: false } + } + }) + + ipcMain.handle('app:downloadAndInstall', async (event) => { + if (!AUTO_UPDATE_ENABLED) { + throw new Error('自动更新已暂时禁用') + } + const win = BrowserWindow.fromWebContents(event.sender) + + // 监听下载进度 + autoUpdater.on('download-progress', (progress) => { + win?.webContents.send('app:downloadProgress', progress.percent) + }) + + // 下载完成后自动安装 + autoUpdater.on('update-downloaded', () => { + autoUpdater.quitAndInstall(false, true) + }) + + try { + await autoUpdater.downloadUpdate() + } catch (error) { + console.error('下载更新失败:', error) + throw error + } + }) + + // 窗口控制 + ipcMain.on('window:minimize', (event) => { + BrowserWindow.fromWebContents(event.sender)?.minimize() + }) + + ipcMain.on('window:maximize', (event) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (win?.isMaximized()) { + win.unmaximize() + } else { + win?.maximize() + } + }) + + ipcMain.on('window:close', (event) => { + BrowserWindow.fromWebContents(event.sender)?.close() + }) + + // 更新窗口控件主题色 + ipcMain.on('window:setTitleBarOverlay', (event, options: { symbolColor: string }) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (win) { + try { + win.setTitleBarOverlay({ + color: '#00000000', + symbolColor: options.symbolColor, + height: 40 + }) + } catch (error) { + console.warn('TitleBarOverlay not enabled for this window:', error) + } + } + }) + + // 数据库路径相关 + ipcMain.handle('dbpath:autoDetect', async () => { + return dbPathService.autoDetect() + }) + + ipcMain.handle('dbpath:scanWxids', async (_, rootPath: string) => { + return dbPathService.scanWxids(rootPath) + }) + + ipcMain.handle('dbpath:getDefault', async () => { + return dbPathService.getDefaultPath() + }) + + // WCDB 数据库相关 + ipcMain.handle('wcdb:testConnection', async (_, dbPath: string, hexKey: string, wxid: string) => { + return wcdbService.testConnection(dbPath, hexKey, wxid) + }) + + ipcMain.handle('wcdb:open', async (_, dbPath: string, hexKey: string, wxid: string) => { + return wcdbService.open(dbPath, hexKey, wxid) + }) + + ipcMain.handle('wcdb:close', async () => { + wcdbService.close() + return true + }) + + // 聊天相关 + ipcMain.handle('chat:connect', async () => { + return chatService.connect() + }) + + ipcMain.handle('chat:getSessions', async () => { + return chatService.getSessions() + }) + + ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => { + return chatService.getMessages(sessionId, offset, limit) + }) + + ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => { + return chatService.getLatestMessages(sessionId, limit) + }) + + ipcMain.handle('chat:getContact', async (_, username: string) => { + return chatService.getContact(username) + }) + + ipcMain.handle('chat:getContactAvatar', async (_, username: string) => { + return chatService.getContactAvatar(username) + }) + + ipcMain.handle('chat:getMyAvatarUrl', async () => { + return chatService.getMyAvatarUrl() + }) + + ipcMain.handle('chat:downloadEmoji', async (_, cdnUrl: string, md5?: string) => { + return chatService.downloadEmoji(cdnUrl, md5) + }) + + ipcMain.handle('chat:close', async () => { + chatService.close() + return true + }) + + ipcMain.handle('chat:getSessionDetail', async (_, sessionId: string) => { + return chatService.getSessionDetail(sessionId) + }) + + ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => { + return chatService.getImageData(sessionId, msgId) + }) + + ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string) => { + return chatService.getVoiceData(sessionId, msgId) + }) + + ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => { + return chatService.getMessageById(sessionId, localId) + }) + + ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => { + return imageDecryptService.decryptImage(payload) + }) + ipcMain.handle('image:resolveCache', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => { + return imageDecryptService.resolveCachedImage(payload) + }) + ipcMain.handle('image:preload', async (_, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => { + imagePreloadService.enqueue(payloads || []) + return true + }) + + // 导出相关 + ipcMain.handle('export:exportSessions', async (_, sessionIds: string[], outputDir: string, options: ExportOptions) => { + return exportService.exportSessions(sessionIds, outputDir, options) + }) + + ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => { + return exportService.exportSessionToChatLab(sessionId, outputPath, options) + }) + + // 数据分析相关 + ipcMain.handle('analytics:getOverallStatistics', async () => { + return analyticsService.getOverallStatistics() + }) + + ipcMain.handle('analytics:getContactRankings', async (_, limit?: number) => { + return analyticsService.getContactRankings(limit) + }) + + ipcMain.handle('analytics:getTimeDistribution', async () => { + return analyticsService.getTimeDistribution() + }) + + // 群聊分析相关 + ipcMain.handle('groupAnalytics:getGroupChats', async () => { + return groupAnalyticsService.getGroupChats() + }) + + ipcMain.handle('groupAnalytics:getGroupMembers', async (_, chatroomId: string) => { + return groupAnalyticsService.getGroupMembers(chatroomId) + }) + + ipcMain.handle('groupAnalytics:getGroupMessageRanking', async (_, chatroomId: string, limit?: number, startTime?: number, endTime?: number) => { + return groupAnalyticsService.getGroupMessageRanking(chatroomId, limit, startTime, endTime) + }) + + ipcMain.handle('groupAnalytics:getGroupActiveHours', async (_, chatroomId: string, startTime?: number, endTime?: number) => { + return groupAnalyticsService.getGroupActiveHours(chatroomId, startTime, endTime) + }) + + ipcMain.handle('groupAnalytics:getGroupMediaStats', async (_, chatroomId: string, startTime?: number, endTime?: number) => { + return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime) + }) + + // 打开协议窗口 + ipcMain.handle('window:openAgreementWindow', async () => { + createAgreementWindow() + return true + }) + + // 完成引导,关闭引导窗口并显示主窗口 + ipcMain.handle('window:completeOnboarding', async () => { + try { + configService?.set('onboardingDone', true) + } catch (e) { + console.error('保存引导完成状态失败:', e) + } + + if (onboardingWindow && !onboardingWindow.isDestroyed()) { + onboardingWindow.close() + } + showMainWindow() + return true + }) + + // 重新打开首次引导窗口,并隐藏主窗口 + ipcMain.handle('window:openOnboardingWindow', async () => { + shouldShowMain = false + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.hide() + } + createOnboardingWindow() + return true + }) + + // 年度报告相关 + ipcMain.handle('annualReport:getAvailableYears', async () => { + const cfg = configService || new ConfigService() + configService = cfg + return annualReportService.getAvailableYears({ + dbPath: cfg.get('dbPath'), + decryptKey: cfg.get('decryptKey'), + wxid: cfg.get('myWxid') + }) + }) + + ipcMain.handle('annualReport:generateReport', async (_, year: number) => { + const cfg = configService || new ConfigService() + configService = cfg + + const dbPath = cfg.get('dbPath') + const decryptKey = cfg.get('decryptKey') + const wxid = cfg.get('myWxid') + const logEnabled = cfg.get('logEnabled') + + const resourcesPath = app.isPackaged + ? join(process.resourcesPath, 'resources') + : join(app.getAppPath(), 'resources') + const userDataPath = app.getPath('userData') + + const workerPath = join(__dirname, 'annualReportWorker.js') + + return await new Promise((resolve) => { + const worker = new Worker(workerPath, { + workerData: { year, dbPath, decryptKey, myWxid: wxid, resourcesPath, userDataPath, logEnabled } + }) + + const cleanup = () => { + worker.removeAllListeners() + } + + worker.on('message', (msg: any) => { + if (msg && msg.type === 'annualReport:progress') { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) { + win.webContents.send('annualReport:progress', msg.data) + } + } + return + } + if (msg && (msg.type === 'annualReport:result' || msg.type === 'done')) { + cleanup() + void worker.terminate() + resolve(msg.data ?? msg.result) + return + } + if (msg && (msg.type === 'annualReport:error' || msg.type === 'error')) { + cleanup() + void worker.terminate() + resolve({ success: false, error: msg.error || '年度报告生成失败' }) + } + }) + + worker.on('error', (err) => { + cleanup() + resolve({ success: false, error: String(err) }) + }) + + worker.on('exit', (code) => { + if (code !== 0) { + cleanup() + resolve({ success: false, error: `年度报告线程异常退出: ${code}` }) + } + }) + }) + }) + + ipcMain.handle('annualReport:exportImages', async (_, payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => { + try { + const { baseDir, folderName, images } = payload + if (!baseDir || !folderName || !Array.isArray(images) || images.length === 0) { + return { success: false, error: '导出参数无效' } + } + + let targetDir = join(baseDir, folderName) + if (existsSync(targetDir)) { + let idx = 2 + while (existsSync(`${targetDir}_${idx}`)) idx++ + targetDir = `${targetDir}_${idx}` + } + + await mkdir(targetDir, { recursive: true }) + + for (const img of images) { + const dataUrl = img.dataUrl || '' + const commaIndex = dataUrl.indexOf(',') + if (commaIndex <= 0) continue + const base64 = dataUrl.slice(commaIndex + 1) + const buffer = Buffer.from(base64, 'base64') + const filePath = join(targetDir, img.name) + await writeFile(filePath, buffer) + } + + return { success: true, dir: targetDir } + } catch (e) { + return { success: false, error: String(e) } + } + }) + + // 密钥获取 + ipcMain.handle('key:autoGetDbKey', async (event) => { + return keyService.autoGetDbKey(60_000, (message, level) => { + event.sender.send('key:dbKeyStatus', { message, level }) + }) + }) + + ipcMain.handle('key:autoGetImageKey', async (event, manualDir?: string) => { + return keyService.autoGetImageKey(manualDir, (message) => { + event.sender.send('key:imageKeyStatus', { message }) + }) + }) + +} + +// 主窗口引用 +let mainWindow: BrowserWindow | null = null + +// 启动时自动检测更新 +function checkForUpdatesOnStartup() { + if (!AUTO_UPDATE_ENABLED) return + // 开发环境不检测更新 + if (process.env.VITE_DEV_SERVER_URL) return + + // 延迟3秒检测,等待窗口完全加载 + setTimeout(async () => { + try { + const result = await autoUpdater.checkForUpdates() + if (result && result.updateInfo) { + const currentVersion = app.getVersion() + const latestVersion = result.updateInfo.version + if (latestVersion !== currentVersion && mainWindow) { + // 通知渲染进程有新版本 + mainWindow.webContents.send('app:updateAvailable', { + version: latestVersion, + releaseNotes: result.updateInfo.releaseNotes || '' + }) + } + } + } catch (error) { + console.error('启动时检查更新失败:', error) + } + }, 3000) +} + +app.whenReady().then(() => { + configService = new ConfigService() + const resourcesPath = app.isPackaged + ? join(process.resourcesPath, 'resources') + : join(app.getAppPath(), 'resources') + const userDataPath = app.getPath('userData') + wcdbService.setPaths(resourcesPath, userDataPath) + wcdbService.setLogEnabled(configService.get('logEnabled') === true) + registerIpcHandlers() + const onboardingDone = configService.get('onboardingDone') + shouldShowMain = onboardingDone === true + mainWindow = createWindow({ autoShow: shouldShowMain }) + + if (!onboardingDone) { + createOnboardingWindow() + } + + // 启动时检测更新 + checkForUpdatesOnStartup() + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + mainWindow = createWindow() + } + }) +}) + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit() + } +}) diff --git a/electron/preload.ts b/electron/preload.ts new file mode 100644 index 0000000..22f20af --- /dev/null +++ b/electron/preload.ts @@ -0,0 +1,165 @@ +import { contextBridge, ipcRenderer } from 'electron' + +// 暴露给渲染进程的 API +contextBridge.exposeInMainWorld('electronAPI', { + // 配置 + config: { + get: (key: string) => ipcRenderer.invoke('config:get', key), + set: (key: string, value: any) => ipcRenderer.invoke('config:set', key, value), + clear: () => ipcRenderer.invoke('config:clear') + }, + + + // 对话框 + dialog: { + openFile: (options: any) => ipcRenderer.invoke('dialog:openFile', options), + openDirectory: (options: any) => ipcRenderer.invoke('dialog:openDirectory', options), + saveFile: (options: any) => ipcRenderer.invoke('dialog:saveFile', options) + }, + + // Shell + shell: { + openPath: (path: string) => ipcRenderer.invoke('shell:openPath', path), + openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url) + }, + + // App + app: { + getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'), + getVersion: () => ipcRenderer.invoke('app:getVersion'), + checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'), + downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'), + onDownloadProgress: (callback: (progress: number) => void) => { + ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress)) + return () => ipcRenderer.removeAllListeners('app:downloadProgress') + }, + onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => { + ipcRenderer.on('app:updateAvailable', (_, info) => callback(info)) + return () => ipcRenderer.removeAllListeners('app:updateAvailable') + } + }, + + // 日志 + log: { + getPath: () => ipcRenderer.invoke('log:getPath'), + read: () => ipcRenderer.invoke('log:read') + }, + + // 窗口控制 + window: { + minimize: () => ipcRenderer.send('window:minimize'), + maximize: () => ipcRenderer.send('window:maximize'), + close: () => ipcRenderer.send('window:close'), + openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'), + completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'), + openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'), + setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options) + }, + + // 数据库路径 + dbPath: { + autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'), + scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath), + getDefault: () => ipcRenderer.invoke('dbpath:getDefault') + }, + + // WCDB 数据库 + wcdb: { + testConnection: (dbPath: string, hexKey: string, wxid: string) => + ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid), + open: (dbPath: string, hexKey: string, wxid: string) => + ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid), + close: () => ipcRenderer.invoke('wcdb:close') + }, + + // 密钥获取 + key: { + autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'), + autoGetImageKey: (manualDir?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir), + onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => { + ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload)) + return () => ipcRenderer.removeAllListeners('key:dbKeyStatus') + }, + onImageKeyStatus: (callback: (payload: { message: string }) => void) => { + ipcRenderer.on('key:imageKeyStatus', (_, payload) => callback(payload)) + return () => ipcRenderer.removeAllListeners('key:imageKeyStatus') + } + }, + + + // 聊天 + chat: { + connect: () => ipcRenderer.invoke('chat:connect'), + getSessions: () => ipcRenderer.invoke('chat:getSessions'), + getMessages: (sessionId: string, offset?: number, limit?: number) => + ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit), + getLatestMessages: (sessionId: string, limit?: number) => + ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit), + getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username), + getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username), + getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'), + downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5), + close: () => ipcRenderer.invoke('chat:close'), + getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId), + getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId), + getVoiceData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId) + }, + + // 图片解密 + image: { + decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => + ipcRenderer.invoke('image:decrypt', payload), + resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => + ipcRenderer.invoke('image:resolveCache', payload), + preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => + ipcRenderer.invoke('image:preload', payloads), + onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => { + ipcRenderer.on('image:updateAvailable', (_, payload) => callback(payload)) + return () => ipcRenderer.removeAllListeners('image:updateAvailable') + }, + onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => { + ipcRenderer.on('image:cacheResolved', (_, payload) => callback(payload)) + return () => ipcRenderer.removeAllListeners('image:cacheResolved') + } + }, + + // 数据分析 + analytics: { + getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'), + getContactRankings: (limit?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit), + getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'), + onProgress: (callback: (payload: { status: string; progress: number }) => void) => { + ipcRenderer.on('analytics:progress', (_, payload) => callback(payload)) + return () => ipcRenderer.removeAllListeners('analytics:progress') + } + }, + + // 群聊分析 + groupAnalytics: { + getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'), + getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId), + getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime), + getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime), + getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime) + }, + + // 年度报告 + annualReport: { + getAvailableYears: () => ipcRenderer.invoke('annualReport:getAvailableYears'), + generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year), + exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => + ipcRenderer.invoke('annualReport:exportImages', payload), + onProgress: (callback: (payload: { status: string; progress: number }) => void) => { + ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload)) + return () => ipcRenderer.removeAllListeners('annualReport:progress') + } + }, + + // 导出 + export: { + exportSessions: (sessionIds: string[], outputDir: string, options: any) => + ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options), + exportSession: (sessionId: string, outputPath: string, options: any) => + ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options) + } +}) diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts new file mode 100644 index 0000000..a033fb6 --- /dev/null +++ b/electron/services/analyticsService.ts @@ -0,0 +1,490 @@ +import { ConfigService } from './config' +import { wcdbService } from './wcdbService' + +export interface ChatStatistics { + totalMessages: number + textMessages: number + imageMessages: number + voiceMessages: number + videoMessages: number + emojiMessages: number + otherMessages: number + sentMessages: number + receivedMessages: number + firstMessageTime: number | null + lastMessageTime: number | null + activeDays: number + messageTypeCounts: Record +} + +export interface TimeDistribution { + hourlyDistribution: Record + weekdayDistribution: Record + monthlyDistribution: Record +} + +export interface ContactRanking { + username: string + displayName: string + avatarUrl?: string + messageCount: number + sentCount: number + receivedCount: number + lastMessageTime: number | null +} + +class AnalyticsService { + private configService: ConfigService + private fallbackAggregateCache: { key: string; data: any; updatedAt: number } | null = null + private aggregateCache: { key: string; data: any; updatedAt: number } | null = null + private aggregatePromise: { key: string; promise: Promise<{ success: boolean; data?: any; source?: string; error?: string }> } | null = null + + constructor() { + this.configService = new ConfigService() + } + + private cleanAccountDirName(name: string): string { + const trimmed = name.trim() + if (!trimmed) return trimmed + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + if (match) return match[1] + return trimmed + } + return trimmed + } + + private isPrivateSession(username: string, cleanedWxid: string): boolean { + if (!username) return false + if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false + if (username.includes('@chatroom')) return false + if (username === 'filehelper') return false + if (username.startsWith('gh_')) return false + + const excludeList = [ + 'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle', + 'newsapp', 'brandsessionholder', 'brandservicesessionholder', + 'notifymessage', 'opencustomerservicemsg', 'notification_messages', + 'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup', + '@helper_folders', '@placeholder_foldgroup' + ] + + for (const prefix of excludeList) { + if (username.startsWith(prefix) || username === prefix) return false + } + + if (username.includes('@kefu.openim') || username.includes('@openim')) return false + if (username.includes('service_')) return false + + return true + } + + private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> { + const wxid = this.configService.get('myWxid') + const dbPath = this.configService.get('dbPath') + const decryptKey = this.configService.get('decryptKey') + if (!wxid) return { success: false, error: '未配置微信ID' } + if (!dbPath) return { success: false, error: '未配置数据库路径' } + if (!decryptKey) return { success: false, error: '未配置解密密钥' } + + const cleanedWxid = this.cleanAccountDirName(wxid) + const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid) + if (!ok) return { success: false, error: 'WCDB 打开失败' } + return { success: true, cleanedWxid } + } + + private async getPrivateSessions( + cleanedWxid: string + ): Promise<{ usernames: string[]; numericIds: string[] }> { + const sessionResult = await wcdbService.getSessions() + if (!sessionResult.success || !sessionResult.sessions) { + return { usernames: [], numericIds: [] } + } + const rows = sessionResult.sessions as Record[] + + const sample = rows[0] + void sample + + const sessions = rows.map((row) => { + const username = row.username || row.user_name || row.userName || '' + const idValue = + row.id ?? + row.session_id ?? + row.sessionId ?? + row.sid ?? + row.local_id ?? + row.user_id ?? + row.userId ?? + row.chatroom_id ?? + row.chatroomId ?? + null + return { username, idValue } + }) + const usernames = sessions.map((s) => s.username) + const privateSessions = sessions.filter((s) => this.isPrivateSession(s.username, cleanedWxid)) + const privateUsernames = privateSessions.map((s) => s.username) + const numericIds = privateSessions + .map((s) => s.idValue) + .filter((id) => typeof id === 'number' || (typeof id === 'string' && /^\d+$/.test(id))) + .map((id) => String(id)) + return { usernames: privateUsernames, numericIds } + } + + private async iterateSessionMessages( + sessionId: string, + onRow: (row: Record) => void, + beginTimestamp = 0, + endTimestamp = 0 + ): Promise { + const cursorResult = await wcdbService.openMessageCursor( + sessionId, + 500, + true, + beginTimestamp, + endTimestamp + ) + if (!cursorResult.success || !cursorResult.cursor) return + + try { + let hasMore = true + let batchCount = 0 + while (hasMore) { + const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor) + if (!batch.success || !batch.rows) break + for (const row of batch.rows) { + onRow(row) + } + hasMore = batch.hasMore === true + + // 每处理完一个批次,如果已经处理了较多数据,暂时让出执行权 + batchCount++ + if (batchCount % 10 === 0) { + await new Promise(resolve => setImmediate(resolve)) + } + } + } finally { + await wcdbService.closeMessageCursor(cursorResult.cursor) + } + } + + private setProgress(window: any, status: string, progress: number) { + if (window && !window.isDestroyed()) { + window.webContents.send('analytics:progress', { status, progress }) + } + } + + private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string { + const sample = sessionIds.slice(0, 5).join(',') + return `${beginTimestamp}-${endTimestamp}-${sessionIds.length}-${sample}` + } + + private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise { + const aggregate = { + total: 0, + sent: 0, + received: 0, + firstTime: 0, + lastTime: 0, + typeCounts: {} as Record, + hourly: {} as Record, + weekday: {} as Record, + daily: {} as Record, + monthly: {} as Record, + sessions: {} as Record, + idMap: {} + } + + for (const sessionId of sessionIds) { + const sessionStat = { total: 0, sent: 0, received: 0, lastTime: 0 } + await this.iterateSessionMessages(sessionId, (row) => { + const createTime = parseInt(row.create_time || row.createTime || row.create_time_ms || '0', 10) + if (!createTime) return + if (beginTimestamp > 0 && createTime < beginTimestamp) return + if (endTimestamp > 0 && createTime > endTimestamp) return + + const localType = parseInt(row.local_type || row.type || '1', 10) + const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? 0 + const isSend = String(isSendRaw) === '1' || isSendRaw === 1 || isSendRaw === true + + aggregate.total += 1 + sessionStat.total += 1 + + aggregate.typeCounts[localType] = (aggregate.typeCounts[localType] || 0) + 1 + + if (isSend) { + aggregate.sent += 1 + sessionStat.sent += 1 + } else { + aggregate.received += 1 + sessionStat.received += 1 + } + + if (aggregate.firstTime === 0 || createTime < aggregate.firstTime) { + aggregate.firstTime = createTime + } + if (createTime > aggregate.lastTime) { + aggregate.lastTime = createTime + } + if (createTime > sessionStat.lastTime) { + sessionStat.lastTime = createTime + } + + const date = new Date(createTime * 1000) + const hour = date.getHours() + const weekday = date.getDay() + const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + const dayKey = `${monthKey}-${String(date.getDate()).padStart(2, '0')}` + + aggregate.hourly[hour] = (aggregate.hourly[hour] || 0) + 1 + aggregate.weekday[weekday] = (aggregate.weekday[weekday] || 0) + 1 + aggregate.monthly[monthKey] = (aggregate.monthly[monthKey] || 0) + 1 + aggregate.daily[dayKey] = (aggregate.daily[dayKey] || 0) + 1 + }, beginTimestamp, endTimestamp) + + if (sessionStat.total > 0) { + aggregate.sessions[sessionId] = sessionStat + } + } + + return aggregate + } + + private async getAggregateWithFallback( + sessionIds: string[], + beginTimestamp = 0, + endTimestamp = 0, + window?: any + ): Promise<{ success: boolean; data?: any; source?: string; error?: string }> { + const cacheKey = this.buildAggregateCacheKey(sessionIds, beginTimestamp, endTimestamp) + if (this.aggregateCache && this.aggregateCache.key === cacheKey) { + if (Date.now() - this.aggregateCache.updatedAt < 5 * 60 * 1000) { + return { success: true, data: this.aggregateCache.data, source: 'cache' } + } + } + + if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) { + return this.aggregatePromise.promise + } + + const promise = (async () => { + const result = await wcdbService.getAggregateStats(sessionIds, beginTimestamp, endTimestamp) + if (result.success && result.data && result.data.total > 0) { + this.aggregateCache = { key: cacheKey, data: result.data, updatedAt: Date.now() } + return { success: true, data: result.data, source: 'dll' } + } + + if (this.fallbackAggregateCache && this.fallbackAggregateCache.key === cacheKey) { + if (Date.now() - this.fallbackAggregateCache.updatedAt < 5 * 60 * 1000) { + return { success: true, data: this.fallbackAggregateCache.data, source: 'cursor-cache' } + } + } + + if (window) { + this.setProgress(window, '原生聚合为0,使用游标统计...', 45) + } + + const data = await this.computeAggregateByCursor(sessionIds, beginTimestamp, endTimestamp) + this.fallbackAggregateCache = { key: cacheKey, data, updatedAt: Date.now() } + this.aggregateCache = { key: cacheKey, data, updatedAt: Date.now() } + return { success: true, data, source: 'cursor' } + })() + + this.aggregatePromise = { key: cacheKey, promise } + try { + return await promise + } finally { + if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) { + this.aggregatePromise = null + } + } + } + + private normalizeAggregateSessions( + sessions: Record | undefined, + idMap: Record | undefined + ): Record { + if (!sessions) return {} + if (!idMap) return sessions + const keys = Object.keys(sessions) + if (keys.length === 0) return sessions + const numericKeys = keys.every((k) => /^\d+$/.test(k)) + if (!numericKeys) return sessions + const remapped: Record = {} + for (const [id, stat] of Object.entries(sessions)) { + const username = idMap[id] || id + remapped[username] = stat + } + return remapped + } + + private async logAggregateDiagnostics(sessionIds: string[]): Promise { + const samples = sessionIds.slice(0, 5) + const results = await Promise.all(samples.map(async (sessionId) => { + const countResult = await wcdbService.getMessageCount(sessionId) + return { sessionId, success: countResult.success, count: countResult.count, error: countResult.error } + })) + void results + } + + async getOverallStatistics(): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } + + const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid) + if (sessionInfo.usernames.length === 0) { + return { success: false, error: '未找到消息会话' } + } + + const { BrowserWindow } = require('electron') + const win = BrowserWindow.getAllWindows()[0] + this.setProgress(win, '正在执行原生数据聚合...', 30) + + const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win) + + if (!result.success || !result.data) { + return { success: false, error: result.error || '聚合统计失败' } + } + + this.setProgress(win, '同步分析结果...', 90) + const d = result.data + if (d.total === 0 && sessionInfo.usernames.length > 0) { + await this.logAggregateDiagnostics(sessionInfo.usernames) + } + + const textTypes = [1, 244813135921] + let textMessages = 0 + for (const t of textTypes) textMessages += (d.typeCounts[t] || 0) + const imageMessages = d.typeCounts[3] || 0 + const voiceMessages = d.typeCounts[34] || 0 + const videoMessages = d.typeCounts[43] || 0 + const emojiMessages = d.typeCounts[47] || 0 + const otherMessages = d.total - textMessages - imageMessages - voiceMessages - videoMessages - emojiMessages + + // 估算活跃天数(按月分布估算或从日期列表中提取,由于 C++ 只返回了月份映射, + // 我们这里暂时返回月份数作为参考,或者如果需要精确天数,原生层需要返回 Set 大小) + // 为了性能,我们先用月份数,或者后续再优化 C++ 返回 activeDays 计数。 + // 当前 C++ 逻辑中 gs.monthly.size() 就是活跃月份。 + const activeMonths = Object.keys(d.monthly).length + + return { + success: true, + data: { + totalMessages: d.total, + textMessages, + imageMessages, + voiceMessages, + videoMessages, + emojiMessages, + otherMessages: Math.max(0, otherMessages), + sentMessages: d.sent, + receivedMessages: d.received, + firstMessageTime: d.firstTime || null, + lastMessageTime: d.lastTime || null, + activeDays: activeMonths * 20, // 粗略估算,或改为返回活跃月份 + messageTypeCounts: d.typeCounts + } + } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getContactRankings(limit: number = 20): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } + + const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid) + if (sessionInfo.usernames.length === 0) { + return { success: false, error: '未找到消息会话' } + } + + const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0) + if (!result.success || !result.data) { + return { success: false, error: result.error || '聚合统计失败' } + } + + const d = result.data + const sessions = this.normalizeAggregateSessions(d.sessions, d.idMap) + const usernames = Object.keys(sessions) + const [displayNames, avatarUrls] = await Promise.all([ + wcdbService.getDisplayNames(usernames), + wcdbService.getAvatarUrls(usernames) + ]) + + const rankings: ContactRanking[] = usernames + .map((username) => { + const stat = sessions[username] + const displayName = displayNames.success && displayNames.map + ? (displayNames.map[username] || username) + : username + const avatarUrl = avatarUrls.success && avatarUrls.map + ? avatarUrls.map[username] + : undefined + return { + username, + displayName, + avatarUrl, + messageCount: stat.total, + sentCount: stat.sent, + receivedCount: stat.received, + lastMessageTime: stat.lastTime || null + } + }) + .sort((a, b) => b.messageCount - a.messageCount) + .slice(0, limit) + + return { success: true, data: rankings } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getTimeDistribution(): Promise<{ success: boolean; data?: TimeDistribution; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } + + const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid) + if (sessionInfo.usernames.length === 0) { + return { success: false, error: '未找到消息会话' } + } + + const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0) + if (!result.success || !result.data) { + return { success: false, error: result.error || '聚合统计失败' } + } + + const d = result.data + + // SQLite strftime('%w') 返回 0=Sun, 1=Mon...6=Sat + // 前端期望 1=Mon...7=Sun + const weekdayDistribution: Record = {} + for (const [w, count] of Object.entries(d.weekday)) { + const sqliteW = parseInt(w, 10) + const jsW = sqliteW === 0 ? 7 : sqliteW + weekdayDistribution[jsW] = count as number + } + + // 补全 24 小时 + const hourlyDistribution: Record = {} + for (let i = 0; i < 24; i++) { + hourlyDistribution[i] = d.hourly[i] || 0 + } + + return { + success: true, + data: { + hourlyDistribution, + weekdayDistribution, + monthlyDistribution: d.monthly + } + } + } catch (e) { + return { success: false, error: String(e) } + } + } +} + +export const analyticsService = new AnalyticsService() diff --git a/electron/services/annualReportService.ts b/electron/services/annualReportService.ts new file mode 100644 index 0000000..caab4be --- /dev/null +++ b/electron/services/annualReportService.ts @@ -0,0 +1,928 @@ +import { parentPort } from 'worker_threads' +import { wcdbService } from './wcdbService' + +export interface TopContact { + username: string + displayName: string + avatarUrl?: string + messageCount: number + sentCount: number + receivedCount: number +} + +export interface MonthlyTopFriend { + month: number + displayName: string + avatarUrl?: string + messageCount: number +} + +export interface ChatPeakDay { + date: string + messageCount: number + topFriend?: string + topFriendCount?: number +} + +export interface ActivityHeatmap { + data: number[][] +} + +export interface AnnualReportData { + year: number + totalMessages: number + totalFriends: number + coreFriends: TopContact[] + monthlyTopFriends: MonthlyTopFriend[] + peakDay: ChatPeakDay | null + longestStreak: { + friendName: string + days: number + startDate: string + endDate: string + } | null + activityHeatmap: ActivityHeatmap + midnightKing: { + displayName: string + count: number + percentage: number + } | null + selfAvatarUrl?: string + mutualFriend: { + displayName: string + avatarUrl?: string + sentCount: number + receivedCount: number + ratio: number + } | null + socialInitiative: { + initiatedChats: number + receivedChats: number + initiativeRate: number + } | null + responseSpeed: { + avgResponseTime: number + fastestFriend: string + fastestTime: number + } | null + topPhrases: { + phrase: string + count: number + }[] +} + +class AnnualReportService { + constructor() { + } + + private broadcastProgress(status: string, progress: number) { + if (parentPort) { + parentPort.postMessage({ + type: 'annualReport:progress', + data: { status, progress } + }) + } + } + + private reportProgress(status: string, progress: number, onProgress?: (status: string, progress: number) => void) { + if (onProgress) { + onProgress(status, progress) + return + } + this.broadcastProgress(status, progress) + } + + private cleanAccountDirName(dirName: string): string { + const trimmed = dirName.trim() + if (!trimmed) return trimmed + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + if (match) return match[1] + return trimmed + } + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + if (suffixMatch) return suffixMatch[1] + return trimmed + } + + private async ensureConnectedWithConfig( + dbPath: string, + decryptKey: string, + wxid: string + ): Promise<{ success: boolean; cleanedWxid?: string; rawWxid?: string; error?: string }> { + if (!wxid) return { success: false, error: '未配置微信ID' } + if (!dbPath) return { success: false, error: '未配置数据库路径' } + if (!decryptKey) return { success: false, error: '未配置解密密钥' } + + const cleanedWxid = this.cleanAccountDirName(wxid) + const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid) + if (!ok) return { success: false, error: 'WCDB 打开失败' } + return { success: true, cleanedWxid, rawWxid: wxid } + } + + private async getPrivateSessions(cleanedWxid: string): Promise { + const sessionResult = await wcdbService.getSessions() + if (!sessionResult.success || !sessionResult.sessions) return [] + const rows = sessionResult.sessions as Record[] + + const excludeList = [ + 'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle', + 'newsapp', 'brandsessionholder', 'brandservicesessionholder', + 'notifymessage', 'opencustomerservicemsg', 'notification_messages', + 'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup', + '@helper_folders', '@placeholder_foldgroup' + ] + + return rows + .map((row) => row.username || row.user_name || row.userName || '') + .filter((username) => { + if (!username) return false + if (username.includes('@chatroom')) return false + if (username === 'filehelper') return false + if (username.startsWith('gh_')) return false + if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false + + for (const prefix of excludeList) { + if (username.startsWith(prefix) || username === prefix) return false + } + + if (username.includes('@kefu.openim') || username.includes('@openim')) return false + if (username.includes('service_')) return false + + return true + }) + } + + private async getEdgeMessageTime(sessionId: string, ascending: boolean): Promise { + const cursor = await wcdbService.openMessageCursor(sessionId, 1, ascending, 0, 0) + if (!cursor.success || !cursor.cursor) return null + try { + const batch = await wcdbService.fetchMessageBatch(cursor.cursor) + if (!batch.success || !batch.rows || batch.rows.length === 0) return null + const ts = parseInt(batch.rows[0].create_time || '0', 10) + return ts > 0 ? ts : null + } finally { + await wcdbService.closeMessageCursor(cursor.cursor) + } + } + + private decodeMessageContent(messageContent: any, compressContent: any): string { + let content = this.decodeMaybeCompressed(compressContent) + if (!content || content.length === 0) { + content = this.decodeMaybeCompressed(messageContent) + } + return content + } + + private decodeMaybeCompressed(raw: any): string { + if (!raw) return '' + if (typeof raw === 'string') { + if (raw.length === 0) return '' + if (this.looksLikeHex(raw)) { + const bytes = Buffer.from(raw, 'hex') + if (bytes.length > 0) return this.decodeBinaryContent(bytes) + } + if (this.looksLikeBase64(raw)) { + try { + const bytes = Buffer.from(raw, 'base64') + return this.decodeBinaryContent(bytes) + } catch { + return raw + } + } + return raw + } + return '' + } + + private decodeBinaryContent(data: Buffer): string { + if (data.length === 0) return '' + try { + if (data.length >= 4) { + const magic = data.readUInt32LE(0) + if (magic === 0xFD2FB528) { + const fzstd = require('fzstd') + const decompressed = fzstd.decompress(data) + return Buffer.from(decompressed).toString('utf-8') + } + } + const decoded = data.toString('utf-8') + const replacementCount = (decoded.match(/\uFFFD/g) || []).length + if (replacementCount < decoded.length * 0.2) { + return decoded.replace(/\uFFFD/g, '') + } + return data.toString('latin1') + } catch { + return '' + } + } + + private looksLikeHex(s: string): boolean { + if (s.length % 2 !== 0) return false + return /^[0-9a-fA-F]+$/.test(s) + } + + private looksLikeBase64(s: string): boolean { + if (s.length % 4 !== 0) return false + return /^[A-Za-z0-9+/=]+$/.test(s) + } + + private formatDateYmd(date: Date): string { + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + return `${y}-${m}-${d}` + } + + private async computeLongestStreak( + sessionIds: string[], + beginTimestamp: number, + endTimestamp: number, + onProgress?: (status: string, progress: number) => void, + progressStart: number = 0, + progressEnd: number = 0 + ): Promise<{ sessionId: string; days: number; start: Date | null; end: Date | null }> { + let bestSessionId = '' + let bestDays = 0 + let bestStart: Date | null = null + let bestEnd: Date | null = null + let lastProgressAt = 0 + let lastProgressSent = progressStart + + const shouldReportProgress = onProgress && progressEnd > progressStart && sessionIds.length > 0 + let apiTimeMs = 0 + let jsTimeMs = 0 + + for (let i = 0; i < sessionIds.length; i++) { + const sessionId = sessionIds[i] + const openStart = Date.now() + const cursor = await wcdbService.openMessageCursorLite(sessionId, 2000, true, beginTimestamp, endTimestamp) + apiTimeMs += Date.now() - openStart + if (!cursor.success || !cursor.cursor) continue + + let lastDayIndex: number | null = null + let currentStreak = 0 + let currentStart: Date | null = null + let maxStreak = 0 + let maxStart: Date | null = null + let maxEnd: Date | null = null + + try { + let hasMore = true + while (hasMore) { + const fetchStart = Date.now() + const batch = await wcdbService.fetchMessageBatch(cursor.cursor) + apiTimeMs += Date.now() - fetchStart + if (!batch.success || !batch.rows) break + + const processStart = Date.now() + for (const row of batch.rows) { + const createTime = parseInt(row.create_time || '0', 10) + if (!createTime) continue + + const dt = new Date(createTime * 1000) + const dayDate = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate()) + const dayIndex = Math.floor(dayDate.getTime() / 86400000) + + if (lastDayIndex !== null && dayIndex === lastDayIndex) continue + + if (lastDayIndex !== null && dayIndex - lastDayIndex === 1) { + currentStreak++ + } else { + currentStreak = 1 + currentStart = dayDate + } + + if (currentStreak > maxStreak) { + maxStreak = currentStreak + maxStart = currentStart + maxEnd = dayDate + } + + lastDayIndex = dayIndex + } + jsTimeMs += Date.now() - processStart + + hasMore = batch.hasMore === true + await new Promise(resolve => setImmediate(resolve)) + } + } finally { + const closeStart = Date.now() + await wcdbService.closeMessageCursor(cursor.cursor) + apiTimeMs += Date.now() - closeStart + } + + if (maxStreak > bestDays) { + bestDays = maxStreak + bestSessionId = sessionId + bestStart = maxStart + bestEnd = maxEnd + } + + if (shouldReportProgress) { + const now = Date.now() + if (now - lastProgressAt > 250) { + const ratio = Math.min(1, (i + 1) / sessionIds.length) + const progress = Math.floor(progressStart + ratio * (progressEnd - progressStart)) + if (progress > lastProgressSent) { + lastProgressSent = progress + lastProgressAt = now + const label = `${i + 1}/${sessionIds.length}` + const timing = (apiTimeMs > 0 || jsTimeMs > 0) + ? `, DB ${(apiTimeMs / 1000).toFixed(1)}s / JS ${(jsTimeMs / 1000).toFixed(1)}s` + : '' + onProgress?.(`计算连续聊天... (${label}${timing})`, progress) + } + } + } + } + + return { sessionId: bestSessionId, days: bestDays, start: bestStart, end: bestEnd } + } + + async getAvailableYears(params: { dbPath: string; decryptKey: string; wxid: string }): Promise<{ success: boolean; data?: number[]; error?: string }> { + try { + const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid) + if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } + + const sessionIds = await this.getPrivateSessions(conn.cleanedWxid) + if (sessionIds.length === 0) { + return { success: false, error: '未找到消息会话' } + } + + const fastYears = await wcdbService.getAvailableYears(sessionIds) + if (fastYears.success && fastYears.data) { + return { success: true, data: fastYears.data } + } + + const years = new Set() + for (const sessionId of sessionIds) { + const first = await this.getEdgeMessageTime(sessionId, true) + const last = await this.getEdgeMessageTime(sessionId, false) + if (!first && !last) continue + + const minYear = new Date((first || last || 0) * 1000).getFullYear() + const maxYear = new Date((last || first || 0) * 1000).getFullYear() + for (let y = minYear; y <= maxYear; y++) { + if (y >= 2010 && y <= new Date().getFullYear()) years.add(y) + } + } + + const sortedYears = Array.from(years).sort((a, b) => b - a) + return { success: true, data: sortedYears } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async generateReportWithConfig(params: { + year: number + wxid: string + dbPath: string + decryptKey: string + onProgress?: (status: string, progress: number) => void + }): Promise<{ success: boolean; data?: AnnualReportData; error?: string }> { + try { + const { year, wxid, dbPath, decryptKey, onProgress } = params + this.reportProgress('正在连接数据库...', 5, onProgress) + const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid) + if (!conn.success || !conn.cleanedWxid || !conn.rawWxid) return { success: false, error: conn.error } + + const cleanedWxid = conn.cleanedWxid + const rawWxid = conn.rawWxid + const sessionIds = await this.getPrivateSessions(cleanedWxid) + if (sessionIds.length === 0) { + return { success: false, error: '未找到消息会话' } + } + + this.reportProgress('加载会话列表...', 15, onProgress) + + const startTime = Math.floor(new Date(year, 0, 1).getTime() / 1000) + const endTime = Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000) + + let totalMessages = 0 + const contactStats = new Map() + const monthlyStats = new Map>() + const dailyStats = new Map() + const dailyContactStats = new Map>() + const heatmapData: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0)) + const midnightStats = new Map() + let longestStreakSessionId = '' + let longestStreakDays = 0 + let longestStreakStart: Date | null = null + let longestStreakEnd: Date | null = null + + const conversationStarts = new Map() + const responseTimeStats = new Map() + const phraseCount = new Map() + const lastMessageTime = new Map() + + const CONVERSATION_GAP = 3600 + + this.reportProgress('统计会话消息...', 20, onProgress) + const result = await wcdbService.getAnnualReportStats(sessionIds, startTime, endTime) + if (!result.success || !result.data) { + return { success: false, error: result.error ? `基础统计失败: ${result.error}` : '基础统计失败' } + } + + const d = result.data + totalMessages = d.total + this.reportProgress('汇总基础统计...', 25, onProgress) + + const totalMessagesForProgress = totalMessages > 0 ? totalMessages : sessionIds.length + let processedMessages = 0 + let lastProgressSent = 0 + let lastProgressAt = 0 + + // 填充基础统计 + for (const [sid, stat] of Object.entries(d.sessions)) { + const s = stat as any + contactStats.set(sid, { sent: s.sent, received: s.received }) + + const mMap = new Map() + for (const [m, c] of Object.entries(s.monthly || {})) { + mMap.set(parseInt(m, 10), c as number) + } + monthlyStats.set(sid, mMap) + } + + // 填充全局分布,并锁定峰值日期以减少逐日消息统计 + let peakDayKey = '' + let peakDayCount = 0 + for (const [day, count] of Object.entries(d.daily)) { + const c = count as number + dailyStats.set(day, c) + if (c > peakDayCount) { + peakDayCount = c + peakDayKey = day + } + } + + let useSqlExtras = false + let responseStatsFromSql: Record | null = null + let topPhrasesFromSql: { phrase: string; count: number }[] | null = null + let streakComputedInLoop = false + + let peakDayBegin = 0 + let peakDayEnd = 0 + if (peakDayKey) { + const start = new Date(`${peakDayKey}T00:00:00`).getTime() + if (!Number.isNaN(start)) { + peakDayBegin = Math.floor(start / 1000) + peakDayEnd = peakDayBegin + 24 * 3600 - 1 + } + } + + this.reportProgress('加载扩展统计... (初始化)', 30, onProgress) + const extras = await wcdbService.getAnnualReportExtras(sessionIds, startTime, endTime, peakDayBegin, peakDayEnd) + if (extras.success && extras.data) { + this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress) + const extrasData = extras.data as any + const heatmap = extrasData.heatmap as number[][] | undefined + if (Array.isArray(heatmap) && heatmap.length === 7) { + for (let w = 0; w < 7; w++) { + if (Array.isArray(heatmap[w])) { + for (let h = 0; h < 24; h++) { + heatmapData[w][h] = heatmap[w][h] || 0 + } + } + } + } + + this.reportProgress('加载扩展统计... (解析夜聊统计)', 33, onProgress) + const midnight = extrasData.midnight as Record | undefined + if (midnight) { + for (const [sid, count] of Object.entries(midnight)) { + midnightStats.set(sid, count as number) + } + } + + this.reportProgress('加载扩展统计... (解析对话发起)', 34, onProgress) + const conversation = extrasData.conversation as Record | undefined + if (conversation) { + for (const [sid, stats] of Object.entries(conversation)) { + conversationStarts.set(sid, { initiated: stats.initiated || 0, received: stats.received || 0 }) + } + } + + this.reportProgress('加载扩展统计... (解析响应速度)', 35, onProgress) + responseStatsFromSql = extrasData.response || null + + this.reportProgress('加载扩展统计... (解析峰值日)', 36, onProgress) + const peakDayCounts = extrasData.peakDay as Record | undefined + if (peakDayKey && peakDayCounts) { + const dayMap = new Map() + for (const [sid, count] of Object.entries(peakDayCounts)) { + dayMap.set(sid, count as number) + } + if (dayMap.size > 0) { + dailyContactStats.set(peakDayKey, dayMap) + } + } + + this.reportProgress('加载扩展统计... (解析常用语)', 37, onProgress) + const sqlPhrases = extrasData.topPhrases as { phrase: string; count: number }[] | undefined + if (Array.isArray(sqlPhrases) && sqlPhrases.length > 0) { + topPhrasesFromSql = sqlPhrases + } + + const streak = extrasData.streak as { sessionId?: string; days?: number; startDate?: string; endDate?: string } | undefined + if (streak && streak.sessionId && streak.days && streak.days > 0) { + longestStreakSessionId = streak.sessionId + longestStreakDays = streak.days + longestStreakStart = streak.startDate ? new Date(`${streak.startDate}T00:00:00`) : null + longestStreakEnd = streak.endDate ? new Date(`${streak.endDate}T00:00:00`) : null + if (longestStreakStart && !Number.isNaN(longestStreakStart.getTime()) && + longestStreakEnd && !Number.isNaN(longestStreakEnd.getTime())) { + streakComputedInLoop = true + } + } + + useSqlExtras = true + this.reportProgress('加载扩展统计... (完成)', 40, onProgress) + } else if (!extras.success) { + const reason = extras.error ? ` (${extras.error})` : '' + this.reportProgress(`扩展统计失败,转入完整分析...${reason}`, 30, onProgress) + } + + if (!useSqlExtras) { + // 注意:原生层目前未返回交叉维度 heatmapData[weekday][hour], + // 这里的 heatmapData 仍然需要通过下面的遍历来精确填充。 + + // 考虑到 Annual Report 需要一些复杂的序列特征(响应速度、对话发起)和文本特征(常用语), + // 我们仍然保留一次轻量级循环,但因为有了原生统计,我们可以分步进行,或者如果数据量极大则跳过某些步骤。 + // 为保持功能完整,我们进行深度集成的轻量遍历: + for (let i = 0; i < sessionIds.length; i++) { + const sessionId = sessionIds[i] + const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, startTime, endTime) + if (!cursor.success || !cursor.cursor) continue + + let lastDayIndex: number | null = null + let currentStreak = 0 + let currentStart: Date | null = null + let maxStreak = 0 + let maxStart: Date | null = null + let maxEnd: Date | null = null + + try { + let hasMore = true + while (hasMore) { + const batch = await wcdbService.fetchMessageBatch(cursor.cursor) + if (!batch.success || !batch.rows) break + + for (const row of batch.rows) { + const createTime = parseInt(row.create_time || '0', 10) + if (!createTime) continue + + const isSendRaw = row.computed_is_send ?? row.is_send ?? '0' + const isSent = parseInt(isSendRaw, 10) === 1 + const localType = parseInt(row.local_type || row.type || '1', 10) + + // 响应速度 & 对话发起 + if (!conversationStarts.has(sessionId)) { + conversationStarts.set(sessionId, { initiated: 0, received: 0 }) + } + const convStats = conversationStarts.get(sessionId)! + const lastMsg = lastMessageTime.get(sessionId) + if (!lastMsg || (createTime - lastMsg.time) > CONVERSATION_GAP) { + if (isSent) convStats.initiated++ + else convStats.received++ + } else if (lastMsg.isSent !== isSent) { + if (isSent && !lastMsg.isSent) { + const responseTime = createTime - lastMsg.time + if (responseTime > 0 && responseTime < 86400) { + if (!responseTimeStats.has(sessionId)) responseTimeStats.set(sessionId, []) + responseTimeStats.get(sessionId)!.push(responseTime) + } + } + } + lastMessageTime.set(sessionId, { time: createTime, isSent }) + + // 常用语 + if ((localType === 1 || localType === 244813135921) && isSent) { + const content = this.decodeMessageContent(row.message_content, row.compress_content) + const text = String(content).trim() + if (text.length >= 2 && text.length <= 20 && + !text.includes('http') && !text.includes('<') && + !text.startsWith('[') && !text.startsWith(' maxStreak) { + maxStreak = currentStreak + maxStart = currentStart + maxEnd = dayDate + } + lastDayIndex = dayIndex + } + + if (dt.getHours() >= 0 && dt.getHours() < 6) { + midnightStats.set(sessionId, (midnightStats.get(sessionId) || 0) + 1) + } + + if (peakDayKey) { + const dayKey = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}` + if (dayKey === peakDayKey) { + if (!dailyContactStats.has(dayKey)) dailyContactStats.set(dayKey, new Map()) + const dayContactMap = dailyContactStats.get(dayKey)! + dayContactMap.set(sessionId, (dayContactMap.get(sessionId) || 0) + 1) + } + } + + if (totalMessagesForProgress > 0) { + processedMessages++ + } + } + hasMore = batch.hasMore === true + + const now = Date.now() + if (now - lastProgressAt > 200) { + let progress = 30 + if (totalMessagesForProgress > 0) { + const ratio = Math.min(1, processedMessages / totalMessagesForProgress) + progress = 30 + Math.floor(ratio * 50) + } else { + const ratio = Math.min(1, (i + 1) / sessionIds.length) + progress = 30 + Math.floor(ratio * 50) + } + if (progress > lastProgressSent) { + lastProgressSent = progress + lastProgressAt = now + let label = `${i + 1}/${sessionIds.length}` + if (totalMessagesForProgress > 0) { + const done = Math.min(processedMessages, totalMessagesForProgress) + label = `${done}/${totalMessagesForProgress}` + } + this.reportProgress(`分析聊天记录... (${label})`, progress, onProgress) + } + } + await new Promise(resolve => setImmediate(resolve)) + } + } finally { + await wcdbService.closeMessageCursor(cursor.cursor) + } + + if (maxStreak > longestStreakDays) { + longestStreakDays = maxStreak + longestStreakSessionId = sessionId + longestStreakStart = maxStart + longestStreakEnd = maxEnd + } + } + streakComputedInLoop = true + } + + if (!streakComputedInLoop) { + this.reportProgress('计算连续聊天...', 45, onProgress) + const streakResult = await this.computeLongestStreak(sessionIds, startTime, endTime, onProgress, 45, 75) + if (streakResult.days > longestStreakDays) { + longestStreakDays = streakResult.days + longestStreakSessionId = streakResult.sessionId + longestStreakStart = streakResult.start + longestStreakEnd = streakResult.end + } + } + + this.reportProgress('整理联系人信息...', 85, onProgress) + + const contactIds = Array.from(contactStats.keys()) + const [displayNames, avatarUrls] = await Promise.all([ + wcdbService.getDisplayNames(contactIds), + wcdbService.getAvatarUrls(contactIds) + ]) + + const contactInfoMap = new Map() + for (const sessionId of contactIds) { + contactInfoMap.set(sessionId, { + displayName: displayNames.success && displayNames.map ? (displayNames.map[sessionId] || sessionId) : sessionId, + avatarUrl: avatarUrls.success && avatarUrls.map ? avatarUrls.map[sessionId] : undefined + }) + } + + const selfAvatarResult = await wcdbService.getAvatarUrls([rawWxid, cleanedWxid]) + const selfAvatarUrl = selfAvatarResult.success && selfAvatarResult.map + ? (selfAvatarResult.map[rawWxid] || selfAvatarResult.map[cleanedWxid]) + : undefined + + const coreFriends: TopContact[] = Array.from(contactStats.entries()) + .map(([sessionId, stats]) => { + const info = contactInfoMap.get(sessionId) + return { + username: sessionId, + displayName: info?.displayName || sessionId, + avatarUrl: info?.avatarUrl, + messageCount: stats.sent + stats.received, + sentCount: stats.sent, + receivedCount: stats.received + } + }) + .sort((a, b) => b.messageCount - a.messageCount) + .slice(0, 3) + + const monthlyTopFriends: MonthlyTopFriend[] = [] + for (let month = 1; month <= 12; month++) { + let maxCount = 0 + let topSessionId = '' + for (const [sessionId, monthMap] of monthlyStats.entries()) { + const count = monthMap.get(month) || 0 + if (count > maxCount) { + maxCount = count + topSessionId = sessionId + } + } + const info = contactInfoMap.get(topSessionId) + monthlyTopFriends.push({ + month, + displayName: info?.displayName || (topSessionId ? topSessionId : '暂无'), + avatarUrl: info?.avatarUrl, + messageCount: maxCount + }) + } + + let peakDay: ChatPeakDay | null = null + let maxDayCount = 0 + for (const [day, count] of dailyStats.entries()) { + if (count > maxDayCount) { + maxDayCount = count + const dayContactMap = dailyContactStats.get(day) + let topFriend = '' + let topFriendCount = 0 + if (dayContactMap) { + for (const [sessionId, c] of dayContactMap.entries()) { + if (c > topFriendCount) { + topFriendCount = c + topFriend = contactInfoMap.get(sessionId)?.displayName || sessionId + } + } + } + peakDay = { date: day, messageCount: count, topFriend, topFriendCount } + } + } + + let midnightKing: AnnualReportData['midnightKing'] = null + const totalMidnight = Array.from(midnightStats.values()).reduce((a, b) => a + b, 0) + if (totalMidnight > 0) { + let maxMidnight = 0 + let midnightSessionId = '' + for (const [sessionId, count] of midnightStats.entries()) { + if (count > maxMidnight) { + maxMidnight = count + midnightSessionId = sessionId + } + } + const info = contactInfoMap.get(midnightSessionId) + midnightKing = { + displayName: info?.displayName || midnightSessionId, + count: maxMidnight, + percentage: Math.round((maxMidnight / totalMidnight) * 1000) / 10 + } + } + + let longestStreak: AnnualReportData['longestStreak'] = null + if (longestStreakSessionId && longestStreakDays > 0 && longestStreakStart && longestStreakEnd) { + const info = contactInfoMap.get(longestStreakSessionId) + longestStreak = { + friendName: info?.displayName || longestStreakSessionId, + days: longestStreakDays, + startDate: this.formatDateYmd(longestStreakStart), + endDate: this.formatDateYmd(longestStreakEnd) + } + } + + let mutualFriend: AnnualReportData['mutualFriend'] = null + let bestRatioDiff = Infinity + for (const [sessionId, stats] of contactStats.entries()) { + if (stats.sent >= 50 && stats.received >= 50) { + const ratio = stats.sent / stats.received + const ratioDiff = Math.abs(ratio - 1) + if (ratioDiff < bestRatioDiff) { + bestRatioDiff = ratioDiff + const info = contactInfoMap.get(sessionId) + mutualFriend = { + displayName: info?.displayName || sessionId, + avatarUrl: info?.avatarUrl, + sentCount: stats.sent, + receivedCount: stats.received, + ratio: Math.round(ratio * 100) / 100 + } + } + } + } + + let socialInitiative: AnnualReportData['socialInitiative'] = null + let totalInitiated = 0 + let totalReceived = 0 + for (const stats of conversationStarts.values()) { + totalInitiated += stats.initiated + totalReceived += stats.received + } + const totalConversations = totalInitiated + totalReceived + if (totalConversations > 0) { + socialInitiative = { + initiatedChats: totalInitiated, + receivedChats: totalReceived, + initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10 + } + } + + this.reportProgress('生成报告...', 95, onProgress) + + let responseSpeed: AnnualReportData['responseSpeed'] = null + if (responseStatsFromSql && Object.keys(responseStatsFromSql).length > 0) { + let totalSum = 0 + let totalCount = 0 + let fastestFriendId = '' + let fastestAvgTime = Infinity + for (const [sessionId, stats] of Object.entries(responseStatsFromSql)) { + const count = stats.count || 0 + const avg = stats.avg || 0 + if (count <= 0 || avg <= 0) continue + totalSum += avg * count + totalCount += count + if (avg < fastestAvgTime) { + fastestAvgTime = avg + fastestFriendId = sessionId + } + } + if (totalCount > 0) { + const avgResponseTime = totalSum / totalCount + const fastestInfo = contactInfoMap.get(fastestFriendId) + responseSpeed = { + avgResponseTime: Math.round(avgResponseTime), + fastestFriend: fastestInfo?.displayName || fastestFriendId, + fastestTime: Math.round(fastestAvgTime) + } + } + } else { + const allResponseTimes: number[] = [] + let fastestFriendId = '' + let fastestAvgTime = Infinity + for (const [sessionId, times] of responseTimeStats.entries()) { + if (times.length >= 10) { + allResponseTimes.push(...times) + const avgTime = times.reduce((a, b) => a + b, 0) / times.length + if (avgTime < fastestAvgTime) { + fastestAvgTime = avgTime + fastestFriendId = sessionId + } + } + } + if (allResponseTimes.length > 0) { + const avgResponseTime = allResponseTimes.reduce((a, b) => a + b, 0) / allResponseTimes.length + const fastestInfo = contactInfoMap.get(fastestFriendId) + responseSpeed = { + avgResponseTime: Math.round(avgResponseTime), + fastestFriend: fastestInfo?.displayName || fastestFriendId, + fastestTime: Math.round(fastestAvgTime) + } + } + } + + const topPhrases = topPhrasesFromSql && topPhrasesFromSql.length > 0 + ? topPhrasesFromSql + : Array.from(phraseCount.entries()) + .filter(([_, count]) => count >= 2) + .sort((a, b) => b[1] - a[1]) + .slice(0, 32) + .map(([phrase, count]) => ({ phrase, count })) + + const reportData: AnnualReportData = { + year, + totalMessages, + totalFriends: contactStats.size, + coreFriends, + monthlyTopFriends, + peakDay, + longestStreak, + activityHeatmap: { data: heatmapData }, + midnightKing, + selfAvatarUrl, + mutualFriend, + socialInitiative, + responseSpeed, + topPhrases + } + + return { success: true, data: reportData } + } catch (e) { + return { success: false, error: String(e) } + } + } +} + +export const annualReportService = new AnnualReportService() diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts new file mode 100644 index 0000000..d260891 --- /dev/null +++ b/electron/services/chatService.ts @@ -0,0 +1,2333 @@ +import { join, dirname, basename, extname } from 'path' +import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync } from 'fs' +import * as path from 'path' +import * as fs from 'fs' +import * as https from 'https' +import * as http from 'http' +import * as fzstd from 'fzstd' +import * as crypto from 'crypto' +import Database from 'better-sqlite3' +import { execFile } from 'child_process' +import { promisify } from 'util' +import { app } from 'electron' + +const execFileAsync = promisify(execFile) +import { ConfigService } from './config' +import { wcdbService } from './wcdbService' + +type HardlinkState = { + db: Database.Database + imageTable?: string + dirTable?: string +} + +export interface ChatSession { + username: string + type: number + unreadCount: number + summary: string + sortTimestamp: number // 用于排序 + lastTimestamp: number // 用于显示时间 + lastMsgType: number + displayName?: string + avatarUrl?: string +} + +export interface Message { + localId: number + serverId: number + localType: number + createTime: number + sortSeq: number + isSend: number | null + senderUsername: string | null + parsedContent: string + rawContent: string + // 表情包相关 + emojiCdnUrl?: string + emojiMd5?: string + emojiLocalPath?: string // 本地缓存 castle 路径 + // 引用消息相关 + quotedContent?: string + quotedSender?: string + // 图片/视频相关 + imageMd5?: string + imageDatName?: string + aesKey?: string + encrypVer?: number + cdnThumbUrl?: string +} + +export interface Contact { + username: string + alias: string + remark: string + nickName: string +} + +// 表情包缓存 +const emojiCache: Map = new Map() +const emojiDownloading: Map> = new Map() + +class ChatService { + private configService: ConfigService + private connected = false + private messageCursors: Map = new Map() + private readonly messageBatchDefault = 50 + private avatarCache: Map = new Map() + private readonly avatarCacheTtlMs = 10 * 60 * 1000 + private readonly defaultV1AesKey = 'cfcd208495d565ef' + private hardlinkCache = new Map() + + constructor() { + this.configService = new ConfigService() + } + + /** + * 清理账号目录名 + */ + private cleanAccountDirName(dirName: string): string { + const trimmed = dirName.trim() + if (!trimmed) return trimmed + + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + if (match) return match[1] + return trimmed + } + + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + if (suffixMatch) return suffixMatch[1] + + return trimmed + } + + /** + * 连接数据库 + */ + async connect(): Promise<{ success: boolean; error?: string }> { + try { + if (this.connected && wcdbService.isReady()) { + return { success: true } + } + const wxid = this.configService.get('myWxid') + const dbPath = this.configService.get('dbPath') + const decryptKey = this.configService.get('decryptKey') + if (!wxid) { + return { success: false, error: '请先在设置页面配置微信ID' } + } + if (!dbPath) { + return { success: false, error: '请先在设置页面配置数据库路径' } + } + if (!decryptKey) { + return { success: false, error: '请先在设置页面配置解密密钥' } + } + + const cleanedWxid = this.cleanAccountDirName(wxid) + const openOk = await wcdbService.open(dbPath, decryptKey, cleanedWxid) + if (!openOk) { + return { success: false, error: 'WCDB 打开失败,请检查路径和密钥' } + } + + this.connected = true + return { success: true } + } catch (e) { + console.error('ChatService: 连接数据库失败:', e) + return { success: false, error: String(e) } + } + } + + private async ensureConnected(): Promise<{ success: boolean; error?: string }> { + if (this.connected && wcdbService.isReady()) { + return { success: true } + } + const result = await this.connect() + if (!result.success) { + this.connected = false + return { success: false, error: result.error } + } + return { success: true } + } + + /** + * 关闭数据库连接 + */ + close(): void { + try { + for (const state of this.messageCursors.values()) { + wcdbService.closeMessageCursor(state.cursor) + } + this.messageCursors.clear() + wcdbService.close() + } catch (e) { + console.error('ChatService: 关闭数据库失败:', e) + } + this.connected = false + } + + /** + * 获取会话列表 + */ + async getSessions(): Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error } + } + + const result = await wcdbService.getSessions() + if (!result.success || !result.sessions) { + return { success: false, error: result.error || '获取会话失败' } + } + const rows = result.sessions as Record[] + if (rows.length > 0 && (rows[0]._error || rows[0]._info)) { + const info = rows[0] + const detail = info._error || info._info + const tableInfo = info.table ? ` table=${info.table}` : '' + const tables = info.tables ? ` tables=${info.tables}` : '' + const columns = info.columns ? ` columns=${info.columns}` : '' + return { success: false, error: `会话表异常: ${detail}${tableInfo}${tables}${columns}` } + } + + // 转换为 ChatSession + const sessions: ChatSession[] = [] + for (const row of rows) { + const username = + row.username || + row.user_name || + row.userName || + row.usrName || + row.UsrName || + row.talker || + row.talker_id || + row.talkerId || + '' + + if (!this.shouldKeepSession(username)) continue + + const sortTs = parseInt( + row.sort_timestamp || + row.sortTimestamp || + row.sort_time || + row.sortTime || + '0', + 10 + ) + const lastTs = parseInt( + row.last_timestamp || + row.lastTimestamp || + row.last_msg_time || + row.lastMsgTime || + String(sortTs), + 10 + ) + + const summary = this.cleanString(row.summary || row.digest || row.last_msg || row.lastMsg || '') + const lastMsgType = parseInt(row.last_msg_type || row.lastMsgType || '0', 10) + + sessions.push({ + username, + type: parseInt(row.type || '0', 10), + unreadCount: parseInt(row.unread_count || row.unreadCount || row.unreadcount || '0', 10), + summary: summary || this.getMessageTypeLabel(lastMsgType), + sortTimestamp: sortTs, + lastTimestamp: lastTs, + lastMsgType, + displayName: username + }) + } + + // 获取联系人信息 + await this.enrichSessionsWithContacts(sessions) + + return { success: true, sessions } + } catch (e) { + console.error('ChatService: 获取会话列表失败:', e) + return { success: false, error: String(e) } + } + } + + /** + * 补充联系人信息 + */ + private async enrichSessionsWithContacts(sessions: ChatSession[]): Promise { + if (sessions.length === 0) return + try { + const now = Date.now() + const missing: string[] = [] + + for (const session of sessions) { + const cached = this.avatarCache.get(session.username) + if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) { + if (cached.displayName) session.displayName = cached.displayName + if (cached.avatarUrl) { + session.avatarUrl = cached.avatarUrl + continue + } + } + missing.push(session.username) + } + + if (missing.length === 0) return + const missingSet = new Set(missing) + + const [displayNames, avatarUrls] = await Promise.all([ + wcdbService.getDisplayNames(missing), + wcdbService.getAvatarUrls(missing) + ]) + + for (const session of sessions) { + if (!missingSet.has(session.username)) continue + const displayName = displayNames.success && displayNames.map ? displayNames.map[session.username] : undefined + const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[session.username] : undefined + if (displayName) session.displayName = displayName + if (avatarUrl) session.avatarUrl = avatarUrl + this.avatarCache.set(session.username, { + displayName: session.displayName, + avatarUrl: session.avatarUrl, + updatedAt: now + }) + } + } catch (e) { + console.error('ChatService: 获取联系人信息失败:', e) + } + } + + /** + * 获取消息列表(支持跨多个数据库合并,已优化) + */ + async getMessages( + sessionId: string, + offset: number = 0, + limit: number = 50 + ): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + const batchSize = Math.max(1, limit || this.messageBatchDefault) + let state = this.messageCursors.get(sessionId) + + // 只在以下情况重新创建游标: + // 1. 没有游标状态 + // 2. offset 为 0 (重新加载会话) + // 3. batchSize 改变 + const needNewCursor = !state || offset === 0 || state.batchSize !== batchSize + + if (needNewCursor) { + console.log(`[ChatService] 创建新游标: sessionId=${sessionId}, offset=${offset}, batchSize=${batchSize}`) + + // 关闭旧游标 + if (state) { + try { + await wcdbService.closeMessageCursor(state.cursor) + } catch (e) { + console.warn('[ChatService] 关闭旧游标失败:', e) + } + } + + // 创建新游标 + const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, false, 0, 0) + if (!cursorResult.success || !cursorResult.cursor) { + console.error('[ChatService] 打开消息游标失败:', cursorResult.error) + return { success: false, error: cursorResult.error || '打开消息游标失败' } + } + + state = { cursor: cursorResult.cursor, fetched: 0, batchSize } + this.messageCursors.set(sessionId, state) + + // 如果需要跳过消息(offset > 0),逐批获取但不返回 + if (offset > 0) { + console.log(`[ChatService] 跳过消息: offset=${offset}`) + let skipped = 0 + while (skipped < offset) { + const skipBatch = await wcdbService.fetchMessageBatch(state.cursor) + if (!skipBatch.success) { + console.error('[ChatService] 跳过消息批次失败:', skipBatch.error) + return { success: false, error: skipBatch.error || '跳过消息失败' } + } + if (!skipBatch.rows || skipBatch.rows.length === 0) { + console.log('[ChatService] 跳过时没有更多消息') + return { success: true, messages: [], hasMore: false } + } + skipped += skipBatch.rows.length + state.fetched += skipBatch.rows.length + if (!skipBatch.hasMore) { + console.log('[ChatService] 跳过时已到达末尾') + return { success: true, messages: [], hasMore: false } + } + } + console.log(`[ChatService] 跳过完成: skipped=${skipped}, fetched=${state.fetched}`) + } + } else if (state && offset !== state.fetched) { + // offset 与 fetched 不匹配,说明状态不一致 + console.warn(`[ChatService] 游标状态不一致: offset=${offset}, fetched=${state.fetched}, 继续使用现有游标`) + // 不重新创建游标,而是继续使用现有游标 + // 这样可以避免频繁重建导致的问题 + } + + // 确保 state 已初始化 + if (!state) { + console.error('[ChatService] 游标状态未初始化') + return { success: false, error: '游标状态未初始化' } + } + + // 获取当前批次的消息 + console.log(`[ChatService] 获取消息批次: cursor=${state.cursor}, fetched=${state.fetched}`) + const batch = await wcdbService.fetchMessageBatch(state.cursor) + if (!batch.success) { + console.error('[ChatService] 获取消息批次失败:', batch.error) + return { success: false, error: batch.error || '获取消息失败' } + } + + if (!batch.rows) { + console.error('[ChatService] 获取消息失败: 返回数据为空') + return { success: false, error: '获取消息失败: 返回数据为空' } + } + + const rows = batch.rows as Record[] + const hasMore = batch.hasMore === true + + const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(rows)) + + // 并发检查并修复缺失 CDN URL 的表情包 + const fixPromises: Promise[] = [] + for (const msg of normalized) { + if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) { + fixPromises.push(this.fallbackEmoticon(msg)) + } + } + + if (fixPromises.length > 0) { + await Promise.allSettled(fixPromises) + } + + state.fetched += rows.length + return { success: true, messages: normalized, hasMore } + } catch (e) { + console.error('ChatService: 获取消息失败:', e) + return { success: false, error: String(e) } + } + } + + /** + * 尝试从 emoticon.db / emotion.db 恢复表情包 CDN URL + */ + private async fallbackEmoticon(msg: Message): Promise { + if (!msg.emojiMd5) return + + try { + const dbPath = await this.findInternalEmoticonDb() + if (!dbPath) { + console.warn(`[ChatService] 表情包数据库未找到,无法恢复: md5=${msg.emojiMd5}`) + return + } + + const urlResult = await wcdbService.getEmoticonCdnUrl(dbPath, msg.emojiMd5) + if (!urlResult.success) { + console.warn(`[ChatService] 表情包数据库查询失败: md5=${msg.emojiMd5}, db=${dbPath}`, urlResult.error) + return + } + if (urlResult.url) { + msg.emojiCdnUrl = urlResult.url + return + } + + console.warn(`[ChatService] 表情包数据库未命中: md5=${msg.emojiMd5}, db=${dbPath}`) + + } catch (e) { + console.error(`[ChatService] 恢复表情包失败: md5=${msg.emojiMd5}`, e) + } + } + + /** + * 查找 emoticon.db 路径 + */ + private async findInternalEmoticonDb(): Promise { + const myWxid = this.configService.get('myWxid') + const rootDbPath = this.configService.get('dbPath') + if (!myWxid || !rootDbPath) return null + + const accountDir = this.resolveAccountDir(rootDbPath, myWxid) + if (!accountDir) return null + + const candidates = [ + // 1. 标准结构: root/wxid/db_storage/emoticon + join(rootDbPath, myWxid, 'db_storage', 'emoticon', 'emoticon.db'), + join(rootDbPath, myWxid, 'db_storage', 'emotion', 'emoticon.db'), + ] + + for (const p of candidates) { + if (existsSync(p)) return p + } + + return null + } + + + async getLatestMessages(sessionId: string, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + const batchSize = Math.max(1, limit) + const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, false, 0, 0) + if (!cursorResult.success || !cursorResult.cursor) { + return { success: false, error: cursorResult.error || '打开消息游标失败' } + } + + try { + const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor) + if (!batch.success || !batch.rows) { + return { success: false, error: batch.error || '获取消息失败' } + } + const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(batch.rows as Record[])) + + // 并发检查并修复缺失 CDN URL 的表情包 + const fixPromises: Promise[] = [] + for (const msg of normalized) { + if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) { + fixPromises.push(this.fallbackEmoticon(msg)) + } + } + if (fixPromises.length > 0) { + await Promise.allSettled(fixPromises) + } + + return { success: true, messages: normalized } + } finally { + await wcdbService.closeMessageCursor(cursorResult.cursor) + } + } catch (e) { + console.error('ChatService: 获取最新消息失败:', e) + return { success: false, error: String(e) } + } + } + + private normalizeMessageOrder(messages: Message[]): Message[] { + if (messages.length < 2) return messages + const first = messages[0] + const last = messages[messages.length - 1] + const firstKey = first.sortSeq || first.createTime || first.localId || 0 + const lastKey = last.sortSeq || last.createTime || last.localId || 0 + if (firstKey > lastKey) { + return [...messages].reverse() + } + return messages + } + + private getRowField(row: Record, keys: string[]): any { + for (const key of keys) { + if (row[key] !== undefined && row[key] !== null) return row[key] + } + const lowerMap = new Map() + for (const actual of Object.keys(row)) { + lowerMap.set(actual.toLowerCase(), actual) + } + for (const key of keys) { + const actual = lowerMap.get(key.toLowerCase()) + if (actual && row[actual] !== undefined && row[actual] !== null) { + return row[actual] + } + } + return undefined + } + + private getRowInt(row: Record, keys: string[], fallback = 0): number { + const raw = this.getRowField(row, keys) + if (raw === undefined || raw === null || raw === '') return fallback + const parsed = this.coerceRowNumber(raw) + return Number.isFinite(parsed) ? parsed : fallback + } + + private coerceRowNumber(raw: any): number { + if (raw === undefined || raw === null) return NaN + if (typeof raw === 'number') return raw + if (typeof raw === 'bigint') return Number(raw) + if (Buffer.isBuffer(raw)) { + return parseInt(raw.toString('utf-8'), 10) + } + if (raw instanceof Uint8Array) { + return parseInt(Buffer.from(raw).toString('utf-8'), 10) + } + if (Array.isArray(raw)) { + return parseInt(Buffer.from(raw).toString('utf-8'), 10) + } + if (typeof raw === 'object') { + if ('value' in raw) return this.coerceRowNumber(raw.value) + if ('intValue' in raw) return this.coerceRowNumber(raw.intValue) + if ('low' in raw && 'high' in raw) { + try { + const low = BigInt(raw.low >>> 0) + const high = BigInt(raw.high >>> 0) + return Number((high << 32n) + low) + } catch { + return NaN + } + } + const text = raw.toString ? String(raw) : '' + if (text && text !== '[object Object]') { + const parsed = parseInt(text, 10) + return Number.isFinite(parsed) ? parsed : NaN + } + return NaN + } + const parsed = parseInt(String(raw), 10) + return Number.isFinite(parsed) ? parsed : NaN + } + + private mapRowsToMessages(rows: Record[]): Message[] { + const myWxid = this.configService.get('myWxid') + const cleanedWxid = myWxid ? this.cleanAccountDirName(myWxid) : null + const myWxidLower = myWxid ? myWxid.toLowerCase() : null + const cleanedWxidLower = cleanedWxid ? cleanedWxid.toLowerCase() : null + + const messages: Message[] = [] + for (const row of rows) { + const content = this.decodeMessageContent( + this.getRowField(row, [ + 'message_content', + 'messageContent', + 'content', + 'msg_content', + 'msgContent', + 'WCDB_CT_message_content', + 'WCDB_CT_messageContent' + ]), + this.getRowField(row, [ + 'compress_content', + 'compressContent', + 'compressed_content', + 'WCDB_CT_compress_content', + 'WCDB_CT_compressContent' + ]) + ) + const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) + const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']) + let isSend = isSendRaw === null ? null : parseInt(isSendRaw, 10) + const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null + const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0) + + if (senderUsername && (myWxidLower || cleanedWxidLower)) { + const senderLower = String(senderUsername).toLowerCase() + const expectedIsSend = (senderLower === myWxidLower || senderLower === cleanedWxidLower) ? 1 : 0 + if (isSend === null) { + isSend = expectedIsSend + } + } + + let emojiCdnUrl: string | undefined + let emojiMd5: string | undefined + let quotedContent: string | undefined + let quotedSender: string | undefined + let imageMd5: string | undefined + let imageDatName: string | undefined + let aesKey: string | undefined + let encrypVer: number | undefined + let cdnThumbUrl: string | undefined + let voiceDurationSeconds: number | undefined + + if (localType === 47 && content) { + const emojiInfo = this.parseEmojiInfo(content) + emojiCdnUrl = emojiInfo.cdnUrl + emojiMd5 = emojiInfo.md5 + } else if (localType === 3 && content) { + const imageInfo = this.parseImageInfo(content) + imageMd5 = imageInfo.md5 + aesKey = imageInfo.aesKey + encrypVer = imageInfo.encrypVer + cdnThumbUrl = imageInfo.cdnThumbUrl + imageDatName = this.parseImageDatNameFromRow(row) + } else if (localType === 34 && content) { + voiceDurationSeconds = this.parseVoiceDurationSeconds(content) + } else if (localType === 244813135921 || (content && content.includes('57'))) { + const quoteInfo = this.parseQuoteMessage(content) + quotedContent = quoteInfo.content + quotedSender = quoteInfo.sender + } + + messages.push({ + localId: this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0), + serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0), + localType, + createTime, + sortSeq: this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime), + isSend, + senderUsername, + parsedContent: this.parseMessageContent(content, localType), + rawContent: content, + emojiCdnUrl, + emojiMd5, + quotedContent, + quotedSender, + imageMd5, + imageDatName, + voiceDurationSeconds, + aesKey, + encrypVer, + cdnThumbUrl + }) + const last = messages[messages.length - 1] + if ((last.localType === 3 || last.localType === 34) && (last.localId === 0 || last.createTime === 0)) { + console.warn('[ChatService] message key missing', { + localType: last.localType, + localId: last.localId, + createTime: last.createTime, + rowKeys: Object.keys(row) + }) + } + } + return messages + } + + /** + * 解析消息内容 + */ + private parseMessageContent(content: string, localType: number): string { + if (!content) { + return this.getMessageTypeLabel(localType) + } + + // 尝试解码 Buffer + if (Buffer.isBuffer(content)) { + content = content.toString('utf-8') + } + + content = this.decodeHtmlEntities(content) + content = this.cleanUtf16(content) + + // 检查 XML type,用于识别引用消息等 + const xmlType = this.extractXmlValue(content, 'type') + + switch (localType) { + case 1: + return this.stripSenderPrefix(content) + case 3: + return '[图片]' + case 34: + return '[语音消息]' + case 42: + return '[名片]' + case 43: + return '[视频]' + case 47: + return '[动画表情]' + case 48: + return '[位置]' + case 49: + return this.parseType49(content) + case 50: + return '[通话]' + case 10000: + return this.cleanSystemMessage(content) + case 244813135921: + // 引用消息,提取 title + const title = this.extractXmlValue(content, 'title') + return title || '[引用消息]' + case 266287972401: + return '[拍一拍]' + case 81604378673: + return '[聊天记录]' + case 8594229559345: + return '[红包]' + case 8589934592049: + return '[转账]' + default: + // 检查是否是 type=57 的引用消息 + if (xmlType === '57') { + const title = this.extractXmlValue(content, 'title') + return title || '[引用消息]' + } + + // 尝试从 XML 提取通用 title + const genericTitle = this.extractXmlValue(content, 'title') + if (genericTitle && genericTitle.length > 0 && genericTitle.length < 100) { + return genericTitle + } + + if (content.length > 200) { + return this.getMessageTypeLabel(localType) + } + return this.stripSenderPrefix(content) || this.getMessageTypeLabel(localType) + } + } + + private parseType49(content: string): string { + const title = this.extractXmlValue(content, 'title') + const type = this.extractXmlValue(content, 'type') + + if (title) { + switch (type) { + case '5': + case '49': + return `[链接] ${title}` + case '6': + return `[文件] ${title}` + case '33': + case '36': + return `[小程序] ${title}` + case '57': + // 引用消息,title 就是回复的内容 + return title + default: + return title + } + } + return '[消息]' + } + + /** + * 解析表情包信息 + */ + private parseEmojiInfo(content: string): { cdnUrl?: string; md5?: string } { + try { + // 提取 cdnurl + let cdnUrl: string | undefined + const cdnUrlMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content) || /cdnurl\s*=\s*([^'"]+?)(?=\s|\/|>)/i.exec(content) + if (cdnUrlMatch) { + cdnUrl = cdnUrlMatch[1].replace(/&/g, '&') + if (cdnUrl.includes('%')) { + try { + cdnUrl = decodeURIComponent(cdnUrl) + } catch { } + } + } + + // 如果没有 cdnurl,尝试 thumburl + if (!cdnUrl) { + const thumbUrlMatch = /thumburl\s*=\s*['"]([^'"]+)['"]/i.exec(content) || /thumburl\s*=\s*([^'"]+?)(?=\s|\/|>)/i.exec(content) + if (thumbUrlMatch) { + cdnUrl = thumbUrlMatch[1].replace(/&/g, '&') + if (cdnUrl.includes('%')) { + try { + cdnUrl = decodeURIComponent(cdnUrl) + } catch { } + } + } + } + + // 提取 md5 + const md5Match = /md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) || /md5\s*=\s*([a-fA-F0-9]+)/i.exec(content) + const md5 = md5Match ? md5Match[1] : undefined + + // 不构造假 URL,只返回真正的 cdnurl + // 没有 cdnUrl 时保持静默,交由后续回退逻辑处理 + return { cdnUrl, md5 } + } catch (e) { + console.error('[ChatService] 表情包解析失败:', e, { xml: content }) + return {} + } + } + + /** + * 解析图片信息 + */ + private parseImageInfo(content: string): { md5?: string; aesKey?: string; encrypVer?: number; cdnThumbUrl?: string } { + try { + const md5 = + this.extractXmlValue(content, 'md5') || + this.extractXmlAttribute(content, 'img', 'md5') || + undefined + const aesKey = this.extractXmlAttribute(content, 'img', 'aeskey') || undefined + const encrypVerStr = this.extractXmlAttribute(content, 'img', 'encrypver') || undefined + const cdnThumbUrl = this.extractXmlAttribute(content, 'img', 'cdnthumburl') || undefined + + return { + md5, + aesKey, + encrypVer: encrypVerStr ? parseInt(encrypVerStr, 10) : undefined, + cdnThumbUrl + } + } catch { + return {} + } + } + + private parseImageDatNameFromRow(row: Record): string | undefined { + const packed = this.getRowField(row, [ + 'packed_info_data', + 'packed_info', + 'packedInfoData', + 'packedInfo', + 'PackedInfoData', + 'PackedInfo', + 'WCDB_CT_packed_info_data', + 'WCDB_CT_packed_info', + 'WCDB_CT_PackedInfoData', + 'WCDB_CT_PackedInfo' + ]) + const buffer = this.decodePackedInfo(packed) + if (!buffer || buffer.length === 0) return undefined + const printable: number[] = [] + for (const byte of buffer) { + if (byte >= 0x20 && byte <= 0x7e) { + printable.push(byte) + } else { + printable.push(0x20) + } + } + const text = Buffer.from(printable).toString('utf-8') + const match = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/.exec(text) + if (match?.[1]) return match[1].toLowerCase() + const hexMatch = /([0-9a-fA-F]{16,})/.exec(text) + return hexMatch?.[1]?.toLowerCase() + } + + private decodePackedInfo(raw: any): Buffer | null { + if (!raw) return null + if (Buffer.isBuffer(raw)) return raw + if (raw instanceof Uint8Array) return Buffer.from(raw) + if (Array.isArray(raw)) return Buffer.from(raw) + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (/^[a-fA-F0-9]+$/.test(trimmed) && trimmed.length % 2 === 0) { + try { + return Buffer.from(trimmed, 'hex') + } catch { } + } + try { + return Buffer.from(trimmed, 'base64') + } catch { } + } + if (typeof raw === 'object' && Array.isArray(raw.data)) { + return Buffer.from(raw.data) + } + return null + } + + private parseVoiceDurationSeconds(content: string): number | undefined { + if (!content) return undefined + const match = /(voicelength|length|time|playlength)\s*=\s*['"]?([0-9]+(?:\.[0-9]+)?)['"]?/i.exec(content) + if (!match) return undefined + const raw = parseFloat(match[2]) + if (!Number.isFinite(raw) || raw <= 0) return undefined + if (raw > 1000) return Math.round(raw / 1000) + return Math.round(raw) + } + + /** + * 解析引用消息 + */ + private parseQuoteMessage(content: string): { content?: string; sender?: string } { + try { + // 提取 refermsg 部分 + const referMsgStart = content.indexOf('') + const referMsgEnd = content.indexOf('') + + if (referMsgStart === -1 || referMsgEnd === -1) { + return {} + } + + const referMsgXml = content.substring(referMsgStart, referMsgEnd + 11) + + // 提取发送者名称 + let displayName = this.extractXmlValue(referMsgXml, 'displayname') + // 过滤掉 wxid + if (displayName && this.looksLikeWxid(displayName)) { + displayName = '' + } + + // 提取引用内容 + const referContent = this.extractXmlValue(referMsgXml, 'content') + const referType = this.extractXmlValue(referMsgXml, 'type') + + // 根据类型渲染引用内容 + let displayContent = referContent + switch (referType) { + case '1': + // 文本消息,清理可能的 wxid + displayContent = this.sanitizeQuotedContent(referContent) + break + case '3': + displayContent = '[图片]' + break + case '34': + displayContent = '[语音]' + break + case '43': + displayContent = '[视频]' + break + case '47': + displayContent = '[动画表情]' + break + case '49': + displayContent = '[链接]' + break + case '42': + displayContent = '[名片]' + break + case '48': + displayContent = '[位置]' + break + default: + if (!referContent || referContent.includes('wxid_')) { + displayContent = '[消息]' + } else { + displayContent = this.sanitizeQuotedContent(referContent) + } + } + + return { + content: displayContent, + sender: displayName || undefined + } + } catch { + return {} + } + } + + private getVoiceLookupCandidates(sessionId: string, msg: Message): string[] { + const candidates: string[] = [] + const add = (value?: string | null) => { + const trimmed = value?.trim() + if (!trimmed) return + if (!candidates.includes(trimmed)) candidates.push(trimmed) + } + add(sessionId) + add(msg.senderUsername) + add(this.configService.get('myWxid')) + return candidates + } + + private async resolveChatNameId(dbPath: string, senderWxid: string): Promise { + const escaped = this.escapeSqlString(senderWxid) + const name2IdTable = await this.resolveName2IdTableName(dbPath) + if (!name2IdTable) return null + const info = await wcdbService.execQuery('media', dbPath, `PRAGMA table_info('${name2IdTable}')`) + if (!info.success || !info.rows) return null + const columns = info.rows.map((row) => String(row.name || row.Name || row.column || '')).filter(Boolean) + const lower = new Map(columns.map((col) => [col.toLowerCase(), col])) + const column = lower.get('name_id') || lower.get('id') || 'rowid' + const sql = `SELECT ${column} AS id FROM ${name2IdTable} WHERE user_name = '${escaped}' LIMIT 1` + const result = await wcdbService.execQuery('media', dbPath, sql) + if (!result.success || !result.rows || result.rows.length === 0) return null + const value = result.rows[0]?.id + if (value === null || value === undefined) return null + const parsed = typeof value === 'number' ? value : parseInt(String(value), 10) + return Number.isFinite(parsed) ? parsed : null + } + + private decodeVoiceBlob(raw: any): Buffer | null { + if (!raw) return null + if (Buffer.isBuffer(raw)) return raw + if (raw instanceof Uint8Array) return Buffer.from(raw) + if (Array.isArray(raw)) return Buffer.from(raw) + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (/^[a-fA-F0-9]+$/.test(trimmed) && trimmed.length % 2 === 0) { + try { + return Buffer.from(trimmed, 'hex') + } catch { } + } + try { + return Buffer.from(trimmed, 'base64') + } catch { } + } + if (typeof raw === 'object' && Array.isArray(raw.data)) { + return Buffer.from(raw.data) + } + return null + } + + private async resolveVoiceInfoColumns(dbPath: string, tableName: string): Promise<{ + dataColumn: string; + chatNameIdColumn?: string; + createTimeColumn?: string; + msgLocalIdColumn?: string; + } | null> { + const info = await wcdbService.execQuery('media', dbPath, `PRAGMA table_info('${tableName}')`) + if (!info.success || !info.rows) return null + const columns = info.rows.map((row) => String(row.name || row.Name || row.column || '')).filter(Boolean) + if (columns.length === 0) return null + const lower = new Map(columns.map((col) => [col.toLowerCase(), col])) + const dataColumn = + lower.get('voice_data') || + lower.get('buf') || + lower.get('voicebuf') || + lower.get('data') + if (!dataColumn) return null + return { + dataColumn, + chatNameIdColumn: lower.get('chat_name_id') || lower.get('chatnameid') || lower.get('chat_nameid'), + createTimeColumn: lower.get('create_time') || lower.get('createtime') || lower.get('time'), + msgLocalIdColumn: lower.get('msg_local_id') || lower.get('msglocalid') || lower.get('localid') + } + } + + private escapeSqlString(value: string): string { + return value.replace(/'/g, "''") + } + + private async resolveVoiceInfoTableName(dbPath: string): Promise { + // 1. 优先尝试标准表名 'VoiceInfo' + const checkStandard = await wcdbService.execQuery( + 'media', + dbPath, + "SELECT name FROM sqlite_master WHERE type='table' AND name='VoiceInfo'" + ) + if (checkStandard.success && checkStandard.rows && checkStandard.rows.length > 0) { + return 'VoiceInfo' + } + + // 2. 只有在找不到标准表时,才尝试模糊匹配 (兼容性) + const result = await wcdbService.execQuery( + 'media', + dbPath, + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%' ORDER BY name DESC LIMIT 1" + ) + if (!result.success || !result.rows || result.rows.length === 0) return null + return result.rows[0]?.name || null + } + + private async resolveName2IdTableName(dbPath: string): Promise { + const result = await wcdbService.execQuery( + 'media', + dbPath, + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%' ORDER BY name DESC LIMIT 1" + ) + if (!result.success || !result.rows || result.rows.length === 0) return null + return result.rows[0]?.name || null + } + + /** + * 判断是否像 wxid + */ + private looksLikeWxid(text: string): boolean { + if (!text) return false + const trimmed = text.trim().toLowerCase() + if (trimmed.startsWith('wxid_')) return true + return /^wx[a-z0-9_-]{4,}$/.test(trimmed) + } + + /** + * 清理引用内容中的 wxid + */ + private sanitizeQuotedContent(content: string): string { + if (!content) return '' + let result = content + // 去掉 wxid_xxx + result = result.replace(/wxid_[A-Za-z0-9_-]{3,}/g, '') + // 去掉开头的分隔符 + result = result.replace(/^[\s::\-]+/, '') + // 折叠重复分隔符 + result = result.replace(/[::]{2,}/g, ':') + result = result.replace(/^[\s::\-]+/, '') + // 标准化空白 + result = result.replace(/\s+/g, ' ').trim() + return result + } + + private getMessageTypeLabel(localType: number): string { + const labels: Record = { + 1: '[文本]', + 3: '[图片]', + 34: '[语音]', + 42: '[名片]', + 43: '[视频]', + 47: '[动画表情]', + 48: '[位置]', + 49: '[链接]', + 50: '[通话]', + 10000: '[系统消息]', + 244813135921: '[引用消息]', + 266287972401: '[拍一拍]', + 81604378673: '[聊天记录]', + 154618822705: '[小程序]', + 8594229559345: '[红包]', + 8589934592049: '[转账]', + 34359738417: '[文件]', + 103079215153: '[文件]', + 25769803825: '[文件]' + } + return labels[localType] || '[消息]' + } + + private extractXmlValue(xml: string, tagName: string): string { + const regex = new RegExp(`<${tagName}>([\\s\\S]*?)`, 'i') + const match = regex.exec(xml) + if (match) { + return match[1].replace(//g, '').trim() + } + return '' + } + + private extractXmlAttribute(xml: string, tagName: string, attrName: string): string { + const tagRegex = new RegExp(`<${tagName}[^>]*>`, 'i') + const tagMatch = tagRegex.exec(xml) + if (!tagMatch) return '' + + const attrRegex = new RegExp(`${attrName}\\s*=\\s*['"]([^'"]*)['"]`, 'i') + const attrMatch = attrRegex.exec(tagMatch[0]) + return attrMatch ? attrMatch[1] : '' + } + + private cleanSystemMessage(content: string): string { + return content + .replace(/]*>/gi, '') + .replace(/<\/?[a-zA-Z0-9_]+[^>]*>/g, '') + .replace(/\s+/g, ' ') + .trim() || '[系统消息]' + } + + private stripSenderPrefix(content: string): string { + return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)\s*/, '') + } + + private decodeHtmlEntities(content: string): string { + return content + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + } + + private cleanString(str: string): string { + if (!str) return '' + if (Buffer.isBuffer(str)) { + str = str.toString('utf-8') + } + return this.cleanUtf16(String(str)) + } + + private cleanUtf16(input: string): string { + if (!input) return input + try { + const cleaned = input.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/g, '') + const codeUnits = cleaned.split('').map((c) => c.charCodeAt(0)) + const validUnits: number[] = [] + for (let i = 0; i < codeUnits.length; i += 1) { + const unit = codeUnits[i] + if (unit >= 0xd800 && unit <= 0xdbff) { + if (i + 1 < codeUnits.length) { + const nextUnit = codeUnits[i + 1] + if (nextUnit >= 0xdc00 && nextUnit <= 0xdfff) { + validUnits.push(unit, nextUnit) + i += 1 + continue + } + } + continue + } + if (unit >= 0xdc00 && unit <= 0xdfff) { + continue + } + validUnits.push(unit) + } + return String.fromCharCode(...validUnits) + } catch { + return input.replace(/[^\u0020-\u007E\u4E00-\u9FFF\u3000-\u303F]/g, '') + } + } + + /** + * 解码消息内容(处理 BLOB 和压缩数据) + */ + private decodeMessageContent(messageContent: any, compressContent: any): string { + // 优先使用 compress_content + let content = this.decodeMaybeCompressed(compressContent) + if (!content || content.length === 0) { + content = this.decodeMaybeCompressed(messageContent) + } + return content + } + + /** + * 尝试解码可能压缩的内容 + */ + private decodeMaybeCompressed(raw: any): string { + if (!raw) return '' + + // 如果是 Buffer/Uint8Array + if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) { + return this.decodeBinaryContent(Buffer.from(raw)) + } + + // 如果是字符串 + if (typeof raw === 'string') { + if (raw.length === 0) return '' + + // 检查是否是 hex 编码 + if (this.looksLikeHex(raw)) { + const bytes = Buffer.from(raw, 'hex') + if (bytes.length > 0) { + return this.decodeBinaryContent(bytes) + } + } + + // 检查是否是 base64 编码 + if (this.looksLikeBase64(raw)) { + try { + const bytes = Buffer.from(raw, 'base64') + return this.decodeBinaryContent(bytes) + } catch { } + } + + // 普通字符串 + return raw + } + + return '' + } + + /** + * 解码二进制内容(处理 zstd 压缩) + */ + private decodeBinaryContent(data: Buffer): string { + if (data.length === 0) return '' + + try { + // 检查是否是 zstd 压缩数据 (magic number: 0xFD2FB528) + if (data.length >= 4) { + const magicLE = data.readUInt32LE(0) + const magicBE = data.readUInt32BE(0) + if (magicLE === 0xFD2FB528 || magicBE === 0xFD2FB528) { + // zstd 压缩,需要解压 + try { + const decompressed = fzstd.decompress(data) + return Buffer.from(decompressed).toString('utf-8') + } catch (e) { + console.error('zstd 解压失败:', e) + } + } + } + + // 尝试直接 UTF-8 解码 + const decoded = data.toString('utf-8') + // 检查是否有太多替换字符 + const replacementCount = (decoded.match(/\uFFFD/g) || []).length + if (replacementCount < decoded.length * 0.2) { + return decoded.replace(/\uFFFD/g, '') + } + + // 尝试 latin1 解码 + return data.toString('latin1') + } catch { + return '' + } + } + + /** + * 检查是否像 hex 编码 + */ + private looksLikeHex(s: string): boolean { + if (s.length % 2 !== 0) return false + return /^[0-9a-fA-F]+$/.test(s) + } + + /** + * 检查是否像 base64 编码 + */ + private looksLikeBase64(s: string): boolean { + if (s.length % 4 !== 0) return false + return /^[A-Za-z0-9+/=]+$/.test(s) + } + + private shouldKeepSession(username: string): boolean { + if (!username) return false + const lowered = username.toLowerCase() + if (lowered.includes('@placeholder') || lowered.includes('foldgroup')) return false + if (username.startsWith('gh_')) return false + + const excludeList = [ + 'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle', + 'newsapp', 'brandsessionholder', 'brandservicesessionholder', + 'notifymessage', 'opencustomerservicemsg', 'notification_messages', + 'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup', + '@helper_folders', '@placeholder_foldgroup' + ] + + for (const prefix of excludeList) { + if (username.startsWith(prefix) || username === prefix) return false + } + + if (username.includes('@kefu.openim') || username.includes('@openim')) return false + if (username.includes('service_')) return false + + return true + } + + async getContact(username: string): Promise { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) return null + const result = await wcdbService.getContact(username) + if (!result.success || !result.contact) return null + return { + username: result.contact.username || username, + alias: result.contact.alias || '', + remark: result.contact.remark || '', + nickName: result.contact.nickName || '' + } + } catch { + return null + } + } + + /** + * 获取联系人头像和显示名称(用于群聊消息) + */ + async getContactAvatar(username: string): Promise<{ avatarUrl?: string; displayName?: string } | null> { + if (!username) return null + + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) return null + const cached = this.avatarCache.get(username) + if (cached && cached.avatarUrl && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) { + return { avatarUrl: cached.avatarUrl, displayName: cached.displayName } + } + + const contact = await this.getContact(username) + const avatarResult = await wcdbService.getAvatarUrls([username]) + const avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined + const displayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || username + this.avatarCache.set(username, { avatarUrl, displayName, updatedAt: Date.now() }) + return { avatarUrl, displayName } + } catch { + return null + } + } + + /** + * 获取当前用户的头像 URL + */ + async getMyAvatarUrl(): Promise<{ success: boolean; avatarUrl?: string; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error } + } + + const myWxid = this.configService.get('myWxid') + if (!myWxid) { + return { success: false, error: '未配置微信ID' } + } + + const cleanedWxid = this.cleanAccountDirName(myWxid) + const result = await wcdbService.getAvatarUrls([myWxid, cleanedWxid]) + if (result.success && result.map) { + const avatarUrl = result.map[myWxid] || result.map[cleanedWxid] + return { success: true, avatarUrl } + } + return { success: true, avatarUrl: undefined } + } catch (e) { + console.error('ChatService: 获取当前用户头像失败:', e) + return { success: false, error: String(e) } + } + } + + /** + * 获取表情包缓存目录 + */ + private getEmojiCacheDir(): string { + const cachePath = this.configService.get('cachePath') + if (cachePath) { + return join(cachePath, 'Emojis') + } + // 回退到默认目录 + const documentsPath = app.getPath('documents') + return join(documentsPath, 'WeFlow', 'Emojis') + } + + /** + * 下载并缓存表情包 + */ + async downloadEmoji(cdnUrl: string, md5?: string): Promise<{ success: boolean; localPath?: string; error?: string }> { + if (!cdnUrl) { + return { success: false, error: '无效的 CDN URL' } + } + + // 生成缓存 key + const cacheKey = md5 || this.hashString(cdnUrl) + + // 检查内存缓存 + const cached = emojiCache.get(cacheKey) + if (cached && existsSync(cached)) { + // 读取文件并转为 data URL + const dataUrl = this.fileToDataUrl(cached) + if (dataUrl) { + return { success: true, localPath: dataUrl } + } + } + + // 检查是否正在下载 + const downloading = emojiDownloading.get(cacheKey) + if (downloading) { + const result = await downloading + if (result) { + const dataUrl = this.fileToDataUrl(result) + if (dataUrl) { + return { success: true, localPath: dataUrl } + } + } + return { success: false, error: '下载失败' } + } + + // 确保缓存目录存在 + const cacheDir = this.getEmojiCacheDir() + if (!existsSync(cacheDir)) { + mkdirSync(cacheDir, { recursive: true }) + } + + // 检查本地是否已有缓存文件 + const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg'] + for (const ext of extensions) { + const filePath = join(cacheDir, `${cacheKey}${ext}`) + if (existsSync(filePath)) { + emojiCache.set(cacheKey, filePath) + const dataUrl = this.fileToDataUrl(filePath) + if (dataUrl) { + return { success: true, localPath: dataUrl } + } + } + } + + // 开始下载 + const downloadPromise = this.doDownloadEmoji(cdnUrl, cacheKey, cacheDir) + emojiDownloading.set(cacheKey, downloadPromise) + + try { + const localPath = await downloadPromise + emojiDownloading.delete(cacheKey) + + if (localPath) { + emojiCache.set(cacheKey, localPath) + const dataUrl = this.fileToDataUrl(localPath) + if (dataUrl) { + return { success: true, localPath: dataUrl } + } + } + return { success: false, error: '下载失败' } + } catch (e) { + console.error(`[ChatService] 表情包下载异常: url=${cdnUrl}, md5=${md5}`, e) + emojiDownloading.delete(cacheKey) + return { success: false, error: String(e) } + } + } + + /** + * 将文件转为 data URL + */ + private fileToDataUrl(filePath: string): string | null { + try { + const ext = extname(filePath).toLowerCase() + const mimeTypes: Record = { + '.gif': 'image/gif', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp' + } + const mimeType = mimeTypes[ext] || 'image/gif' + const data = readFileSync(filePath) + return `data:${mimeType};base64,${data.toString('base64')}` + } catch { + return null + } + } + + /** + * 执行表情包下载 + */ + private doDownloadEmoji(url: string, cacheKey: string, cacheDir: string): Promise { + return new Promise((resolve) => { + const protocol = url.startsWith('https') ? https : http + + const request = protocol.get(url, (response) => { + // 处理重定向 + if (response.statusCode === 301 || response.statusCode === 302) { + const redirectUrl = response.headers.location + if (redirectUrl) { + this.doDownloadEmoji(redirectUrl, cacheKey, cacheDir).then(resolve) + return + } + } + + if (response.statusCode !== 200) { + resolve(null) + return + } + + const chunks: Buffer[] = [] + response.on('data', (chunk) => chunks.push(chunk)) + response.on('end', () => { + const buffer = Buffer.concat(chunks) + if (buffer.length === 0) { + resolve(null) + return + } + + // 检测文件类型 + const ext = this.detectImageExtension(buffer) || this.getExtFromUrl(url) || '.gif' + const filePath = join(cacheDir, `${cacheKey}${ext}`) + + try { + writeFileSync(filePath, buffer) + resolve(filePath) + } catch { + resolve(null) + } + }) + response.on('error', () => resolve(null)) + }) + + request.on('error', () => resolve(null)) + request.setTimeout(10000, () => { + request.destroy() + resolve(null) + }) + }) + } + + /** + * 检测图片格式 + */ + private detectImageExtension(buffer: Buffer): string | null { + if (buffer.length < 12) return null + + // GIF + if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) { + return '.gif' + } + // PNG + if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) { + return '.png' + } + // JPEG + if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) { + return '.jpg' + } + // WEBP + if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 && + buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) { + return '.webp' + } + + return null + } + + /** + * 从 URL 获取扩展名 + */ + private getExtFromUrl(url: string): string | null { + try { + const pathname = new URL(url).pathname + const ext = extname(pathname).toLowerCase() + if (['.gif', '.png', '.jpg', '.jpeg', '.webp'].includes(ext)) { + return ext + } + } catch { } + return null + } + + /** + * 简单的字符串哈希 + */ + private hashString(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = ((hash << 5) - hash) + char + hash = hash & hash + } + return Math.abs(hash).toString(16) + } + + /** + * 获取会话详情信息 + */ + async getSessionDetail(sessionId: string): Promise<{ + success: boolean + detail?: { + wxid: string + displayName: string + remark?: string + nickName?: string + alias?: string + avatarUrl?: string + messageCount: number + firstMessageTime?: number + latestMessageTime?: number + messageTables: { dbName: string; tableName: string; count: number }[] + } + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + let displayName = sessionId + let remark: string | undefined + let nickName: string | undefined + let alias: string | undefined + let avatarUrl: string | undefined + + const contactResult = await wcdbService.getContact(sessionId) + if (contactResult.success && contactResult.contact) { + remark = contactResult.contact.remark || undefined + nickName = contactResult.contact.nickName || undefined + alias = contactResult.contact.alias || undefined + displayName = remark || nickName || alias || sessionId + } + const avatarResult = await wcdbService.getAvatarUrls([sessionId]) + if (avatarResult.success && avatarResult.map) { + avatarUrl = avatarResult.map[sessionId] + } + + const countResult = await wcdbService.getMessageCount(sessionId) + const totalMessageCount = countResult.success && countResult.count ? countResult.count : 0 + + let firstMessageTime: number | undefined + let latestMessageTime: number | undefined + + const earliestCursor = await wcdbService.openMessageCursor(sessionId, 1, true, 0, 0) + if (earliestCursor.success && earliestCursor.cursor) { + const batch = await wcdbService.fetchMessageBatch(earliestCursor.cursor) + if (batch.success && batch.rows && batch.rows.length > 0) { + firstMessageTime = parseInt(batch.rows[0].create_time || '0', 10) || undefined + } + await wcdbService.closeMessageCursor(earliestCursor.cursor) + } + + const latestCursor = await wcdbService.openMessageCursor(sessionId, 1, false, 0, 0) + if (latestCursor.success && latestCursor.cursor) { + const batch = await wcdbService.fetchMessageBatch(latestCursor.cursor) + if (batch.success && batch.rows && batch.rows.length > 0) { + latestMessageTime = parseInt(batch.rows[0].create_time || '0', 10) || undefined + } + await wcdbService.closeMessageCursor(latestCursor.cursor) + } + + const messageTables: { dbName: string; tableName: string; count: number }[] = [] + const tableStats = await wcdbService.getMessageTableStats(sessionId) + if (tableStats.success && tableStats.tables) { + for (const row of tableStats.tables) { + messageTables.push({ + dbName: basename(row.db_path || ''), + tableName: row.table_name || '', + count: parseInt(row.count || '0', 10) + }) + } + } + + return { + success: true, + detail: { + wxid: sessionId, + displayName, + remark, + nickName, + alias, + avatarUrl, + messageCount: totalMessageCount, + firstMessageTime, + latestMessageTime, + messageTables + } + } + } catch (e) { + console.error('ChatService: 获取会话详情失败:', e) + return { success: false, error: String(e) } + } + } + /** + * 获取图片数据(解密后的) + */ + async getImageData(sessionId: string, msgId: string): Promise<{ success: boolean; data?: string; error?: string }> { + try { + const localId = parseInt(msgId, 10) + if (!this.connected) await this.connect() + + // 1. 获取消息详情以拿到 MD5 和 AES Key + const msgResult = await this.getMessageByLocalId(sessionId, localId) + if (!msgResult.success || !msgResult.message) { + return { success: false, error: '未找到消息' } + } + const msg = msgResult.message + console.info('[ChatService][Image] request', { + sessionId, + localId: msg.localId, + imageMd5: msg.imageMd5, + imageDatName: msg.imageDatName + }) + + // 2. 确定搜索的基础名 + const baseName = msg.imageMd5 || msg.imageDatName || String(msg.localId) + + // 3. 查找 .dat 文件 + const myWxid = this.configService.get('myWxid') + const dbPath = this.configService.get('dbPath') + if (!myWxid || !dbPath) return { success: false, error: '配置缺失' } + + const accountDir = dirname(dirname(dbPath)) // dbPath 是 db_storage 里面的路径或同级 + // 实际上 dbPath 指向 db_storage,accountDir 应该是其父目录 + const actualAccountDir = this.resolveAccountDir(dbPath, myWxid) + if (!actualAccountDir) return { success: false, error: '无法定位账号目录' } + + const datPath = await this.findDatFile(actualAccountDir, baseName, sessionId) + if (!datPath) return { success: false, error: '未找到图片源文件 (.dat)' } + console.info('[ChatService][Image] dat path', datPath) + + // 4. 获取解密密钥 + const xorKeyRaw = this.configService.get('imageXorKey') + const aesKeyRaw = this.configService.get('imageAesKey') || msg.aesKey + + if (!xorKeyRaw) return { success: false, error: '未配置图片 XOR 密钥,请在设置中自动获取' } + + const xorKey = this.parseXorKey(xorKeyRaw) + const data = readFileSync(datPath) + + // 5. 解密 + let decrypted: Buffer + const version = this.getDatVersion(data) + + if (version === 0) { + decrypted = this.decryptDatV3(data, xorKey) + } else if (version === 1) { + const aesKey = this.asciiKey16(this.defaultV1AesKey) + decrypted = this.decryptDatV4(data, xorKey, aesKey) + } else { + const trimmed = String(aesKeyRaw ?? '').trim() + if (!trimmed || trimmed.length < 16) { + return { success: false, error: 'V4版本需要16字节AES密钥' } + } + const aesKey = this.asciiKey16(trimmed) + decrypted = this.decryptDatV4(data, xorKey, aesKey) + } + console.info('[ChatService][Image] decrypted bytes', decrypted.length) + + // 返回 base64 + return { success: true, data: decrypted.toString('base64') } + } catch (e) { + console.error('ChatService: getImageData 失败:', e) + return { success: false, error: String(e) } + } + } + + async getVoiceData(sessionId: string, msgId: string): Promise<{ success: boolean; data?: string; error?: string }> { + try { + const localId = parseInt(msgId, 10) + const msgResult = await this.getMessageByLocalId(sessionId, localId) + if (!msgResult.success || !msgResult.message) return { success: false, error: '未找到该消息' } + const msg = msgResult.message + if (msg.isSend === 1) { + return { success: false, error: '暂不支持解密自己发送的语音' } + } + + const candidates = this.getVoiceLookupCandidates(sessionId, msg) + if (candidates.length === 0) { + return { success: false, error: '未找到语音关联账号' } + } + console.info('[ChatService][Voice] request', { + sessionId, + localId: msg.localId, + createTime: msg.createTime, + candidates + }) + + // 2. 查找所有的 media_*.db + const mediaDbs = await wcdbService.listMediaDbs() + if (!mediaDbs.success || !mediaDbs.data) return { success: false, error: '获取媒体库失败' } + console.info('[ChatService][Voice] media dbs', mediaDbs.data) + + // 3. 在所有媒体库中查找该消息的语音数据 + let silkData: Buffer | null = null + for (const dbPath of mediaDbs.data) { + const voiceTable = await this.resolveVoiceInfoTableName(dbPath) + if (!voiceTable) { + console.warn('[ChatService][Voice] voice table not found', dbPath) + continue + } + const columns = await this.resolveVoiceInfoColumns(dbPath, voiceTable) + if (!columns) { + console.warn('[ChatService][Voice] voice columns not found', { dbPath, voiceTable }) + continue + } + for (const candidate of candidates) { + const chatNameId = await this.resolveChatNameId(dbPath, candidate) + // 策略 1: 使用 ChatNameId + CreateTime (最准确) + if (chatNameId) { + let whereClause = '' + if (columns.chatNameIdColumn && columns.createTimeColumn) { + whereClause = `${columns.chatNameIdColumn} = ${chatNameId} AND ${columns.createTimeColumn} = ${msg.createTime}` + const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1` + const result = await wcdbService.execQuery('media', dbPath, sql) + if (result.success && result.rows && result.rows.length > 0) { + const raw = result.rows[0]?.data + const decoded = this.decodeVoiceBlob(raw) + if (decoded && decoded.length > 0) { + console.info('[ChatService][Voice] hit by createTime', { dbPath, voiceTable, whereClause, bytes: decoded.length }) + silkData = decoded + break + } + } + } + } + + // 策略 2: 使用 MsgLocalId (兜底,如果表支持) + if (columns.msgLocalIdColumn) { + const whereClause = `${columns.msgLocalIdColumn} = ${msg.localId}` + const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1` + const result = await wcdbService.execQuery('media', dbPath, sql) + if (result.success && result.rows && result.rows.length > 0) { + const raw = result.rows[0]?.data + const decoded = this.decodeVoiceBlob(raw) + if (decoded && decoded.length > 0) { + console.info('[ChatService][Voice] hit by localId', { dbPath, voiceTable, whereClause, bytes: decoded.length }) + silkData = decoded + break + } + } + } + } + if (silkData) break + } + + if (!silkData) return { success: false, error: '未找到语音数据' } + + // 4. 解码 Silk -> PCM -> WAV + const resourcesPath = app.isPackaged + ? join(process.resourcesPath, 'resources') + : join(app.getAppPath(), 'resources') + const decoderPath = join(resourcesPath, 'silk_v3_decoder.exe') + + if (!existsSync(decoderPath)) { + return { success: false, error: '找不到语音解码器 (silk_v3_decoder.exe)' } + } + console.info('[ChatService][Voice] decoder path', decoderPath) + + const tempDir = app.getPath('temp') + const silkFile = join(tempDir, `voice_${msgId}.silk`) + const pcmFile = join(tempDir, `voice_${msgId}.pcm`) + + try { + writeFileSync(silkFile, silkData) + // 执行解码: silk_v3_decoder.exe -Fs_API 24000 + console.info('[ChatService][Voice] executing decoder:', decoderPath, [silkFile, pcmFile]) + const { stdout, stderr } = await execFileAsync( + decoderPath, + [silkFile, pcmFile, '-Fs_API', '24000'], + { cwd: dirname(decoderPath) } + ) + if (stdout && stdout.trim()) console.info('[ChatService][Voice] decoder stdout:', stdout) + if (stderr && stderr.trim()) console.warn('[ChatService][Voice] decoder stderr:', stderr) + + if (!existsSync(pcmFile)) { + return { success: false, error: '语音解码失败' } + } + + const pcmData = readFileSync(pcmFile) + const wavHeader = this.createWavHeader(pcmData.length, 24000, 1) // 微信语音通常 24kHz + const wavData = Buffer.concat([wavHeader, pcmData]) + + return { success: true, data: wavData.toString('base64') } + } finally { + // 清理临时文件 + try { if (existsSync(silkFile)) unlinkSync(silkFile) } catch { } + try { if (existsSync(pcmFile)) unlinkSync(pcmFile) } catch { } + } + } catch (e) { + console.error('ChatService: getVoiceData 失败:', e) + return { success: false, error: String(e) } + } + } + + private createWavHeader(pcmLength: number, sampleRate: number = 24000, channels: number = 1): Buffer { + const header = Buffer.alloc(44) + header.write('RIFF', 0) + header.writeUInt32LE(36 + pcmLength, 4) + header.write('WAVE', 8) + header.write('fmt ', 12) + header.writeUInt32LE(16, 16) + header.writeUInt16LE(1, 20) + header.writeUInt16LE(channels, 22) + header.writeUInt32LE(sampleRate, 24) + header.writeUInt32LE(sampleRate * channels * 2, 28) + header.writeUInt16LE(channels * 2, 32) + header.writeUInt16LE(16, 34) + header.write('data', 36) + header.writeUInt32LE(pcmLength, 40) + return header + } + + async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> { + try { + console.info('[ChatService] getMessageById (SQL)', { sessionId, localId }) + + // 1. 获取该会话所在的消息表 + // 注意:这里使用 getMessageTableStats 而不是 getMessageTables,因为前者包含 db_path + const tableStats = await wcdbService.getMessageTableStats(sessionId) + if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { + return { success: false, error: '未找到会话消息表' } + } + + // 2. 遍历表查找消息 (通常只有一个主表,但可能有归档) + for (const tableInfo of tableStats.tables) { + const tableName = tableInfo.table_name || tableInfo.name + const dbPath = tableInfo.db_path + if (!tableName || !dbPath) continue + + // 构造查询 + const sql = `SELECT * FROM ${tableName} WHERE local_id = ${localId} LIMIT 1` + const result = await wcdbService.execQuery('message', dbPath, sql) + + if (result.success && result.rows && result.rows.length > 0) { + const row = result.rows[0] + const message = this.parseMessage(row) + + if (message.localId !== 0) { + console.info('[ChatService] getMessageById hit', { tableName, localId: message.localId }) + return { success: true, message } + } + } + } + + return { success: false, error: '未找到消息' } + } catch (e) { + console.error('ChatService: getMessageById 失败:', e) + return { success: false, error: String(e) } + } + } + + private parseMessage(row: any): Message { + const rawContent = this.decodeMessageContent( + this.getRowField(row, [ + 'message_content', + 'messageContent', + 'content', + 'msg_content', + 'msgContent', + 'WCDB_CT_message_content', + 'WCDB_CT_messageContent' + ]), + this.getRowField(row, [ + 'compress_content', + 'compressContent', + 'compressed_content', + 'WCDB_CT_compress_content', + 'WCDB_CT_compressContent' + ]) + ) + // 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的 + // 实际项目中建议抽取 parseRawMessage(row) 供多处使用 + const msg: Message = { + localId: this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0), + serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0), + localType: this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0), + createTime: this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0), + sortSeq: this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)), + isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0), + senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null, + rawContent: rawContent, + parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)) + } + + if (msg.localId === 0 || msg.createTime === 0) { + const rawLocalId = this.getRowField(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id']) + const rawCreateTime = this.getRowField(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time']) + console.warn('[ChatService] parseMessage raw keys', { + rawLocalId, + rawLocalIdType: rawLocalId ? typeof rawLocalId : 'null', + val_local_id: row['local_id'], + val_create_time: row['create_time'], + rawCreateTime, + rawCreateTimeType: rawCreateTime ? typeof rawCreateTime : 'null' + }) + } + + // 图片/语音解析逻辑 (简化示例,实际应调用现有解析方法) + if (msg.localType === 3) { // Image + const imgInfo = this.parseImageInfo(rawContent) + Object.assign(msg, imgInfo) + msg.imageDatName = this.parseImageDatNameFromRow(row) + } + + return msg + } + + private async getMessageByLocalId(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> { + return this.getMessageById(sessionId, localId) + } + + private resolveAccountDir(dbPath: string, wxid: string): string | null { + const normalized = dbPath.replace(/[\\\\/]+$/, '') + const dir = dirname(normalized) + if (basename(normalized).toLowerCase() === 'db_storage') return dir + if (basename(dir).toLowerCase() === 'db_storage') return dirname(dir) + return dir // 兜底 + } + + private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise { + const normalized = this.normalizeDatBase(baseName) + if (this.looksLikeMd5(normalized)) { + const hardlinkPath = this.resolveHardlinkPath(accountDir, normalized, sessionId) + if (hardlinkPath) return hardlinkPath + } + + const searchPaths = [ + join(accountDir, 'FileStorage', 'Image'), + join(accountDir, 'FileStorage', 'Image2'), + join(accountDir, 'FileStorage', 'MsgImg'), + join(accountDir, 'FileStorage', 'Video') + ] + + for (const searchPath of searchPaths) { + if (!existsSync(searchPath)) continue + const found = this.recursiveSearch(searchPath, baseName.toLowerCase(), 3) + if (found) return found + } + return null + } + + private recursiveSearch(dir: string, pattern: string, maxDepth: number): string | null { + if (maxDepth < 0) return null + try { + const entries = readdirSync(dir) + // 优先匹配当前目录文件 + for (const entry of entries) { + const fullPath = join(dir, entry) + const stats = statSync(fullPath) + if (stats.isFile()) { + const lowerEntry = entry.toLowerCase() + if (lowerEntry.includes(pattern) && lowerEntry.endsWith('.dat')) { + const baseLower = lowerEntry.slice(0, -4) + if (!this.hasImageVariantSuffix(baseLower)) continue + return fullPath + } + } + } + // 递归子目录 + for (const entry of entries) { + const fullPath = join(dir, entry) + const stats = statSync(fullPath) + if (stats.isDirectory()) { + const found = this.recursiveSearch(fullPath, pattern, maxDepth - 1) + if (found) return found + } + } + } catch { } + return null + } + + private looksLikeMd5(value: string): boolean { + return /^[a-fA-F0-9]{16,32}$/.test(value) + } + + private normalizeDatBase(name: string): string { + let base = name.toLowerCase() + if (base.endsWith('.dat') || base.endsWith('.jpg')) { + base = base.slice(0, -4) + } + while (/[._][a-z]$/.test(base)) { + base = base.slice(0, -2) + } + return base + } + + private hasXVariant(baseLower: string): boolean { + return /[._][a-z]$/.test(baseLower) + } + + private resolveHardlinkPath(accountDir: string, md5: string, sessionId?: string): string | null { + try { + const hardlinkPath = join(accountDir, 'hardlink.db') + if (!existsSync(hardlinkPath)) return null + + const state = this.getHardlinkState(accountDir, hardlinkPath) + if (!state.imageTable) return null + + const row = state.db + .prepare(`SELECT dir1, dir2, file_name FROM ${state.imageTable} WHERE md5 = ? LIMIT 1`) + .get(md5) as { dir1?: string; dir2?: string; file_name?: string } | undefined + + if (!row) return null + const dir1 = row.dir1 as string | undefined + const dir2 = row.dir2 as string | undefined + const fileName = row.file_name as string | undefined + if (!dir1 || !dir2 || !fileName) return null + const lowerFileName = fileName.toLowerCase() + if (lowerFileName.endsWith('.dat')) { + const baseLower = lowerFileName.slice(0, -4) + if (!this.hasXVariant(baseLower)) return null + } + + let dirName = dir2 + if (state.dirTable && sessionId) { + try { + const dirRow = state.db + .prepare(`SELECT dir_name FROM ${state.dirTable} WHERE dir_id = ? AND username = ? LIMIT 1`) + .get(dir2, sessionId) as { dir_name?: string } | undefined + if (dirRow?.dir_name) dirName = dirRow.dir_name as string + } catch {} + } + + const fullPath = join(accountDir, dir1, dirName, fileName) + if (existsSync(fullPath)) return fullPath + + const withDat = `${fullPath}.dat` + if (existsSync(withDat)) return withDat + } catch {} + return null + } + + private getHardlinkState(accountDir: string, hardlinkPath: string): HardlinkState { + const cached = this.hardlinkCache.get(accountDir) + if (cached) return cached + + const db = new Database(hardlinkPath, { readonly: true, fileMustExist: true }) + const imageRow = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'image_hardlink_info%' ORDER BY name DESC LIMIT 1") + .get() as { name?: string } | undefined + const dirRow = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1") + .get() as { name?: string } | undefined + const state: HardlinkState = { + db, + imageTable: imageRow?.name as string | undefined, + dirTable: dirRow?.name as string | undefined + } + this.hardlinkCache.set(accountDir, state) + return state + } + + private getDatVersion(data: Buffer): number { + if (data.length < 6) return 0 + const sigV1 = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]) + const sigV2 = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) + if (data.subarray(0, 6).equals(sigV1)) return 1 + if (data.subarray(0, 6).equals(sigV2)) return 2 + return 0 + } + + private decryptDatV3(data: Buffer, xorKey: number): Buffer { + const result = Buffer.alloc(data.length) + for (let i = 0; i < data.length; i++) { + result[i] = data[i] ^ xorKey + } + return result + } + + private decryptDatV4(data: Buffer, xorKey: number, aesKey: Buffer): Buffer { + if (data.length < 0x0f) { + throw new Error('文件太小,无法解析') + } + + const header = data.subarray(0, 0x0f) + const payload = data.subarray(0x0f) + const aesSize = this.bytesToInt32(header.subarray(6, 10)) + const xorSize = this.bytesToInt32(header.subarray(10, 14)) + + const remainder = ((aesSize % 16) + 16) % 16 + const alignedAesSize = aesSize + (16 - remainder) + if (alignedAesSize > payload.length) { + throw new Error('文件格式异常:AES 数据长度超过文件实际长度') + } + + const aesData = payload.subarray(0, alignedAesSize) + let unpadded = Buffer.alloc(0) + if (aesData.length > 0) { + const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, Buffer.alloc(0)) + decipher.setAutoPadding(false) + const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()]) + unpadded = this.strictRemovePadding(decrypted) + } + + const remaining = payload.subarray(alignedAesSize) + if (xorSize < 0 || xorSize > remaining.length) { + throw new Error('文件格式异常:XOR 数据长度不合法') + } + + let rawData = Buffer.alloc(0) + let xoredData = Buffer.alloc(0) + if (xorSize > 0) { + const rawLength = remaining.length - xorSize + if (rawLength < 0) { + throw new Error('文件格式异常:原始数据长度小于XOR长度') + } + rawData = remaining.subarray(0, rawLength) + const xorData = remaining.subarray(rawLength) + xoredData = Buffer.alloc(xorData.length) + for (let i = 0; i < xorData.length; i++) { + xoredData[i] = xorData[i] ^ xorKey + } + } else { + rawData = remaining + xoredData = Buffer.alloc(0) + } + + return Buffer.concat([unpadded, rawData, xoredData]) + } + + private strictRemovePadding(data: Buffer): Buffer { + if (!data.length) { + throw new Error('解密结果为空,填充非法') + } + const paddingLength = data[data.length - 1] + if (paddingLength === 0 || paddingLength > 16 || paddingLength > data.length) { + throw new Error('PKCS7 填充长度非法') + } + for (let i = data.length - paddingLength; i < data.length; i++) { + if (data[i] !== paddingLength) { + throw new Error('PKCS7 填充内容非法') + } + } + return data.subarray(0, data.length - paddingLength) + } + + private bytesToInt32(bytes: Buffer): number { + if (bytes.length !== 4) { + throw new Error('需要4个字节') + } + return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24) + } + + private hasImageVariantSuffix(baseLower: string): boolean { + const suffixes = [ + '.b', + '.h', + '.t', + '.c', + '.w', + '.l', + '_b', + '_h', + '_t', + '_c', + '_w', + '_l' + ] + return suffixes.some((suffix) => baseLower.endsWith(suffix)) + } + + private asciiKey16(keyString: string): Buffer { + if (keyString.length < 16) { + throw new Error('AES密钥至少需要16个字符') + } + return Buffer.from(keyString, 'ascii').subarray(0, 16) + } + + private parseXorKey(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + const cleanHex = String(value ?? '').toLowerCase().replace(/^0x/, '') + if (!cleanHex) { + throw new Error('十六进制字符串不能为空') + } + const hex = cleanHex.length >= 2 ? cleanHex.substring(0, 2) : cleanHex + const parsed = parseInt(hex, 16) + if (Number.isNaN(parsed)) { + throw new Error('十六进制字符串不能为空') + } + return parsed + } +} + +export const chatService = new ChatService() diff --git a/electron/services/config.ts b/electron/services/config.ts new file mode 100644 index 0000000..648fdac --- /dev/null +++ b/electron/services/config.ts @@ -0,0 +1,63 @@ +import Store from 'electron-store' + +interface ConfigSchema { + // 数据库相关 + dbPath: string // 数据库根目录 (xwechat_files) + decryptKey: string // 解密密钥 + myWxid: string // 当前用户 wxid + onboardingDone: boolean + imageXorKey: number + imageAesKey: string + + // 缓存相关 + cachePath: string + lastOpenedDb: string + lastSession: string + + // 界面相关 + theme: 'light' | 'dark' | 'system' + themeId: string + language: string + logEnabled: boolean +} + +export class ConfigService { + private store: Store + + constructor() { + this.store = new Store({ + name: 'WeFlow-config', + defaults: { + dbPath: '', + decryptKey: '', + myWxid: '', + onboardingDone: false, + imageXorKey: 0, + imageAesKey: '', + cachePath: '', + lastOpenedDb: '', + lastSession: '', + theme: 'system', + themeId: 'cloud-dancer', + language: 'zh-CN', + logEnabled: false + } + }) + } + + get(key: K): ConfigSchema[K] { + return this.store.get(key) + } + + set(key: K, value: ConfigSchema[K]): void { + this.store.set(key, value) + } + + getAll(): ConfigSchema { + return this.store.store + } + + clear(): void { + this.store.clear() + } +} diff --git a/electron/services/dbPathService.ts b/electron/services/dbPathService.ts new file mode 100644 index 0000000..9d49f21 --- /dev/null +++ b/electron/services/dbPathService.ts @@ -0,0 +1,159 @@ +import { join, basename } from 'path' +import { existsSync, readdirSync, statSync } from 'fs' +import { homedir } from 'os' + +export interface WxidInfo { + wxid: string + modifiedTime: number +} + +export class DbPathService { + /** + * 自动检测微信数据库根目录 + */ + async autoDetect(): Promise<{ success: boolean; path?: string; error?: string }> { + try { + const possiblePaths: string[] = [] + const home = homedir() + + // 微信4.x 数据目录 + possiblePaths.push(join(home, 'Documents', 'xwechat_files')) + // 旧版微信数据目录 + possiblePaths.push(join(home, 'Documents', 'WeChat Files')) + + for (const path of possiblePaths) { + if (existsSync(path)) { + const rootName = path.split(/[/\\]/).pop()?.toLowerCase() + if (rootName !== 'xwechat_files' && rootName !== 'wechat files') { + continue + } + + // 检查是否有有效的账号目录 + const accounts = this.findAccountDirs(path) + if (accounts.length > 0) { + return { success: true, path } + } + } + } + + return { success: false, error: '未能自动检测到微信数据库目录' } + } catch (e) { + return { success: false, error: String(e) } + } + } + + /** + * 查找账号目录(包含 db_storage 或图片目录) + */ + findAccountDirs(rootPath: string): string[] { + const accounts: string[] = [] + + try { + const entries = readdirSync(rootPath) + + for (const entry of entries) { + const entryPath = join(rootPath, entry) + let stat: ReturnType + try { + stat = statSync(entryPath) + } catch { + continue + } + + if (stat.isDirectory()) { + if (!this.isPotentialAccountName(entry)) continue + + // 检查是否有有效账号目录结构 + if (this.isAccountDir(entryPath)) { + accounts.push(entry) + } + } + } + } catch {} + + return accounts + } + + private isAccountDir(entryPath: string): boolean { + return ( + existsSync(join(entryPath, 'db_storage')) || + existsSync(join(entryPath, 'FileStorage', 'Image')) || + existsSync(join(entryPath, 'FileStorage', 'Image2')) + ) + } + + private isPotentialAccountName(name: string): boolean { + const lower = name.toLowerCase() + if (lower.startsWith('all') || lower.startsWith('applet') || lower.startsWith('backup') || lower.startsWith('wmpf')) { + return false + } + return true + } + + private getAccountModifiedTime(entryPath: string): number { + try { + const accountStat = statSync(entryPath) + let latest = accountStat.mtimeMs + + const dbPath = join(entryPath, 'db_storage') + if (existsSync(dbPath)) { + const dbStat = statSync(dbPath) + latest = Math.max(latest, dbStat.mtimeMs) + } + + const imagePath = join(entryPath, 'FileStorage', 'Image') + if (existsSync(imagePath)) { + const imageStat = statSync(imagePath) + latest = Math.max(latest, imageStat.mtimeMs) + } + + const image2Path = join(entryPath, 'FileStorage', 'Image2') + if (existsSync(image2Path)) { + const image2Stat = statSync(image2Path) + latest = Math.max(latest, image2Stat.mtimeMs) + } + + return latest + } catch { + return 0 + } + } + + /** + * 扫描 wxid 列表 + */ + scanWxids(rootPath: string): WxidInfo[] { + const wxids: WxidInfo[] = [] + + try { + if (this.isAccountDir(rootPath)) { + const wxid = basename(rootPath) + const modifiedTime = this.getAccountModifiedTime(rootPath) + return [{ wxid, modifiedTime }] + } + + const accounts = this.findAccountDirs(rootPath) + + for (const account of accounts) { + const fullPath = join(rootPath, account) + const modifiedTime = this.getAccountModifiedTime(fullPath) + wxids.push({ wxid: account, modifiedTime }) + } + } catch {} + + return wxids.sort((a, b) => { + if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime + return a.wxid.localeCompare(b.wxid) + }) + } + + /** + * 获取默认数据库路径 + */ + getDefaultPath(): string { + const home = homedir() + return join(home, 'Documents', 'xwechat_files') + } +} + +export const dbPathService = new DbPathService() diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts new file mode 100644 index 0000000..8a2613c --- /dev/null +++ b/electron/services/exportService.ts @@ -0,0 +1,626 @@ +import * as fs from 'fs' +import * as path from 'path' +import { ConfigService } from './config' +import { wcdbService } from './wcdbService' + +// ChatLab 格式类型定义 +interface ChatLabHeader { + version: string + exportedAt: number + generator: string + description?: string +} + +interface ChatLabMeta { + name: string + platform: string + type: 'group' | 'private' + groupId?: string +} + +interface ChatLabMember { + platformId: string + accountName: string + groupNickname?: string + avatar?: string +} + +interface ChatLabMessage { + sender: string + accountName: string + groupNickname?: string + timestamp: number + type: number + content: string | null +} + +interface ChatLabExport { + chatlab: ChatLabHeader + meta: ChatLabMeta + members: ChatLabMember[] + messages: ChatLabMessage[] +} + +// 消息类型映射:微信 localType -> ChatLab type +const MESSAGE_TYPE_MAP: Record = { + 1: 0, // 文本 -> TEXT + 3: 1, // 图片 -> IMAGE + 34: 2, // 语音 -> VOICE + 43: 3, // 视频 -> VIDEO + 49: 7, // 链接/文件 -> LINK (需要进一步判断) + 47: 5, // 表情包 -> EMOJI + 48: 8, // 位置 -> LOCATION + 42: 27, // 名片 -> CONTACT + 50: 23, // 通话 -> CALL + 10000: 80, // 系统消息 -> SYSTEM +} + +export interface ExportOptions { + format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql' + dateRange?: { start: number; end: number } | null + exportMedia?: boolean + exportAvatars?: boolean +} + +export interface ExportProgress { + current: number + total: number + currentSession: string + phase: 'preparing' | 'exporting' | 'writing' | 'complete' +} + +class ExportService { + private configService: ConfigService + private contactCache: Map = new Map() + + constructor() { + this.configService = new ConfigService() + } + + private cleanAccountDirName(dirName: string): string { + const trimmed = dirName.trim() + if (!trimmed) return trimmed + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + if (match) return match[1] + return trimmed + } + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + if (suffixMatch) return suffixMatch[1] + return trimmed + } + + private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> { + const wxid = this.configService.get('myWxid') + const dbPath = this.configService.get('dbPath') + const decryptKey = this.configService.get('decryptKey') + if (!wxid) return { success: false, error: '请先在设置页面配置微信ID' } + if (!dbPath) return { success: false, error: '请先在设置页面配置数据库路径' } + if (!decryptKey) return { success: false, error: '请先在设置页面配置解密密钥' } + + const cleanedWxid = this.cleanAccountDirName(wxid) + const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid) + if (!ok) return { success: false, error: 'WCDB 打开失败' } + return { success: true, cleanedWxid } + } + + private async getContactInfo(username: string): Promise<{ displayName: string; avatarUrl?: string }> { + if (this.contactCache.has(username)) { + return this.contactCache.get(username)! + } + + const [displayNames, avatarUrls] = await Promise.all([ + wcdbService.getDisplayNames([username]), + wcdbService.getAvatarUrls([username]) + ]) + + const displayName = displayNames.success && displayNames.map + ? (displayNames.map[username] || username) + : username + const avatarUrl = avatarUrls.success && avatarUrls.map + ? avatarUrls.map[username] + : undefined + + const info = { displayName, avatarUrl } + this.contactCache.set(username, info) + return info + } + + /** + * 转换微信消息类型到 ChatLab 类型 + */ + private convertMessageType(localType: number, content: string): number { + if (localType === 49) { + const typeMatch = /(\d+)<\/type>/i.exec(content) + if (typeMatch) { + const subType = parseInt(typeMatch[1]) + switch (subType) { + case 6: return 4 // 文件 -> FILE + case 33: + case 36: return 24 // 小程序 -> SHARE + case 57: return 25 // 引用回复 -> REPLY + default: return 7 // 链接 -> LINK + } + } + } + return MESSAGE_TYPE_MAP[localType] ?? 99 + } + + /** + * 解码消息内容 + */ + private decodeMessageContent(messageContent: any, compressContent: any): string { + let content = this.decodeMaybeCompressed(compressContent) + if (!content || content.length === 0) { + content = this.decodeMaybeCompressed(messageContent) + } + return content + } + + private decodeMaybeCompressed(raw: any): string { + if (!raw) return '' + if (typeof raw === 'string') { + if (raw.length === 0) return '' + if (this.looksLikeHex(raw)) { + const bytes = Buffer.from(raw, 'hex') + if (bytes.length > 0) return this.decodeBinaryContent(bytes) + } + if (this.looksLikeBase64(raw)) { + try { + const bytes = Buffer.from(raw, 'base64') + return this.decodeBinaryContent(bytes) + } catch { + return raw + } + } + return raw + } + return '' + } + + private decodeBinaryContent(data: Buffer): string { + if (data.length === 0) return '' + try { + if (data.length >= 4) { + const magic = data.readUInt32LE(0) + if (magic === 0xFD2FB528) { + const fzstd = require('fzstd') + const decompressed = fzstd.decompress(data) + return Buffer.from(decompressed).toString('utf-8') + } + } + const decoded = data.toString('utf-8') + const replacementCount = (decoded.match(/\uFFFD/g) || []).length + if (replacementCount < decoded.length * 0.2) { + return decoded.replace(/\uFFFD/g, '') + } + return data.toString('latin1') + } catch { + return '' + } + } + + private looksLikeHex(s: string): boolean { + if (s.length % 2 !== 0) return false + return /^[0-9a-fA-F]+$/.test(s) + } + + private looksLikeBase64(s: string): boolean { + if (s.length % 4 !== 0) return false + return /^[A-Za-z0-9+/=]+$/.test(s) + } + + /** + * 解析消息内容为可读文本 + */ + private parseMessageContent(content: string, localType: number): string | null { + if (!content) return null + + switch (localType) { + case 1: + return this.stripSenderPrefix(content) + case 3: return '[图片]' + case 34: return '[语音消息]' + case 42: return '[名片]' + case 43: return '[视频]' + case 47: return '[动画表情]' + case 48: return '[位置]' + case 49: { + const title = this.extractXmlValue(content, 'title') + return title || '[链接]' + } + case 50: return '[通话]' + case 10000: return this.cleanSystemMessage(content) + default: + if (content.includes('57')) { + const title = this.extractXmlValue(content, 'title') + return title || '[引用消息]' + } + return this.stripSenderPrefix(content) || null + } + } + + private stripSenderPrefix(content: string): string { + return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)/, '') + } + + private extractXmlValue(xml: string, tagName: string): string { + const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\/${tagName}>`, 'i') + const match = regex.exec(xml) + if (match) { + return match[1].replace(//g, '').trim() + } + return '' + } + + private cleanSystemMessage(content: string): string { + return content + .replace(/]*>/gi, '') + .replace(/<\/?[a-zA-Z0-9_]+[^>]*>/g, '') + .replace(/\s+/g, ' ') + .trim() || '[系统消息]' + } + + /** + * 获取消息类型名称 + */ + private getMessageTypeName(localType: number): string { + const typeNames: Record = { + 1: '文本消息', + 3: '图片消息', + 34: '语音消息', + 42: '名片消息', + 43: '视频消息', + 47: '动画表情', + 48: '位置消息', + 49: '链接消息', + 50: '通话消息', + 10000: '系统消息' + } + return typeNames[localType] || '其他消息' + } + + /** + * 格式化时间戳为可读字符串 + */ + private formatTimestamp(timestamp: number): string { + const date = new Date(timestamp * 1000) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + const seconds = String(date.getSeconds()).padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` + } + + private async collectMessages( + sessionId: string, + cleanedMyWxid: string, + dateRange?: { start: number; end: number } | null + ): Promise<{ rows: any[]; memberSet: Map; firstTime: number | null; lastTime: number | null }> { + const rows: any[] = [] + const memberSet = new Map() + let firstTime: number | null = null + let lastTime: number | null = null + + const cursor = await wcdbService.openMessageCursor( + sessionId, + 500, + true, + dateRange?.start || 0, + dateRange?.end || 0 + ) + if (!cursor.success || !cursor.cursor) { + return { rows, memberSet, firstTime, lastTime } + } + + try { + let hasMore = true + while (hasMore) { + const batch = await wcdbService.fetchMessageBatch(cursor.cursor) + if (!batch.success || !batch.rows) break + for (const row of batch.rows) { + const createTime = parseInt(row.create_time || '0', 10) + if (dateRange) { + if (createTime < dateRange.start || createTime > dateRange.end) continue + } + + const content = this.decodeMessageContent(row.message_content, row.compress_content) + const localType = parseInt(row.local_type || row.type || '1', 10) + const senderUsername = row.sender_username || '' + const isSendRaw = row.computed_is_send ?? row.is_send ?? '0' + const isSend = parseInt(isSendRaw, 10) === 1 + + const actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId) + const memberInfo = await this.getContactInfo(actualSender) + if (!memberSet.has(actualSender)) { + memberSet.set(actualSender, { + platformId: actualSender, + accountName: memberInfo.displayName + }) + } + + rows.push({ + createTime, + localType, + content, + senderUsername: actualSender, + isSend + }) + + if (firstTime === null || createTime < firstTime) firstTime = createTime + if (lastTime === null || createTime > lastTime) lastTime = createTime + } + hasMore = batch.hasMore === true + } + } finally { + await wcdbService.closeMessageCursor(cursor.cursor) + } + + return { rows, memberSet, firstTime, lastTime } + } + + /** + * 导出单个会话为 ChatLab 格式 + */ + async exportSessionToChatLab( + sessionId: string, + outputPath: string, + options: ExportOptions, + onProgress?: (progress: ExportProgress) => void + ): Promise<{ success: boolean; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } + + const cleanedMyWxid = conn.cleanedWxid + const isGroup = sessionId.includes('@chatroom') + + const sessionInfo = await this.getContactInfo(sessionId) + + onProgress?.({ + current: 0, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'preparing' + }) + + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + const allMessages = collected.rows + + allMessages.sort((a, b) => a.createTime - b.createTime) + + onProgress?.({ + current: 50, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting' + }) + + const chatLabMessages: ChatLabMessage[] = allMessages.map((msg) => { + const memberInfo = collected.memberSet.get(msg.senderUsername) || { + platformId: msg.senderUsername, + accountName: msg.senderUsername + } + return { + sender: msg.senderUsername, + accountName: memberInfo.accountName, + timestamp: msg.createTime, + type: this.convertMessageType(msg.localType, msg.content), + content: this.parseMessageContent(msg.content, msg.localType) + } + }) + + const chatLabExport: ChatLabExport = { + chatlab: { + version: '0.0.1', + exportedAt: Math.floor(Date.now() / 1000), + generator: 'WeFlow' + }, + meta: { + name: sessionInfo.displayName, + platform: 'wechat', + type: isGroup ? 'group' : 'private', + ...(isGroup && { groupId: sessionId }) + }, + members: Array.from(collected.memberSet.values()), + messages: chatLabMessages + } + + onProgress?.({ + current: 80, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'writing' + }) + + if (options.format === 'chatlab-jsonl') { + const lines: string[] = [] + lines.push(JSON.stringify({ + _type: 'header', + chatlab: chatLabExport.chatlab, + meta: chatLabExport.meta + })) + for (const member of chatLabExport.members) { + lines.push(JSON.stringify({ _type: 'member', ...member })) + } + for (const message of chatLabExport.messages) { + lines.push(JSON.stringify({ _type: 'message', ...message })) + } + fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8') + } else { + fs.writeFileSync(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8') + } + + onProgress?.({ + current: 100, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'complete' + }) + + return { success: true } + } catch (e) { + console.error('ExportService: 导出失败:', e) + return { success: false, error: String(e) } + } + } + + /** + * 导出单个会话为详细 JSON 格式(原项目格式) + */ + async exportSessionToDetailedJson( + sessionId: string, + outputPath: string, + options: ExportOptions, + onProgress?: (progress: ExportProgress) => void + ): Promise<{ success: boolean; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } + + const cleanedMyWxid = conn.cleanedWxid + const isGroup = sessionId.includes('@chatroom') + + const sessionInfo = await this.getContactInfo(sessionId) + const myInfo = await this.getContactInfo(cleanedMyWxid) + + onProgress?.({ + current: 0, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'preparing' + }) + + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + const allMessages: any[] = [] + + for (const msg of collected.rows) { + const senderInfo = await this.getContactInfo(msg.senderUsername) + const sourceMatch = /[\s\S]*?<\/msgsource>/i.exec(msg.content || '') + const source = sourceMatch ? sourceMatch[0] : '' + + allMessages.push({ + localId: allMessages.length + 1, + createTime: msg.createTime, + formattedTime: this.formatTimestamp(msg.createTime), + type: this.getMessageTypeName(msg.localType), + localType: msg.localType, + content: this.parseMessageContent(msg.content, msg.localType), + isSend: msg.isSend ? 1 : 0, + senderUsername: msg.senderUsername, + senderDisplayName: senderInfo.displayName, + source, + senderAvatarKey: msg.senderUsername + }) + } + + allMessages.sort((a, b) => a.createTime - b.createTime) + + onProgress?.({ + current: 70, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'writing' + }) + + const detailedExport = { + session: { + wxid: sessionId, + nickname: sessionInfo.displayName, + remark: sessionInfo.displayName, + displayName: sessionInfo.displayName, + type: isGroup ? '群聊' : '私聊', + lastTimestamp: collected.lastTime, + messageCount: allMessages.length + }, + messages: allMessages + } + + fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8') + + onProgress?.({ + current: 100, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'complete' + }) + + return { success: true } + } catch (e) { + console.error('ExportService: 导出失败:', e) + return { success: false, error: String(e) } + } + } + + /** + * 批量导出多个会话 + */ + async exportSessions( + sessionIds: string[], + outputDir: string, + options: ExportOptions, + onProgress?: (progress: ExportProgress) => void + ): Promise<{ success: boolean; successCount: number; failCount: number; error?: string }> { + let successCount = 0 + let failCount = 0 + + try { + const conn = await this.ensureConnected() + if (!conn.success) { + return { success: false, successCount: 0, failCount: sessionIds.length, error: conn.error } + } + + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }) + } + + for (let i = 0; i < sessionIds.length; i++) { + const sessionId = sessionIds[i] + const sessionInfo = await this.getContactInfo(sessionId) + + onProgress?.({ + current: i + 1, + total: sessionIds.length, + currentSession: sessionInfo.displayName, + phase: 'exporting' + }) + + const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_') + let ext = '.json' + if (options.format === 'chatlab-jsonl') ext = '.jsonl' + const outputPath = path.join(outputDir, `${safeName}${ext}`) + + let result: { success: boolean; error?: string } + if (options.format === 'json') { + result = await this.exportSessionToDetailedJson(sessionId, outputPath, options) + } else if (options.format === 'chatlab' || options.format === 'chatlab-jsonl') { + result = await this.exportSessionToChatLab(sessionId, outputPath, options) + } else { + result = { success: false, error: `不支持的格式: ${options.format}` } + } + + if (result.success) { + successCount++ + } else { + failCount++ + console.error(`导出 ${sessionId} 失败:`, result.error) + } + } + + onProgress?.({ + current: sessionIds.length, + total: sessionIds.length, + currentSession: '', + phase: 'complete' + }) + + return { success: true, successCount, failCount } + } catch (e) { + return { success: false, successCount, failCount, error: String(e) } + } + } +} + +export const exportService = new ExportService() diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts new file mode 100644 index 0000000..9758a7b --- /dev/null +++ b/electron/services/groupAnalyticsService.ts @@ -0,0 +1,251 @@ +import { ConfigService } from './config' +import { wcdbService } from './wcdbService' + +export interface GroupChatInfo { + username: string + displayName: string + memberCount: number + avatarUrl?: string +} + +export interface GroupMember { + username: string + displayName: string + avatarUrl?: string +} + +export interface GroupMessageRank { + member: GroupMember + messageCount: number +} + +export interface GroupActiveHours { + hourlyDistribution: Record +} + +export interface MediaTypeCount { + type: number + name: string + count: number +} + +export interface GroupMediaStats { + typeCounts: MediaTypeCount[] + total: number +} + +class GroupAnalyticsService { + private configService: ConfigService + + constructor() { + this.configService = new ConfigService() + } + + private cleanAccountDirName(name: string): string { + const trimmed = name.trim() + if (!trimmed) return trimmed + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + if (match) return match[1] + } + return trimmed + } + + private async ensureConnected(): Promise<{ success: boolean; error?: string }> { + const wxid = this.configService.get('myWxid') + const dbPath = this.configService.get('dbPath') + const decryptKey = this.configService.get('decryptKey') + if (!wxid) return { success: false, error: '未配置微信ID' } + if (!dbPath) return { success: false, error: '未配置数据库路径' } + if (!decryptKey) return { success: false, error: '未配置解密密钥' } + + const cleanedWxid = this.cleanAccountDirName(wxid) + const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid) + if (!ok) return { success: false, error: 'WCDB 打开失败' } + return { success: true } + } + + async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success) return { success: false, error: conn.error } + + const sessionResult = await wcdbService.getSessions() + if (!sessionResult.success || !sessionResult.sessions) { + return { success: false, error: sessionResult.error || '获取会话失败' } + } + + const rows = sessionResult.sessions as Record[] + const groupIds = rows + .map((row) => row.username || row.user_name || row.userName || '') + .filter((username) => username.includes('@chatroom')) + + const [displayNames, avatarUrls, memberCounts] = await Promise.all([ + wcdbService.getDisplayNames(groupIds), + wcdbService.getAvatarUrls(groupIds), + wcdbService.getGroupMemberCounts(groupIds) + ]) + + const groups: GroupChatInfo[] = [] + for (const groupId of groupIds) { + groups.push({ + username: groupId, + displayName: displayNames.success && displayNames.map + ? (displayNames.map[groupId] || groupId) + : groupId, + memberCount: memberCounts.success && memberCounts.map && typeof memberCounts.map[groupId] === 'number' + ? memberCounts.map[groupId] + : 0, + avatarUrl: avatarUrls.success && avatarUrls.map ? avatarUrls.map[groupId] : undefined + }) + } + + groups.sort((a, b) => b.memberCount - a.memberCount) + return { success: true, data: groups } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getGroupMembers(chatroomId: string): Promise<{ success: boolean; data?: GroupMember[]; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success) return { success: false, error: conn.error } + + const result = await wcdbService.getGroupMembers(chatroomId) + if (!result.success || !result.members) { + return { success: false, error: result.error || '获取群成员失败' } + } + + const members = result.members as { username: string; avatarUrl?: string }[] + const usernames = members.map((m) => m.username) + const displayNames = await wcdbService.getDisplayNames(usernames) + + const data: GroupMember[] = members.map((m) => ({ + username: m.username, + displayName: displayNames.success && displayNames.map ? (displayNames.map[m.username] || m.username) : m.username, + avatarUrl: m.avatarUrl + })) + + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getGroupMessageRanking(chatroomId: string, limit: number = 20, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupMessageRank[]; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success) return { success: false, error: conn.error } + + const result = await wcdbService.getGroupStats(chatroomId, startTime || 0, endTime || 0) + if (!result.success || !result.data) return { success: false, error: result.error || '聚合失败' } + + const d = result.data + const sessionData = d.sessions[chatroomId] + if (!sessionData || !sessionData.senders) return { success: true, data: [] } + + const idMap = d.idMap || {} + const senderEntries = Object.entries(sessionData.senders as Record) + + const rankings: GroupMessageRank[] = senderEntries + .map(([id, count]) => { + const username = idMap[id] || id + return { + member: { username, displayName: username }, // Display name will be resolved below + messageCount: count + } + }) + .sort((a, b) => b.messageCount - a.messageCount) + .slice(0, limit) + + // 批量获取显示名称和头像 + const usernames = rankings.map(r => r.member.username) + const [names, avatars] = await Promise.all([ + wcdbService.getDisplayNames(usernames), + wcdbService.getAvatarUrls(usernames) + ]) + + for (const rank of rankings) { + if (names.success && names.map && names.map[rank.member.username]) { + rank.member.displayName = names.map[rank.member.username] + } + if (avatars.success && avatars.map && avatars.map[rank.member.username]) { + rank.member.avatarUrl = avatars.map[rank.member.username] + } + } + + return { success: true, data: rankings } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getGroupActiveHours(chatroomId: string, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupActiveHours; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success) return { success: false, error: conn.error } + + const result = await wcdbService.getGroupStats(chatroomId, startTime || 0, endTime || 0) + if (!result.success || !result.data) return { success: false, error: result.error || '聚合失败' } + + const hourlyDistribution: Record = {} + for (let i = 0; i < 24; i++) { + hourlyDistribution[i] = result.data.hourly[i] || 0 + } + + return { success: true, data: { hourlyDistribution } } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getGroupMediaStats(chatroomId: string, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupMediaStats; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success) return { success: false, error: conn.error } + + const result = await wcdbService.getGroupStats(chatroomId, startTime || 0, endTime || 0) + if (!result.success || !result.data) return { success: false, error: result.error || '聚合失败' } + + const typeCountsRaw = result.data.typeCounts as Record + const mainTypes = [1, 3, 34, 43, 47, 49] + const typeNames: Record = { + 1: '文本', 3: '图片', 34: '语音', 43: '视频', 47: '表情包', 49: '链接/文件' + } + + const countsMap = new Map() + let othersCount = 0 + + for (const [typeStr, count] of Object.entries(typeCountsRaw)) { + const type = parseInt(typeStr, 10) + if (mainTypes.includes(type)) { + countsMap.set(type, (countsMap.get(type) || 0) + count) + } else { + othersCount += count + } + } + + const mediaCounts: MediaTypeCount[] = mainTypes + .map(type => ({ + type, + name: typeNames[type], + count: countsMap.get(type) || 0 + })) + .filter(item => item.count > 0) + + if (othersCount > 0) { + mediaCounts.push({ type: -1, name: '其他', count: othersCount }) + } + + mediaCounts.sort((a, b) => b.count - a.count) + const total = mediaCounts.reduce((sum, item) => sum + item.count, 0) + + return { success: true, data: { typeCounts: mediaCounts, total } } + } catch (e) { + return { success: false, error: String(e) } + } + } +} + +export const groupAnalyticsService = new GroupAnalyticsService() diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts new file mode 100644 index 0000000..6797270 --- /dev/null +++ b/electron/services/imageDecryptService.ts @@ -0,0 +1,1383 @@ +import { app, BrowserWindow } from 'electron' +import { basename, dirname, extname, join } from 'path' +import { pathToFileURL } from 'url' +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'fs' +import { writeFile } from 'fs/promises' +import crypto from 'crypto' +import { Worker } from 'worker_threads' +import { ConfigService } from './config' +import { wcdbService } from './wcdbService' + +type DecryptResult = { + success: boolean + localPath?: string + error?: string + isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图) +} + +type HardlinkState = { + imageTable?: string + dirTable?: string +} + +export class ImageDecryptService { + private configService = new ConfigService() + private hardlinkCache = new Map() + private resolvedCache = new Map() + private pending = new Map>() + private readonly defaultV1AesKey = 'cfcd208495d565ef' + private cacheIndexed = false + private cacheIndexing: Promise | null = null + private updateFlags = new Map() + + private logInfo(message: string, meta?: Record): void { + if (!this.configService.get('logEnabled')) return + if (meta) { + console.info(message, meta) + } else { + console.info(message) + } + } + + async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise { + await this.ensureCacheIndexed() + const cacheKeys = this.getCacheKeys(payload) + const cacheKey = cacheKeys[0] + if (!cacheKey) { + return { success: false, error: '缺少图片标识' } + } + for (const key of cacheKeys) { + const cached = this.resolvedCache.get(key) + if (cached && existsSync(cached) && this.isImageFile(cached)) { + const dataUrl = this.fileToDataUrl(cached) + const isThumb = this.isThumbnailPath(cached) + const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false + if (isThumb) { + this.triggerUpdateCheck(payload, key, cached) + } else { + this.updateFlags.delete(key) + } + this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached)) + return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate } + } + if (cached && !this.isImageFile(cached)) { + this.resolvedCache.delete(key) + } + } + + for (const key of cacheKeys) { + const existing = this.findCachedOutput(key, false, payload.sessionId) + if (existing) { + this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing) + const dataUrl = this.fileToDataUrl(existing) + const isThumb = this.isThumbnailPath(existing) + const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false + if (isThumb) { + this.triggerUpdateCheck(payload, key, existing) + } else { + this.updateFlags.delete(key) + } + this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing)) + return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate } + } + } + return { success: false, error: '未找到缓存图片' } + } + + async decryptImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }): Promise { + await this.ensureCacheIndexed() + const cacheKey = payload.imageMd5 || payload.imageDatName + if (!cacheKey) { + return { success: false, error: '缺少图片标识' } + } + + if (!payload.force) { + const cached = this.resolvedCache.get(cacheKey) + if (cached && existsSync(cached) && this.isImageFile(cached)) { + const dataUrl = this.fileToDataUrl(cached) + const localPath = dataUrl || this.filePathToUrl(cached) + this.emitCacheResolved(payload, cacheKey, localPath) + return { success: true, localPath } + } + if (cached && !this.isImageFile(cached)) { + this.resolvedCache.delete(cacheKey) + } + } + + const pending = this.pending.get(cacheKey) + if (pending) return pending + + const task = this.decryptImageInternal(payload, cacheKey) + this.pending.set(cacheKey, task) + try { + return await task + } finally { + this.pending.delete(cacheKey) + } + } + + private async decryptImageInternal( + payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }, + cacheKey: string + ): Promise { + try { + const wxid = this.configService.get('myWxid') + const dbPath = this.configService.get('dbPath') + if (!wxid || !dbPath) { + return { success: false, error: '未配置账号或数据库路径' } + } + + const accountDir = this.resolveAccountDir(dbPath, wxid) + if (!accountDir) { + return { success: false, error: '未找到账号目录' } + } + + const datPath = await this.resolveDatPath( + accountDir, + payload.imageMd5, + payload.imageDatName, + payload.sessionId, + { allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) } + ) + + // 如果要求高清图但没找到,直接返回提示 + if (!datPath && payload.force) { + return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' } + } + if (!datPath) { + return { success: false, error: '未找到图片文件' } + } + + if (!extname(datPath).toLowerCase().includes('dat')) { + this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath) + const dataUrl = this.fileToDataUrl(datPath) + const localPath = dataUrl || this.filePathToUrl(datPath) + const isThumb = this.isThumbnailPath(datPath) + this.emitCacheResolved(payload, cacheKey, localPath) + return { success: true, localPath, isThumb } + } + + // 查找已缓存的解密文件 + const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId) + if (existing) { + const isHd = this.isHdPath(existing) + // 如果要求高清但找到的是缩略图,继续解密高清图 + if (!(payload.force && !isHd)) { + this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing) + const dataUrl = this.fileToDataUrl(existing) + const localPath = dataUrl || this.filePathToUrl(existing) + const isThumb = this.isThumbnailPath(existing) + this.emitCacheResolved(payload, cacheKey, localPath) + return { success: true, localPath, isThumb } + } + } + + const xorKeyRaw = this.configService.get('imageXorKey') as unknown + // 支持十六进制格式(如 0x53)和十进制格式 + let xorKey: number + if (typeof xorKeyRaw === 'number') { + xorKey = xorKeyRaw + } else { + const trimmed = String(xorKeyRaw ?? '').trim() + if (trimmed.toLowerCase().startsWith('0x')) { + xorKey = parseInt(trimmed, 16) + } else { + xorKey = parseInt(trimmed, 10) + } + } + if (Number.isNaN(xorKey) || (!xorKey && xorKey !== 0)) { + return { success: false, error: '未配置图片解密密钥' } + } + + const aesKeyRaw = this.configService.get('imageAesKey') + const aesKey = this.resolveAesKey(aesKeyRaw) + + const decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey) + + const ext = this.detectImageExtension(decrypted) || '.jpg' + + const outputPath = this.getCacheOutputPathFromDat(datPath, ext, payload.sessionId) + await writeFile(outputPath, decrypted) + + const isThumb = this.isThumbnailPath(datPath) + this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) + if (!isThumb) { + this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) + } + const dataUrl = this.bufferToDataUrl(decrypted, ext) + const localPath = dataUrl || this.filePathToUrl(outputPath) + this.emitCacheResolved(payload, cacheKey, localPath) + return { success: true, localPath, isThumb } + } catch (e) { + return { success: false, error: String(e) } + } + } + + private resolveAccountDir(dbPath: string, wxid: string): string | null { + const cleanedWxid = this.cleanAccountDirName(wxid) + const normalized = dbPath.replace(/[\\/]+$/, '') + + const direct = join(normalized, cleanedWxid) + if (existsSync(direct)) return direct + + if (this.isAccountDir(normalized)) return normalized + + try { + const entries = readdirSync(normalized) + const lowerWxid = cleanedWxid.toLowerCase() + for (const entry of entries) { + const entryPath = join(normalized, entry) + if (!this.isDirectory(entryPath)) continue + const lowerEntry = entry.toLowerCase() + if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) { + if (this.isAccountDir(entryPath)) return entryPath + } + } + } catch {} + + return null + } + + /** + * 获取解密后的缓存目录(用于查找 hardlink.db) + */ + private getDecryptedCacheDir(wxid: string): string | null { + const cachePath = this.configService.get('cachePath') + if (!cachePath) return null + + const cleanedWxid = this.cleanAccountDirName(wxid) + const cacheAccountDir = join(cachePath, cleanedWxid) + + // 检查缓存目录下是否有 hardlink.db + if (existsSync(join(cacheAccountDir, 'hardlink.db'))) { + return cacheAccountDir + } + if (existsSync(join(cachePath, 'hardlink.db'))) { + return cachePath + } + const cacheHardlinkDir = join(cacheAccountDir, 'db_storage', 'hardlink') + if (existsSync(join(cacheHardlinkDir, 'hardlink.db'))) { + return cacheHardlinkDir + } + return null + } + + private isAccountDir(dirPath: string): boolean { + return ( + existsSync(join(dirPath, 'hardlink.db')) || + existsSync(join(dirPath, 'db_storage')) || + existsSync(join(dirPath, 'FileStorage', 'Image')) || + existsSync(join(dirPath, 'FileStorage', 'Image2')) + ) + } + + private isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory() + } catch { + return false + } + } + + private cleanAccountDirName(dirName: string): string { + const trimmed = dirName.trim() + if (!trimmed) return trimmed + + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + if (match) return match[1] + return trimmed + } + + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + if (suffixMatch) return suffixMatch[1] + + return trimmed + } + + private async resolveDatPath( + accountDir: string, + imageMd5?: string, + imageDatName?: string, + sessionId?: string, + options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean } + ): Promise { + const allowThumbnail = options?.allowThumbnail ?? true + const skipResolvedCache = options?.skipResolvedCache ?? false + this.logInfo('[ImageDecrypt] resolveDatPath', { + accountDir, + imageMd5, + imageDatName, + sessionId, + allowThumbnail, + skipResolvedCache + }) + + // 优先通过 hardlink.db 查询 + if (imageMd5) { + this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId }) + const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId) + if (hardlinkPath) { + const isThumb = this.isThumbnailPath(hardlinkPath) + if (allowThumbnail || !isThumb) { + this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: hardlinkPath }) + this.cacheDatPath(accountDir, imageMd5, hardlinkPath) + if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath) + return hardlinkPath + } + // hardlink 找到的是缩略图,但要求高清图,直接返回 null,不再搜索 + if (!allowThumbnail && isThumb) { + return null + } + } + this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 }) + if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) { + this.logInfo('[ImageDecrypt] hardlink fallback (datName)', { imageDatName, sessionId }) + const fallbackPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId) + if (fallbackPath) { + const isThumb = this.isThumbnailPath(fallbackPath) + if (allowThumbnail || !isThumb) { + this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: fallbackPath }) + this.cacheDatPath(accountDir, imageDatName, fallbackPath) + return fallbackPath + } + if (!allowThumbnail && isThumb) { + return null + } + } + this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) + } + } + + if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) { + this.logInfo('[ImageDecrypt] hardlink lookup (datName)', { imageDatName, sessionId }) + const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId) + if (hardlinkPath) { + const isThumb = this.isThumbnailPath(hardlinkPath) + if (allowThumbnail || !isThumb) { + this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: hardlinkPath }) + this.cacheDatPath(accountDir, imageDatName, hardlinkPath) + return hardlinkPath + } + // hardlink 找到的是缩略图,但要求高清图,直接返回 null + if (!allowThumbnail && isThumb) { + return null + } + } + this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) + } + + // 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢) + if (!allowThumbnail) { + return null + } + + if (!imageDatName) return null + if (!skipResolvedCache) { + const cached = this.resolvedCache.get(imageDatName) + if (cached && existsSync(cached)) { + if (allowThumbnail || !this.isThumbnailPath(cached)) return cached + } + } + + const datPath = await this.searchDatFile(accountDir, imageDatName, allowThumbnail) + if (datPath) { + this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, path: datPath }) + this.resolvedCache.set(imageDatName, datPath) + this.cacheDatPath(accountDir, imageDatName, datPath) + return datPath + } + const normalized = this.normalizeDatBase(imageDatName) + if (normalized !== imageDatName.toLowerCase()) { + const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail) + if (normalizedPath) { + this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, normalized, path: normalizedPath }) + this.resolvedCache.set(imageDatName, normalizedPath) + this.cacheDatPath(accountDir, imageDatName, normalizedPath) + return normalizedPath + } + } + this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, normalized }) + return null + } + + private async resolveThumbnailDatPath( + accountDir: string, + imageMd5?: string, + imageDatName?: string, + sessionId?: string + ): Promise { + if (imageMd5) { + const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId) + if (hardlinkPath && this.isThumbnailPath(hardlinkPath)) return hardlinkPath + } + + if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) { + const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId) + if (hardlinkPath && this.isThumbnailPath(hardlinkPath)) return hardlinkPath + } + + if (!imageDatName) return null + return this.searchDatFile(accountDir, imageDatName, true, true) + } + + private async checkHasUpdate( + payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, + cacheKey: string, + cachedPath: string + ): Promise { + if (!cachedPath || !existsSync(cachedPath)) return false + const isThumbnail = this.isThumbnailPath(cachedPath) + if (!isThumbnail) return false + const wxid = this.configService.get('myWxid') + const dbPath = this.configService.get('dbPath') + if (!wxid || !dbPath) return false + const accountDir = this.resolveAccountDir(dbPath, wxid) + if (!accountDir) return false + + const quickDir = this.getCachedDatDir(accountDir, payload.imageDatName, payload.imageMd5) + if (quickDir) { + const baseName = payload.imageDatName || payload.imageMd5 || cacheKey + const candidate = this.findNonThumbnailVariantInDir(quickDir, baseName) + if (candidate) { + return true + } + } + + const thumbPath = await this.resolveThumbnailDatPath( + accountDir, + payload.imageMd5, + payload.imageDatName, + payload.sessionId + ) + if (thumbPath) { + const baseName = payload.imageDatName || payload.imageMd5 || cacheKey + const candidate = this.findNonThumbnailVariantInDir(dirname(thumbPath), baseName) + if (candidate) { + return true + } + const searchHit = await this.searchDatFileInDir(dirname(thumbPath), baseName, false) + if (searchHit && this.isNonThumbnailVariantDat(searchHit)) { + return true + } + } + return false + } + + private triggerUpdateCheck( + payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, + cacheKey: string, + cachedPath: string + ): void { + if (this.updateFlags.get(cacheKey)) return + void this.checkHasUpdate(payload, cacheKey, cachedPath).then((hasUpdate) => { + if (!hasUpdate) return + this.updateFlags.set(cacheKey, true) + this.emitImageUpdate(payload, cacheKey) + }).catch(() => {}) + } + + private looksLikeMd5(value: string): boolean { + return /^[a-fA-F0-9]{16,32}$/.test(value) + } + + private resolveHardlinkDbPath(accountDir: string): string | null { + const wxid = this.configService.get('myWxid') + const cacheDir = wxid ? this.getDecryptedCacheDir(wxid) : null + const candidates = [ + join(accountDir, 'db_storage', 'hardlink', 'hardlink.db'), + join(accountDir, 'hardlink.db'), + cacheDir ? join(cacheDir, 'hardlink.db') : null + ].filter(Boolean) as string[] + this.logInfo('[ImageDecrypt] hardlink db probe', { accountDir, cacheDir, candidates }) + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate + } + this.logInfo('[ImageDecrypt] hardlink db missing', { accountDir, cacheDir, candidates }) + return null + } + + private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise { + try { + const hardlinkPath = this.resolveHardlinkDbPath(accountDir) + if (!hardlinkPath) { + return null + } + + const ready = await this.ensureWcdbReady() + if (!ready) { + this.logInfo('[ImageDecrypt] hardlink db not ready') + return null + } + + const state = await this.getHardlinkState(accountDir, hardlinkPath) + if (!state.imageTable) { + this.logInfo('[ImageDecrypt] hardlink table missing', { hardlinkPath }) + return null + } + + const escapedMd5 = this.escapeSqlString(md5) + const rowResult = await wcdbService.execQuery( + 'media', + hardlinkPath, + `SELECT dir1, dir2, file_name FROM ${state.imageTable} WHERE lower(md5) = lower('${escapedMd5}') LIMIT 1` + ) + const row = rowResult.success && rowResult.rows ? rowResult.rows[0] : null + + if (!row) { + this.logInfo('[ImageDecrypt] hardlink row miss', { md5, table: state.imageTable }) + return null + } + + const dir1 = this.getRowValue(row, 'dir1') + const dir2 = this.getRowValue(row, 'dir2') + const fileName = this.getRowValue(row, 'file_name') ?? this.getRowValue(row, 'fileName') + if (dir1 === undefined || dir2 === undefined || !fileName) { + this.logInfo('[ImageDecrypt] hardlink row incomplete', { row }) + return null + } + + const lowerFileName = fileName.toLowerCase() + if (lowerFileName.endsWith('.dat')) { + const baseLower = lowerFileName.slice(0, -4) + if (!this.isLikelyImageDatBase(baseLower) && !this.looksLikeMd5(baseLower)) { + this.logInfo('[ImageDecrypt] hardlink fileName rejected', { fileName }) + return null + } + } + + // dir1 和 dir2 是 rowid,需要从 dir2id 表查询对应的目录名 + let dir1Name: string | null = null + let dir2Name: string | null = null + + if (state.dirTable) { + try { + // 通过 rowid 查询目录名 + const dir1Result = await wcdbService.execQuery( + 'media', + hardlinkPath, + `SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir1)} LIMIT 1` + ) + if (dir1Result.success && dir1Result.rows && dir1Result.rows.length > 0) { + const value = this.getRowValue(dir1Result.rows[0], 'username') + if (value) dir1Name = String(value) + } + + const dir2Result = await wcdbService.execQuery( + 'media', + hardlinkPath, + `SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir2)} LIMIT 1` + ) + if (dir2Result.success && dir2Result.rows && dir2Result.rows.length > 0) { + const value = this.getRowValue(dir2Result.rows[0], 'username') + if (value) dir2Name = String(value) + } + } catch { + // ignore + } + } + + if (!dir1Name || !dir2Name) { + this.logInfo('[ImageDecrypt] hardlink dir resolve miss', { dir1, dir2, dir1Name, dir2Name }) + return null + } + + // 构建路径: msg/attach/{dir1Name}/{dir2Name}/Img/{fileName} + const possiblePaths = [ + join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'Img', fileName), + join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'mg', fileName), + join(accountDir, 'msg', 'attach', dir1Name, dir2Name, fileName), + ] + + for (const fullPath of possiblePaths) { + if (existsSync(fullPath)) { + this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath }) + return fullPath + } + } + + this.logInfo('[ImageDecrypt] hardlink path miss', { possiblePaths }) + return null + } catch { + // ignore + } + return null + } + + private async getHardlinkState(accountDir: string, hardlinkPath: string): Promise { + const cached = this.hardlinkCache.get(hardlinkPath) + if (cached) return cached + + const imageResult = await wcdbService.execQuery( + 'media', + hardlinkPath, + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'image_hardlink_info%' ORDER BY name DESC LIMIT 1" + ) + const dirResult = await wcdbService.execQuery( + 'media', + hardlinkPath, + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1" + ) + const imageTable = imageResult.success && imageResult.rows && imageResult.rows.length > 0 + ? this.getRowValue(imageResult.rows[0], 'name') + : undefined + const dirTable = dirResult.success && dirResult.rows && dirResult.rows.length > 0 + ? this.getRowValue(dirResult.rows[0], 'name') + : undefined + const state: HardlinkState = { + imageTable: imageTable ? String(imageTable) : undefined, + dirTable: dirTable ? String(dirTable) : undefined + } + this.logInfo('[ImageDecrypt] hardlink state', { hardlinkPath, imageTable: state.imageTable, dirTable: state.dirTable }) + this.hardlinkCache.set(hardlinkPath, state) + return state + } + + private async ensureWcdbReady(): Promise { + if (wcdbService.isReady()) return true + const dbPath = this.configService.get('dbPath') + const decryptKey = this.configService.get('decryptKey') + const wxid = this.configService.get('myWxid') + if (!dbPath || !decryptKey || !wxid) return false + const cleanedWxid = this.cleanAccountDirName(wxid) + return await wcdbService.open(dbPath, decryptKey, cleanedWxid) + } + + private getRowValue(row: any, column: string): any { + if (!row) return undefined + if (Object.prototype.hasOwnProperty.call(row, column)) return row[column] + const target = column.toLowerCase() + for (const key of Object.keys(row)) { + if (key.toLowerCase() === target) return row[key] + } + return undefined + } + + private escapeSqlString(value: string): string { + return value.replace(/'/g, "''") + } + + private async searchDatFile( + accountDir: string, + datName: string, + allowThumbnail = true, + thumbOnly = false + ): Promise { + const key = `${accountDir}|${datName}` + const cached = this.resolvedCache.get(key) + if (cached && existsSync(cached)) { + if (allowThumbnail || !this.isThumbnailPath(cached)) return cached + } + + const root = join(accountDir, 'msg', 'attach') + if (!existsSync(root)) return null + const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly) + if (found) { + this.resolvedCache.set(key, found) + return found + } + return null + } + + private async searchDatFileInDir( + dirPath: string, + datName: string, + allowThumbnail = true + ): Promise { + if (!existsSync(dirPath)) return null + return await this.walkForDatInWorker(dirPath, datName.toLowerCase(), 3, allowThumbnail, false) + } + + private async walkForDatInWorker( + root: string, + datName: string, + maxDepth = 4, + allowThumbnail = true, + thumbOnly = false + ): Promise { + const workerPath = join(__dirname, 'imageSearchWorker.js') + return await new Promise((resolve) => { + const worker = new Worker(workerPath, { + workerData: { root, datName, maxDepth, allowThumbnail, thumbOnly } + }) + + const cleanup = () => { + worker.removeAllListeners() + } + + worker.on('message', (msg: any) => { + if (msg && msg.type === 'done') { + cleanup() + void worker.terminate() + resolve(msg.path || null) + return + } + if (msg && msg.type === 'error') { + cleanup() + void worker.terminate() + resolve(null) + } + }) + + worker.on('error', () => { + cleanup() + void worker.terminate() + resolve(null) + }) + }) + } + + private matchesDatName(fileName: string, datName: string): boolean { + const lower = fileName.toLowerCase() + const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower + const normalizedBase = this.normalizeDatBase(base) + const normalizedTarget = this.normalizeDatBase(datName.toLowerCase()) + if (normalizedBase === normalizedTarget) return true + const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`, 'i') + if (pattern.test(lower)) return true + return lower.endsWith('.dat') && lower.includes(datName) + } + + private scoreDatName(fileName: string): number { + if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1 + if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1 + return 2 + } + + private isThumbnailDat(fileName: string): boolean { + return fileName.includes('.t.dat') || fileName.includes('_t.dat') + } + + private hasXVariant(baseLower: string): boolean { + return /[._][a-z]$/.test(baseLower) + } + + private isThumbnailPath(filePath: string): boolean { + const lower = basename(filePath).toLowerCase() + if (this.isThumbnailDat(lower)) return true + const ext = extname(lower) + const base = ext ? lower.slice(0, -ext.length) : lower + // 支持新命名 _thumb 和旧命名 _t + return base.endsWith('_t') || base.endsWith('_thumb') + } + + private isHdPath(filePath: string): boolean { + const lower = basename(filePath).toLowerCase() + const ext = extname(lower) + const base = ext ? lower.slice(0, -ext.length) : lower + return base.endsWith('_hd') || base.endsWith('_h') + } + + private hasImageVariantSuffix(baseLower: string): boolean { + return /[._][a-z]$/.test(baseLower) + } + + private isLikelyImageDatBase(baseLower: string): boolean { + return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower) + } + + private normalizeDatBase(name: string): string { + let base = name.toLowerCase() + if (base.endsWith('.dat') || base.endsWith('.jpg')) { + base = base.slice(0, -4) + } + while (/[._][a-z]$/.test(base)) { + base = base.slice(0, -2) + } + return base + } + + private sanitizeDirName(name: string): string { + const trimmed = name.trim() + if (!trimmed) return 'unknown' + return trimmed.replace(/[<>:"/\\|?*]/g, '_') + } + + private resolveTimeDir(datPath: string): string { + const parts = datPath.split(/[\\/]+/) + for (const part of parts) { + if (/^\d{4}-\d{2}$/.test(part)) return part + } + try { + const stat = statSync(datPath) + const year = stat.mtime.getFullYear() + const month = String(stat.mtime.getMonth() + 1).padStart(2, '0') + return `${year}-${month}` + } catch { + return 'unknown-time' + } + } + + private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null { + const root = this.getCacheRoot() + const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase()) + const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] + + if (sessionId) { + const sessionDir = join(root, this.sanitizeDirName(sessionId)) + if (existsSync(sessionDir)) { + try { + const sessionEntries = readdirSync(sessionDir) + for (const entry of sessionEntries) { + const timeDir = join(sessionDir, entry) + if (!this.isDirectory(timeDir)) continue + const hit = this.findCachedOutputInDir(timeDir, normalizedKey, extensions, preferHd) + if (hit) return hit + } + } catch { + // ignore + } + } + } + + // 新目录结构: Images/{normalizedKey}/{normalizedKey}_thumb.jpg 或 _hd.jpg + const imageDir = join(root, normalizedKey) + if (existsSync(imageDir)) { + const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd) + if (hit) return hit + } + + // 兼容旧的平铺结构 + for (const ext of extensions) { + const candidate = join(root, `${cacheKey}${ext}`) + if (existsSync(candidate)) return candidate + } + for (const ext of extensions) { + const candidate = join(root, `${cacheKey}_t${ext}`) + if (existsSync(candidate)) return candidate + } + + return null + } + + private findCachedOutputInDir( + dirPath: string, + normalizedKey: string, + extensions: string[], + preferHd: boolean + ): string | null { + for (const ext of extensions) { + if (preferHd) { + const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`) + if (existsSync(hdPath)) return hdPath + } + const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`) + if (existsSync(thumbPath)) return thumbPath + if (!preferHd) { + const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`) + if (existsSync(hdPath)) return hdPath + } + } + return null + } + + private getCacheOutputPathFromDat(datPath: string, ext: string, sessionId?: string): string { + const name = basename(datPath) + const lower = name.toLowerCase() + const base = lower.endsWith('.dat') ? name.slice(0, -4) : name + + // 提取基础名称(去掉 _t, _h 等后缀) + const normalizedBase = this.normalizeDatBase(base) + + // 判断是缩略图还是高清图 + const isThumb = this.isThumbnailDat(lower) + const suffix = isThumb ? '_thumb' : '_hd' + + const contactDir = this.sanitizeDirName(sessionId || 'unknown') + const timeDir = this.resolveTimeDir(datPath) + const outputDir = join(this.getCacheRoot(), contactDir, timeDir) + if (!existsSync(outputDir)) { + mkdirSync(outputDir, { recursive: true }) + } + + return join(outputDir, `${normalizedBase}${suffix}${ext}`) + } + + private cacheResolvedPaths(cacheKey: string, imageMd5: string | undefined, imageDatName: string | undefined, outputPath: string): void { + this.resolvedCache.set(cacheKey, outputPath) + if (imageMd5 && imageMd5 !== cacheKey) { + this.resolvedCache.set(imageMd5, outputPath) + } + if (imageDatName && imageDatName !== cacheKey && imageDatName !== imageMd5) { + this.resolvedCache.set(imageDatName, outputPath) + } + } + + private getCacheKeys(payload: { imageMd5?: string; imageDatName?: string }): string[] { + const keys: string[] = [] + const addKey = (value?: string) => { + if (!value) return + const lower = value.toLowerCase() + if (!keys.includes(value)) keys.push(value) + if (!keys.includes(lower)) keys.push(lower) + const normalized = this.normalizeDatBase(lower) + if (normalized && !keys.includes(normalized)) keys.push(normalized) + } + addKey(payload.imageMd5) + if (payload.imageDatName && payload.imageDatName !== payload.imageMd5) { + addKey(payload.imageDatName) + } + return keys + } + + private cacheDatPath(accountDir: string, datName: string, datPath: string): void { + const key = `${accountDir}|${datName}` + this.resolvedCache.set(key, datPath) + const normalized = this.normalizeDatBase(datName) + if (normalized && normalized !== datName.toLowerCase()) { + this.resolvedCache.set(`${accountDir}|${normalized}`, datPath) + } + } + + private clearUpdateFlags(cacheKey: string, imageMd5?: string, imageDatName?: string): void { + this.updateFlags.delete(cacheKey) + if (imageMd5) this.updateFlags.delete(imageMd5) + if (imageDatName) this.updateFlags.delete(imageDatName) + } + + private getCachedDatDir(accountDir: string, imageDatName?: string, imageMd5?: string): string | null { + const keys = [ + imageDatName ? `${accountDir}|${imageDatName}` : null, + imageDatName ? `${accountDir}|${this.normalizeDatBase(imageDatName)}` : null, + imageMd5 ? `${accountDir}|${imageMd5}` : null + ].filter(Boolean) as string[] + for (const key of keys) { + const cached = this.resolvedCache.get(key) + if (cached && existsSync(cached)) return dirname(cached) + } + return null + } + + private findNonThumbnailVariantInDir(dirPath: string, baseName: string): string | null { + let entries: string[] + try { + entries = readdirSync(dirPath) + } catch { + return null + } + const target = this.normalizeDatBase(baseName.toLowerCase()) + for (const entry of entries) { + const lower = entry.toLowerCase() + if (!lower.endsWith('.dat')) continue + if (this.isThumbnailDat(lower)) continue + if (!this.hasXVariant(lower.slice(0, -4))) continue + const baseLower = lower.slice(0, -4) + if (this.normalizeDatBase(baseLower) !== target) continue + return join(dirPath, entry) + } + return null + } + + private isNonThumbnailVariantDat(datPath: string): boolean { + const lower = basename(datPath).toLowerCase() + if (!lower.endsWith('.dat')) return false + if (this.isThumbnailDat(lower)) return false + const baseLower = lower.slice(0, -4) + return this.hasXVariant(baseLower) + } + + private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string): void { + const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName } + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) { + win.webContents.send('image:updateAvailable', message) + } + } + } + + private emitCacheResolved(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string, localPath: string): void { + const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName, localPath } + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) { + win.webContents.send('image:cacheResolved', message) + } + } + } + + private async ensureCacheIndexed(): Promise { + if (this.cacheIndexed) return + if (this.cacheIndexing) return this.cacheIndexing + this.cacheIndexing = new Promise((resolve) => { + const root = this.getCacheRoot() + try { + this.indexCacheDir(root, 2, 0) + } catch { + this.cacheIndexed = true + this.cacheIndexing = null + resolve() + return + } + this.cacheIndexed = true + this.cacheIndexing = null + resolve() + }) + return this.cacheIndexing + } + + private indexCacheDir(root: string, maxDepth: number, depth: number): void { + let entries: string[] + try { + entries = readdirSync(root) + } catch { + return + } + const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] + for (const entry of entries) { + const fullPath = join(root, entry) + let stat: ReturnType + try { + stat = statSync(fullPath) + } catch { + continue + } + if (stat.isDirectory()) { + if (depth < maxDepth) { + this.indexCacheDir(fullPath, maxDepth, depth + 1) + } + continue + } + if (!stat.isFile()) continue + const lower = entry.toLowerCase() + const ext = extensions.find((item) => lower.endsWith(item)) + if (!ext) continue + const base = entry.slice(0, -ext.length) + this.addCacheIndex(base, fullPath) + const normalized = this.normalizeDatBase(base) + if (normalized && normalized !== base.toLowerCase()) { + this.addCacheIndex(normalized, fullPath) + } + } + } + + private addCacheIndex(key: string, path: string): void { + const normalizedKey = key.toLowerCase() + const existing = this.resolvedCache.get(normalizedKey) + if (existing) { + const existingIsThumb = this.isThumbnailPath(existing) + const candidateIsThumb = this.isThumbnailPath(path) + if (!existingIsThumb && candidateIsThumb) return + } + this.resolvedCache.set(normalizedKey, path) + } + + private getCacheRoot(): string { + const configured = this.configService.get('cachePath') + const root = configured + ? join(configured, 'Images') + : join(app.getPath('documents'), 'WeFlow', 'Images') + if (!existsSync(root)) { + mkdirSync(root, { recursive: true }) + } + return root + } + + private resolveAesKey(aesKeyRaw: string): Buffer | null { + const trimmed = aesKeyRaw?.trim() ?? '' + if (!trimmed) return null + return this.asciiKey16(trimmed) + } + + private async decryptDatAuto(datPath: string, xorKey: number, aesKey: Buffer | null): Promise { + const version = this.getDatVersion(datPath) + + if (version === 0) { + return this.decryptDatV3(datPath, xorKey) + } + if (version === 1) { + const key = this.asciiKey16(this.defaultV1AesKey) + return this.decryptDatV4(datPath, xorKey, key) + } + // version === 2 + if (!aesKey || aesKey.length !== 16) { + throw new Error('请到设置配置图片解密密钥') + } + return this.decryptDatV4(datPath, xorKey, aesKey) + } + + private getDatVersion(inputPath: string): number { + if (!existsSync(inputPath)) { + throw new Error('文件不存在') + } + const bytes = readFileSync(inputPath) + if (bytes.length < 6) { + return 0 + } + const signature = bytes.subarray(0, 6) + if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]))) { + return 1 + } + if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]))) { + return 2 + } + return 0 + } + + private decryptDatV3(inputPath: string, xorKey: number): Buffer { + const data = readFileSync(inputPath) + const out = Buffer.alloc(data.length) + for (let i = 0; i < data.length; i += 1) { + out[i] = data[i] ^ xorKey + } + return out + } + + private decryptDatV4(inputPath: string, xorKey: number, aesKey: Buffer): Buffer { + const bytes = readFileSync(inputPath) + if (bytes.length < 0x0f) { + throw new Error('文件太小,无法解析') + } + + const header = bytes.subarray(0, 0x0f) + const data = bytes.subarray(0x0f) + const aesSize = this.bytesToInt32(header.subarray(6, 10)) + const xorSize = this.bytesToInt32(header.subarray(10, 14)) + + // AES 数据需要对齐到 16 字节(PKCS7 填充) + // 当 aesSize % 16 === 0 时,仍需要额外 16 字节的填充 + const remainder = ((aesSize % 16) + 16) % 16 + const alignedAesSize = aesSize + (16 - remainder) + + if (alignedAesSize > data.length) { + throw new Error('文件格式异常:AES 数据长度超过文件实际长度') + } + + const aesData = data.subarray(0, alignedAesSize) + let unpadded: Buffer = Buffer.alloc(0) + if (aesData.length > 0) { + const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null) + decipher.setAutoPadding(false) + const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()]) + + // 使用 PKCS7 填充移除 + unpadded = this.strictRemovePadding(decrypted) + } + + const remaining = data.subarray(alignedAesSize) + if (xorSize < 0 || xorSize > remaining.length) { + throw new Error('文件格式异常:XOR 数据长度不合法') + } + + let rawData = Buffer.alloc(0) + let xoredData = Buffer.alloc(0) + if (xorSize > 0) { + const rawLength = remaining.length - xorSize + if (rawLength < 0) { + throw new Error('文件格式异常:原始数据长度小于XOR长度') + } + rawData = remaining.subarray(0, rawLength) + const xorData = remaining.subarray(rawLength) + xoredData = Buffer.alloc(xorData.length) + for (let i = 0; i < xorData.length; i += 1) { + xoredData[i] = xorData[i] ^ xorKey + } + } else { + rawData = remaining + xoredData = Buffer.alloc(0) + } + + return Buffer.concat([unpadded, rawData, xoredData]) + } + + private bytesToInt32(bytes: Buffer): number { + if (bytes.length !== 4) { + throw new Error('需要4个字节') + } + return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24) + } + + asciiKey16(keyString: string): Buffer { + if (keyString.length < 16) { + throw new Error('AES密钥至少需要16个字符') + } + return Buffer.from(keyString, 'ascii').subarray(0, 16) + } + + private strictRemovePadding(data: Buffer): Buffer { + if (!data.length) { + throw new Error('解密结果为空,填充非法') + } + const paddingLength = data[data.length - 1] + if (paddingLength === 0 || paddingLength > 16 || paddingLength > data.length) { + throw new Error('PKCS7 填充长度非法') + } + for (let i = data.length - paddingLength; i < data.length; i += 1) { + if (data[i] !== paddingLength) { + throw new Error('PKCS7 填充内容非法') + } + } + return data.subarray(0, data.length - paddingLength) + } + + private detectImageExtension(buffer: Buffer): string | null { + if (buffer.length < 12) return null + if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) return '.gif' + if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return '.png' + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg' + if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 && + buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) { + return '.webp' + } + return null + } + + private bufferToDataUrl(buffer: Buffer, ext: string): string | null { + const mimeType = this.mimeFromExtension(ext) + if (!mimeType) return null + return `data:${mimeType};base64,${buffer.toString('base64')}` + } + + private fileToDataUrl(filePath: string): string | null { + try { + const ext = extname(filePath).toLowerCase() + const mimeType = this.mimeFromExtension(ext) + if (!mimeType) return null + const data = readFileSync(filePath) + return `data:${mimeType};base64,${data.toString('base64')}` + } catch { + return null + } + } + + private mimeFromExtension(ext: string): string | null { + switch (ext.toLowerCase()) { + case '.gif': + return 'image/gif' + case '.png': + return 'image/png' + case '.jpg': + case '.jpeg': + return 'image/jpeg' + case '.webp': + return 'image/webp' + default: + return null + } + } + + private filePathToUrl(filePath: string): string { + const url = pathToFileURL(filePath).toString() + try { + const mtime = statSync(filePath).mtimeMs + return `${url}?v=${Math.floor(mtime)}` + } catch { + return url + } + } + + private isImageFile(filePath: string): boolean { + const ext = extname(filePath).toLowerCase() + return ext === '.gif' || ext === '.png' || ext === '.jpg' || ext === '.jpeg' || ext === '.webp' + } + + private compareBytes(a: Buffer, b: Buffer): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false + } + return true + } + + // 保留原有的批量检测 XOR 密钥方法(用于兼容) + async batchDetectXorKey(dirPath: string, maxFiles: number = 100): Promise { + const keyCount: Map = new Map() + let filesChecked = 0 + + const V1_SIGNATURE = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]) + const V2_SIGNATURE = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) + const IMAGE_SIGNATURES: { [key: string]: Buffer } = { + jpg: Buffer.from([0xFF, 0xD8, 0xFF]), + png: Buffer.from([0x89, 0x50, 0x4E, 0x47]), + gif: Buffer.from([0x47, 0x49, 0x46, 0x38]), + bmp: Buffer.from([0x42, 0x4D]), + webp: Buffer.from([0x52, 0x49, 0x46, 0x46]) + } + + const detectXorKeyFromV3 = (header: Buffer): number | null => { + for (const [, signature] of Object.entries(IMAGE_SIGNATURES)) { + const xorKey = header[0] ^ signature[0] + let valid = true + for (let i = 0; i < signature.length && i < header.length; i++) { + if ((header[i] ^ xorKey) !== signature[i]) { + valid = false + break + } + } + if (valid) return xorKey + } + return null + } + + const scanDir = (dir: string) => { + if (filesChecked >= maxFiles) return + try { + const entries = readdirSync(dir, { withFileTypes: true }) + for (const entry of entries) { + if (filesChecked >= maxFiles) return + const fullPath = join(dir, entry.name) + if (entry.isDirectory()) { + scanDir(fullPath) + } else if (entry.name.endsWith('.dat')) { + try { + const header = Buffer.alloc(16) + const fd = require('fs').openSync(fullPath, 'r') + require('fs').readSync(fd, header, 0, 16, 0) + require('fs').closeSync(fd) + + if (header.subarray(0, 6).equals(V1_SIGNATURE) || header.subarray(0, 6).equals(V2_SIGNATURE)) { + continue + } + + const key = detectXorKeyFromV3(header) + if (key !== null) { + keyCount.set(key, (keyCount.get(key) || 0) + 1) + filesChecked++ + } + } catch {} + } + } + } catch {} + } + + scanDir(dirPath) + + if (keyCount.size === 0) return null + + let maxCount = 0 + let mostCommonKey: number | null = null + keyCount.forEach((count, key) => { + if (count > maxCount) { + maxCount = count + mostCommonKey = key + } + }) + + return mostCommonKey + } + + // 保留原有的解密到文件方法(用于兼容) + async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise { + const version = this.getDatVersion(inputPath) + let decrypted: Buffer + + if (version === 0) { + decrypted = this.decryptDatV3(inputPath, xorKey) + } else if (version === 1) { + const key = this.asciiKey16(this.defaultV1AesKey) + decrypted = this.decryptDatV4(inputPath, xorKey, key) + } else { + if (!aesKey || aesKey.length !== 16) { + throw new Error('V4版本需要16字节AES密钥') + } + decrypted = this.decryptDatV4(inputPath, xorKey, aesKey) + } + + const outputDir = dirname(outputPath) + if (!existsSync(outputDir)) { + mkdirSync(outputDir, { recursive: true }) + } + + await writeFile(outputPath, decrypted) + } +} + +export const imageDecryptService = new ImageDecryptService() diff --git a/electron/services/imagePreloadService.ts b/electron/services/imagePreloadService.ts new file mode 100644 index 0000000..ccedd8c --- /dev/null +++ b/electron/services/imagePreloadService.ts @@ -0,0 +1,66 @@ +import { imageDecryptService } from './imageDecryptService' + +type PreloadImagePayload = { + sessionId?: string + imageMd5?: string + imageDatName?: string +} + +type PreloadTask = PreloadImagePayload & { + key: string +} + +export class ImagePreloadService { + private queue: PreloadTask[] = [] + private pending = new Set() + private active = 0 + private readonly maxConcurrent = 2 + + enqueue(payloads: PreloadImagePayload[]): void { + if (!Array.isArray(payloads) || payloads.length === 0) return + for (const payload of payloads) { + const cacheKey = payload.imageMd5 || payload.imageDatName + if (!cacheKey) continue + const key = `${payload.sessionId || 'unknown'}|${cacheKey}` + if (this.pending.has(key)) continue + this.pending.add(key) + this.queue.push({ ...payload, key }) + } + this.processQueue() + } + + private processQueue(): void { + while (this.active < this.maxConcurrent && this.queue.length > 0) { + const task = this.queue.shift() + if (!task) return + this.active += 1 + void this.handleTask(task).finally(() => { + this.active -= 1 + this.pending.delete(task.key) + this.processQueue() + }) + } + } + + private async handleTask(task: PreloadTask): Promise { + const cacheKey = task.imageMd5 || task.imageDatName + if (!cacheKey) return + try { + const cached = await imageDecryptService.resolveCachedImage({ + sessionId: task.sessionId, + imageMd5: task.imageMd5, + imageDatName: task.imageDatName + }) + if (cached.success) return + await imageDecryptService.decryptImage({ + sessionId: task.sessionId, + imageMd5: task.imageMd5, + imageDatName: task.imageDatName + }) + } catch { + // ignore preload failures + } + } +} + +export const imagePreloadService = new ImagePreloadService() diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts new file mode 100644 index 0000000..971a79f --- /dev/null +++ b/electron/services/keyService.ts @@ -0,0 +1,928 @@ +import { app } from 'electron' +import { join, dirname, basename } from 'path' +import { existsSync, readdirSync, readFileSync, statSync } from 'fs' +import { execFile, spawn } from 'child_process' +import { promisify } from 'util' +import crypto from 'crypto' + +const execFileAsync = promisify(execFile) + +type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } +type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string } + +export class KeyService { + private koffi: any = null + private lib: any = null + private initialized = false + private initHook: any = null + private pollKeyData: any = null + private getStatusMessage: any = null + private cleanupHook: any = null + private getLastErrorMsg: any = null + + // Win32 APIs + private kernel32: any = null + private user32: any = null + private advapi32: any = null + + // Kernel32 + private OpenProcess: any = null + private CloseHandle: any = null + private VirtualQueryEx: any = null + private ReadProcessMemory: any = null + private MEMORY_BASIC_INFORMATION: any = null + private TerminateProcess: any = null + + // User32 + private EnumWindows: any = null + private GetWindowTextW: any = null + private GetWindowTextLengthW: any = null + private GetClassNameW: any = null + private GetWindowThreadProcessId: any = null + private IsWindowVisible: any = null + private EnumChildWindows: any = null + private WNDENUMPROC_PTR: any = null + + // Advapi32 + private RegOpenKeyExW: any = null + private RegQueryValueExW: any = null + private RegCloseKey: any = null + + // Constants + private readonly PROCESS_ALL_ACCESS = 0x1F0FFF + private readonly PROCESS_TERMINATE = 0x0001 + private readonly KEY_READ = 0x20019 + private readonly HKEY_LOCAL_MACHINE = 0x80000002 + private readonly HKEY_CURRENT_USER = 0x80000001 + private readonly ERROR_SUCCESS = 0 + + private getDllPath(): string { + const resourcesPath = app.isPackaged + ? join(process.resourcesPath, 'resources') + : join(app.getAppPath(), 'resources') + return join(resourcesPath, 'wx_key.dll') + } + + private ensureLoaded(): boolean { + if (this.initialized) return true + try { + this.koffi = require('koffi') + const dllPath = this.getDllPath() + if (!existsSync(dllPath)) return false + + this.lib = this.koffi.load(dllPath) + this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)') + this.pollKeyData = this.lib.func('bool PollKeyData(_Out_ char *keyBuffer, int bufferSize)') + this.getStatusMessage = this.lib.func('bool GetStatusMessage(_Out_ char *msgBuffer, int bufferSize, _Out_ int *outLevel)') + this.cleanupHook = this.lib.func('bool CleanupHook()') + this.getLastErrorMsg = this.lib.func('const char* GetLastErrorMsg()') + + this.initialized = true + return true + } catch (e) { + console.error('加载 wx_key.dll 失败:', e) + return false + } + } + + private ensureWin32(): boolean { + return process.platform === 'win32' + } + + private ensureKernel32(): boolean { + if (this.kernel32) return true + try { + this.koffi = require('koffi') + this.kernel32 = this.koffi.load('kernel32.dll') + + const HANDLE = this.koffi.pointer('HANDLE', this.koffi.opaque()) + this.MEMORY_BASIC_INFORMATION = this.koffi.struct('MEMORY_BASIC_INFORMATION', { + BaseAddress: 'uint64', + AllocationBase: 'uint64', + AllocationProtect: 'uint32', + RegionSize: 'uint64', + State: 'uint32', + Protect: 'uint32', + Type: 'uint32' + }) + + // Use explicit definitions to avoid parser issues + this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32']) + this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE']) + this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32']) + this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64']) + this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))]) + + return true + } catch (e) { + console.error('初始化 kernel32 失败:', e) + return false + } + } + + private decodeUtf8(buf: Buffer): string { + const nullIdx = buf.indexOf(0) + return buf.toString('utf8', 0, nullIdx > -1 ? nullIdx : undefined).trim() + } + + private ensureUser32(): boolean { + if (this.user32) return true + try { + this.koffi = require('koffi') + this.user32 = this.koffi.load('user32.dll') + + // Callbacks + // Define the prototype and its pointer type + const WNDENUMPROC = this.koffi.proto('bool __stdcall (void *hWnd, intptr_t lParam)') + this.WNDENUMPROC_PTR = this.koffi.pointer(WNDENUMPROC) + + this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t']) + this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t']) + + this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int']) + this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*']) + this.GetClassNameW = this.user32.func('GetClassNameW', 'int', ['void*', this.koffi.out('uint16*'), 'int']) + this.GetWindowThreadProcessId = this.user32.func('GetWindowThreadProcessId', 'uint32', ['void*', this.koffi.out('uint32*')]) + this.IsWindowVisible = this.user32.func('IsWindowVisible', 'bool', ['void*']) + + return true + } catch (e) { + console.error('初始化 user32 失败:', e) + return false + } + } + + private ensureAdvapi32(): boolean { + if (this.advapi32) return true + try { + this.koffi = require('koffi') + this.advapi32 = this.koffi.load('advapi32.dll') + + // Types + // Use intptr_t for HKEY to match system architecture (64-bit safe) + const HKEY = this.koffi.alias('HKEY', 'intptr_t') + const HKEY_PTR = this.koffi.pointer(HKEY) + + this.RegOpenKeyExW = this.advapi32.func('RegOpenKeyExW', 'long', [HKEY, 'uint16*', 'uint32', 'uint32', this.koffi.out(HKEY_PTR)]) + this.RegQueryValueExW = this.advapi32.func('RegQueryValueExW', 'long', [HKEY, 'uint16*', 'uint32*', this.koffi.out('uint32*'), this.koffi.out('uint8*'), this.koffi.out('uint32*')]) + this.RegCloseKey = this.advapi32.func('RegCloseKey', 'long', [HKEY]) + + return true + } catch (e) { + console.error('初始化 advapi32 失败:', e) + return false + } + } + + private decodeCString(ptr: any): string { + try { + if (typeof ptr === 'string') return ptr + return this.koffi.decode(ptr, 'char', -1) + } catch { + return '' + } + } + + // --- WeChat Process & Path Finding --- + + // Helper to read simple registry string + private readRegistryString(rootKey: number, subKey: string, valueName: string): string | null { + if (!this.ensureAdvapi32()) return null + + // Convert strings to UTF-16 buffers + const subKeyBuf = Buffer.from(subKey + '\0', 'ucs2') + const valueNameBuf = valueName ? Buffer.from(valueName + '\0', 'ucs2') : null + + const phkResult = Buffer.alloc(8) // Pointer size (64-bit safe) + + if (this.RegOpenKeyExW(rootKey, subKeyBuf, 0, this.KEY_READ, phkResult) !== this.ERROR_SUCCESS) { + return null + } + + const hKey = this.koffi.decode(phkResult, 'uintptr_t') + + try { + const lpcbData = Buffer.alloc(4) + lpcbData.writeUInt32LE(0, 0) // First call to get size? No, RegQueryValueExW expects initialized size or null to get size. + // Usually we call it twice or just provide a big buffer. + // Let's call twice. + + let ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, null, lpcbData) + if (ret !== this.ERROR_SUCCESS) return null + + const size = lpcbData.readUInt32LE(0) + if (size === 0) return null + + const dataBuf = Buffer.alloc(size) + ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, dataBuf, lpcbData) + if (ret !== this.ERROR_SUCCESS) return null + + // Read UTF-16 string (remove null terminator) + let str = dataBuf.toString('ucs2') + if (str.endsWith('\0')) str = str.slice(0, -1) + return str + } finally { + this.RegCloseKey(hKey) + } + } + + private async findWeChatInstallPath(): Promise { + // 1. Registry - Uninstall Keys + const uninstallKeys = [ + 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall', + 'SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall' + ] + const roots = [this.HKEY_LOCAL_MACHINE, this.HKEY_CURRENT_USER] + + // NOTE: Scanning subkeys in registry via Koffi is tedious (RegEnumKeyEx). + // Simplified strategy: Check common known registry keys first, then fallback to common paths. + // wx_key searches *all* subkeys of Uninstall, which is robust but complex to port quickly. + // Let's rely on specific Tencent keys first. + + // 2. Tencent specific keys + const tencentKeys = [ + 'Software\\Tencent\\WeChat', + 'Software\\WOW6432Node\\Tencent\\WeChat', + 'Software\\Tencent\\Weixin', + ] + + for (const root of roots) { + for (const key of tencentKeys) { + const path = this.readRegistryString(root, key, 'InstallPath') + if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe') + if (path && existsSync(join(path, 'WeChat.exe'))) return join(path, 'WeChat.exe') + } + } + + // 3. Uninstall key exact match (sometimes works) + for (const root of roots) { + for (const parent of uninstallKeys) { + // Try WeChat specific subkey + const path = this.readRegistryString(root, parent + '\\WeChat', 'InstallLocation') + if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe') + } + } + + // 4. Common Paths + const drives = ['C', 'D', 'E', 'F'] + const commonPaths = [ + 'Program Files\\Tencent\\WeChat\\WeChat.exe', + 'Program Files (x86)\\Tencent\\WeChat\\WeChat.exe', + 'Program Files\\Tencent\\Weixin\\Weixin.exe', + 'Program Files (x86)\\Tencent\\Weixin\\Weixin.exe' + ] + + for (const drive of drives) { + for (const p of commonPaths) { + const full = join(drive + ':\\', p) + if (existsSync(full)) return full + } + } + + return null + } + + private async findPidByImageName(imageName: string): Promise { + try { + const { stdout } = await execFileAsync('tasklist', ['/FI', `IMAGENAME eq ${imageName}`, '/FO', 'CSV', '/NH']) + const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean) + for (const line of lines) { + if (line.startsWith('INFO:')) continue + const parts = line.split('","').map((p) => p.replace(/^"|"$/g, '')) + if (parts[0]?.toLowerCase() === imageName.toLowerCase()) { + const pid = Number(parts[1]) + if (!Number.isNaN(pid)) return pid + } + } + return null + } catch (e) { + console.error(`获取进程失败 (${imageName}):`, e) + return null + } + } + + private async findWeChatPid(): Promise { + const names = ['Weixin.exe', 'WeChat.exe'] + for (const name of names) { + const pid = await this.findPidByImageName(name) + if (pid) return pid + } + + const fallbackPid = await this.waitForWeChatWindow(5000) + return fallbackPid ?? null + } + + private async killWeChatProcesses() { + try { + await execFileAsync('taskkill', ['/F', '/IM', 'Weixin.exe']) + await execFileAsync('taskkill', ['/F', '/IM', 'WeChat.exe']) + } catch (e) { + // Ignore if not found + } + await new Promise(r => setTimeout(r, 1000)) + } + + // --- Window Detection --- + + private getWindowTitle(hWnd: any): string { + const len = this.GetWindowTextLengthW(hWnd) + if (len === 0) return '' + const buf = Buffer.alloc((len + 1) * 2) + this.GetWindowTextW(hWnd, buf, len + 1) + return buf.toString('ucs2', 0, len * 2) + } + + private getClassName(hWnd: any): string { + const buf = Buffer.alloc(512) + const len = this.GetClassNameW(hWnd, buf, 256) + return buf.toString('ucs2', 0, len * 2) + } + + private isWeChatWindowTitle(title: string): boolean { + const normalized = title.trim() + if (!normalized) return false + const lower = normalized.toLowerCase() + return normalized === '微信' || lower === 'wechat' || lower === 'weixin' + } + + private async waitForWeChatWindow(timeoutMs = 25000): Promise { + if (!this.ensureUser32()) return null + const startTime = Date.now() + while (Date.now() - startTime < timeoutMs) { + let foundPid: number | null = null + + const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => { + if (!this.IsWindowVisible(hWnd)) return true + const title = this.getWindowTitle(hWnd) + if (!this.isWeChatWindowTitle(title)) return true + + const pidBuf = Buffer.alloc(4) + this.GetWindowThreadProcessId(hWnd, pidBuf) + const pid = pidBuf.readUInt32LE(0) + if (pid) { + foundPid = pid + return false + } + return true + }, this.WNDENUMPROC_PTR) + + this.EnumWindows(enumWindowsCallback, 0) + this.koffi.unregister(enumWindowsCallback) + + if (foundPid) return foundPid + await new Promise(r => setTimeout(r, 500)) + } + return null + } + + private collectChildWindowInfos(parent: any): Array<{ title: string; className: string }> { + const children: Array<{ title: string; className: string }> = [] + const enumChildCallback = this.koffi.register((hChild: any, lp: any) => { + const title = this.getWindowTitle(hChild).trim() + const className = this.getClassName(hChild).trim() + children.push({ title, className }) + return true + }, this.WNDENUMPROC_PTR) + this.EnumChildWindows(parent, enumChildCallback, 0) + this.koffi.unregister(enumChildCallback) + return children + } + + private hasReadyComponents(children: Array<{ title: string; className: string }>): boolean { + if (children.length === 0) return false + + const readyTexts = ['聊天', '登录', '账号'] + const readyClassMarkers = ['WeChat', 'Weixin', 'TXGuiFoundation', 'Qt5', 'ChatList', 'MainWnd', 'BrowserWnd', 'ListView'] + const readyChildCountThreshold = 14 + + let classMatchCount = 0 + let titleMatchCount = 0 + let hasValidClassName = false + + for (const child of children) { + const normalizedTitle = child.title.replace(/\s+/g, '') + if (normalizedTitle) { + if (readyTexts.some(marker => normalizedTitle.includes(marker))) { + return true + } + titleMatchCount += 1 + } + + const className = child.className + if (className) { + if (readyClassMarkers.some(marker => className.includes(marker))) { + return true + } + if (className.length > 5) { + classMatchCount += 1 + hasValidClassName = true + } + } + } + + if (classMatchCount >= 3 || titleMatchCount >= 2) return true + if (children.length >= readyChildCountThreshold) return true + if (hasValidClassName && children.length >= 5) return true + return false + } + + private async waitForWeChatWindowComponents(pid: number, timeoutMs = 15000): Promise { + if (!this.ensureUser32()) return true + const startTime = Date.now() + while (Date.now() - startTime < timeoutMs) { + let ready = false + const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => { + if (!this.IsWindowVisible(hWnd)) return true + const title = this.getWindowTitle(hWnd) + if (!this.isWeChatWindowTitle(title)) return true + + const pidBuf = Buffer.alloc(4) + this.GetWindowThreadProcessId(hWnd, pidBuf) + const windowPid = pidBuf.readUInt32LE(0) + if (windowPid !== pid) return true + + const children = this.collectChildWindowInfos(hWnd) + if (this.hasReadyComponents(children)) { + ready = true + return false + } + return true + }, this.WNDENUMPROC_PTR) + + this.EnumWindows(enumWindowsCallback, 0) + this.koffi.unregister(enumWindowsCallback) + + if (ready) return true + await new Promise(r => setTimeout(r, 500)) + } + return true + } + + // --- Main Methods --- + + async autoGetDbKey( + timeoutMs = 60_000, + onStatus?: (message: string, level: number) => void + ): Promise { + if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' } + if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' } + if (!this.ensureKernel32()) return { success: false, error: 'Kernel32 Init Failed' } + + const logs: string[] = [] + + // 1. Find Path + onStatus?.('正在定位微信安装路径...', 0) + let wechatPath = await this.findWeChatInstallPath() + if (!wechatPath) { + const err = '未找到微信安装路径,请确认已安装PC微信' + onStatus?.(err, 2) + return { success: false, error: err } + } + + // 2. Restart WeChat + onStatus?.('正在重启微信以进行获取...', 0) + await this.killWeChatProcesses() + + // 3. Launch + onStatus?.('正在启动微信...', 0) + const sub = spawn(wechatPath, { detached: true, stdio: 'ignore' }) + sub.unref() + + // 4. Wait for Window & Get PID (Crucial change: discover PID from window) + onStatus?.('等待微信界面就绪...', 0) + const pid = await this.waitForWeChatWindow() + if (!pid) { + return { success: false, error: '启动微信失败或等待界面就绪超时' } + } + + onStatus?.(`检测到微信窗口 (PID: ${pid}),正在获取...`, 0) + onStatus?.('正在检测微信界面组件...', 0) + await this.waitForWeChatWindowComponents(pid, 15000) + + // 5. Inject + const ok = this.initHook(pid) + if (!ok) { + const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : '' + if (error) { + return { success: false, error } + } + const statusBuffer = Buffer.alloc(256) + const levelOut = [0] + const status = this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut) + ? this.decodeUtf8(statusBuffer) + : '' + return { success: false, error: status || '初始化失败' } + } + + const keyBuffer = Buffer.alloc(128) + const start = Date.now() + + try { + while (Date.now() - start < timeoutMs) { + if (this.pollKeyData(keyBuffer, keyBuffer.length)) { + const key = this.decodeUtf8(keyBuffer) + if (key.length === 64) { + onStatus?.('密钥获取成功', 1) + return { success: true, key, logs } + } + } + + for (let i = 0; i < 5; i++) { + const statusBuffer = Buffer.alloc(256) + const levelOut = [0] + if (!this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)) { + break + } + const msg = this.decodeUtf8(statusBuffer) + const level = levelOut[0] ?? 0 + if (msg) { + logs.push(msg) + onStatus?.(msg, level) + } + } + + await new Promise((resolve) => setTimeout(resolve, 120)) + } + } finally { + try { + this.cleanupHook() + } catch { } + } + + return { success: false, error: '获取密钥超时', logs } + } + + // --- Image Key Stuff (Legacy but kept) --- + + private isAccountDir(dirPath: string): boolean { + return ( + existsSync(join(dirPath, 'db_storage')) || + existsSync(join(dirPath, 'FileStorage', 'Image')) || + existsSync(join(dirPath, 'FileStorage', 'Image2')) + ) + } + + private isPotentialAccountName(name: string): boolean { + const lower = name.toLowerCase() + if (lower.startsWith('all') || lower.startsWith('applet') || lower.startsWith('backup') || lower.startsWith('wmpf')) { + return false + } + if (lower.startsWith('wxid_')) return true + if (/^\d+$/.test(name) && name.length >= 6) return true + return name.length > 5 + } + + private listAccountDirs(rootDir: string): string[] { + try { + const entries = readdirSync(rootDir) + const high: string[] = [] + const low: string[] = [] + for (const entry of entries) { + const fullPath = join(rootDir, entry) + try { + if (!statSync(fullPath).isDirectory()) continue + } catch { + continue + } + + if (!this.isPotentialAccountName(entry)) { + continue + } + + if (this.isAccountDir(fullPath)) { + high.push(fullPath) + } else { + low.push(fullPath) + } + } + return high.length ? high.sort() : low.sort() + } catch { + return [] + } + } + + private normalizeExistingDir(inputPath: string): string | null { + const trimmed = inputPath.replace(/[\\\\/]+$/, '') + if (!existsSync(trimmed)) return null + try { + const stats = statSync(trimmed) + if (stats.isFile()) { + return dirname(trimmed) + } + } catch { + return null + } + return trimmed + } + + private resolveAccountDirFromPath(inputPath: string): string | null { + const normalized = this.normalizeExistingDir(inputPath) + if (!normalized) return null + + if (this.isAccountDir(normalized)) return normalized + + const lower = normalized.toLowerCase() + if (lower.endsWith('db_storage') || lower.endsWith('filestorage') || lower.endsWith('image') || lower.endsWith('image2')) { + const parent = dirname(normalized) + if (this.isAccountDir(parent)) return parent + const grandParent = dirname(parent) + if (this.isAccountDir(grandParent)) return grandParent + } + + const candidates = this.listAccountDirs(normalized) + if (candidates.length) return candidates[0] + return null + } + + private resolveAccountDir(manualDir?: string): string | null { + if (manualDir) { + const resolved = this.resolveAccountDirFromPath(manualDir) + if (resolved) return resolved + } + + const userProfile = process.env.USERPROFILE + if (!userProfile) return null + const roots = [ + join(userProfile, 'Documents', 'xwechat_files'), + join(userProfile, 'Documents', 'WeChat Files') + ] + for (const root of roots) { + if (!existsSync(root)) continue + const candidates = this.listAccountDirs(root) + if (candidates.length) return candidates[0] + } + return null + } + + private findTemplateDatFiles(rootDir: string): string[] { + const files: string[] = [] + const stack = [rootDir] + const maxFiles = 32 + while (stack.length && files.length < maxFiles) { + const dir = stack.pop() as string + let entries: string[] + try { + entries = readdirSync(dir) + } catch { + continue + } + for (const entry of entries) { + const fullPath = join(dir, entry) + let stats: any + try { + stats = statSync(fullPath) + } catch { + continue + } + if (stats.isDirectory()) { + stack.push(fullPath) + } else if (entry.endsWith('_t.dat')) { + files.push(fullPath) + if (files.length >= maxFiles) break + } + } + } + + if (!files.length) return [] + const dateReg = /(\d{4}-\d{2})/ + files.sort((a, b) => { + const ma = a.match(dateReg)?.[1] + const mb = b.match(dateReg)?.[1] + if (ma && mb) return mb.localeCompare(ma) + return 0 + }) + return files.slice(0, 16) + } + + private getXorKey(templateFiles: string[]): number | null { + const counts = new Map() + for (const file of templateFiles) { + try { + const bytes = readFileSync(file) + if (bytes.length < 2) continue + const x = bytes[bytes.length - 2] + const y = bytes[bytes.length - 1] + const key = `${x}_${y}` + counts.set(key, (counts.get(key) ?? 0) + 1) + } catch { } + } + if (!counts.size) return null + let mostKey = '' + let mostCount = 0 + for (const [key, count] of counts) { + if (count > mostCount) { + mostCount = count + mostKey = key + } + } + if (!mostKey) return null + const [xStr, yStr] = mostKey.split('_') + const x = Number(xStr) + const y = Number(yStr) + const xorKey = x ^ 0xFF + const check = y ^ 0xD9 + return xorKey === check ? xorKey : null + } + + private getCiphertextFromTemplate(templateFiles: string[]): Buffer | null { + for (const file of templateFiles) { + try { + const bytes = readFileSync(file) + if (bytes.length < 0x1f) continue + if ( + bytes[0] === 0x07 && + bytes[1] === 0x08 && + bytes[2] === 0x56 && + bytes[3] === 0x32 && + bytes[4] === 0x08 && + bytes[5] === 0x07 + ) { + return bytes.subarray(0x0f, 0x1f) + } + } catch { } + } + return null + } + + private isAlphaNumAscii(byte: number): boolean { + return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x30 && byte <= 0x39) + } + + private isUtf16AsciiKey(buf: Buffer, start: number): boolean { + if (start + 64 > buf.length) return false + for (let j = 0; j < 32; j++) { + const charByte = buf[start + j * 2] + const nullByte = buf[start + j * 2 + 1] + if (nullByte !== 0x00 || !this.isAlphaNumAscii(charByte)) { + return false + } + } + return true + } + + private verifyKey(ciphertext: Buffer, keyBytes: Buffer): boolean { + try { + const key = keyBytes.subarray(0, 16) + const decipher = crypto.createDecipheriv('aes-128-ecb', key, null) + decipher.setAutoPadding(false) + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff + } catch { + return false + } + } + + private getMemoryRegions(hProcess: any): Array<[number, number]> { + const regions: Array<[number, number]> = [] + const MEM_COMMIT = 0x1000 + const MEM_PRIVATE = 0x20000 + const MEM_MAPPED = 0x40000 + const MEM_IMAGE = 0x1000000 + const PAGE_NOACCESS = 0x01 + const PAGE_GUARD = 0x100 + + let address = 0 + const maxAddress = 0x7fffffffffff + while (address >= 0 && address < maxAddress) { + const info: any = {} + const result = this.VirtualQueryEx(hProcess, address, info, this.koffi.sizeof(this.MEMORY_BASIC_INFORMATION)) + if (!result) break + + const state = info.State + const protect = info.Protect + const type = info.Type + const regionSize = Number(info.RegionSize) + if (state === MEM_COMMIT && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) { + if (type === MEM_PRIVATE || type === MEM_MAPPED || type === MEM_IMAGE) { + regions.push([Number(info.BaseAddress), regionSize]) + } + } + + const nextAddress = address + regionSize + if (nextAddress <= address) break + address = nextAddress + } + return regions + } + + private readProcessMemory(hProcess: any, address: number, size: number): Buffer | null { + const buffer = Buffer.alloc(size) + const bytesRead = [0] + const ok = this.ReadProcessMemory(hProcess, address, buffer, size, bytesRead) + if (!ok || bytesRead[0] === 0) return null + return buffer.subarray(0, bytesRead[0]) + } + + private async getAesKeyFromMemory(pid: number, ciphertext: Buffer): Promise { + if (!this.ensureKernel32()) return null + const hProcess = this.OpenProcess(this.PROCESS_ALL_ACCESS, false, pid) + if (!hProcess) return null + + try { + const regions = this.getMemoryRegions(hProcess) + const chunkSize = 4 * 1024 * 1024 + const overlap = 65 + for (const [baseAddress, regionSize] of regions) { + if (regionSize > 100 * 1024 * 1024) continue + let offset = 0 + let trailing: Buffer | null = null + while (offset < regionSize) { + const remaining = regionSize - offset + const currentChunkSize = remaining > chunkSize ? chunkSize : remaining + const chunk = this.readProcessMemory(hProcess, baseAddress + offset, currentChunkSize) + if (!chunk || !chunk.length) { + offset += currentChunkSize + trailing = null + continue + } + + let dataToScan: Buffer + if (trailing && trailing.length) { + dataToScan = Buffer.concat([trailing, chunk]) + } else { + dataToScan = chunk + } + + for (let i = 0; i < dataToScan.length - 34; i++) { + if (this.isAlphaNumAscii(dataToScan[i])) continue + let valid = true + for (let j = 1; j <= 32; j++) { + if (!this.isAlphaNumAscii(dataToScan[i + j])) { + valid = false + break + } + } + if (valid && this.isAlphaNumAscii(dataToScan[i + 33])) { + valid = false + } + if (valid) { + const keyBytes = dataToScan.subarray(i + 1, i + 33) + if (this.verifyKey(ciphertext, keyBytes)) { + return keyBytes.toString('ascii') + } + } + } + + for (let i = 0; i < dataToScan.length - 65; i++) { + if (!this.isUtf16AsciiKey(dataToScan, i)) continue + const keyBytes = Buffer.alloc(32) + for (let j = 0; j < 32; j++) { + keyBytes[j] = dataToScan[i + j * 2] + } + if (this.verifyKey(ciphertext, keyBytes)) { + return keyBytes.toString('ascii') + } + } + + const start = dataToScan.length - overlap + trailing = dataToScan.subarray(start < 0 ? 0 : start) + offset += currentChunkSize + } + } + return null + } finally { + try { + this.CloseHandle(hProcess) + } catch { } + } + } + + async autoGetImageKey( + manualDir?: string, + onProgress?: (message: string) => void + ): Promise { + if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' } + if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' } + if (!this.ensureKernel32()) return { success: false, error: '初始化系统 API 失败' } + + onProgress?.('正在定位微信账号目录...') + const accountDir = this.resolveAccountDir(manualDir) + if (!accountDir) return { success: false, error: '未找到微信账号目录' } + + onProgress?.('正在收集模板文件...') + const templateFiles = this.findTemplateDatFiles(accountDir) + if (!templateFiles.length) return { success: false, error: '未找到模板文件' } + + onProgress?.('正在计算 XOR 密钥...') + const xorKey = this.getXorKey(templateFiles) + if (xorKey == null) return { success: false, error: '无法计算 XOR 密钥' } + + onProgress?.('正在读取加密模板数据...') + const ciphertext = this.getCiphertextFromTemplate(templateFiles) + if (!ciphertext) return { success: false, error: '无法读取加密模板数据' } + + const pid = await this.findWeChatPid() + if (!pid) return { success: false, error: '未检测到微信进程' } + + onProgress?.('正在扫描内存获取 AES 密钥...') + const aesKey = await this.getAesKeyFromMemory(pid, ciphertext) + if (!aesKey) { + return { + success: false, + error: '未能从内存中获取 AES 密钥,请打开朋友圈图片后重试' + } + } + + return { success: true, xorKey, aesKey: aesKey.slice(0, 16) } + } +} diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts new file mode 100644 index 0000000..2e2a95b --- /dev/null +++ b/electron/services/wcdbService.ts @@ -0,0 +1,1210 @@ +import { join, dirname, basename } from 'path' +import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' + +export class WcdbService { + private resourcesPath: string | null = null + private userDataPath: string | null = null + private logEnabled = false + private lib: any = null + private koffi: any = null + private initialized = false + private handle: number | null = null + private currentPath: string | null = null + private currentKey: string | null = null + private currentWxid: string | null = null + + // 函数引用 + private wcdbInit: any = null + private wcdbShutdown: any = null + private wcdbOpenAccount: any = null + private wcdbCloseAccount: any = null + private wcdbSetMyWxid: any = null + private wcdbFreeString: any = null + private wcdbGetSessions: any = null + private wcdbGetMessages: any = null + private wcdbGetMessageCount: any = null + private wcdbGetDisplayNames: any = null + private wcdbGetAvatarUrls: any = null + private wcdbGetGroupMemberCount: any = null + private wcdbGetGroupMemberCounts: any = null + private wcdbGetGroupMembers: any = null + private wcdbGetMessageTables: any = null + private wcdbGetMessageMeta: any = null + private wcdbGetContact: any = null + private wcdbGetMessageTableStats: any = null + private wcdbGetAggregateStats: any = null + private wcdbGetAvailableYears: any = null + private wcdbGetAnnualReportStats: any = null + private wcdbGetAnnualReportExtras: any = null + private wcdbGetGroupStats: any = null + private wcdbOpenMessageCursor: any = null + private wcdbOpenMessageCursorLite: any = null + private wcdbFetchMessageBatch: any = null + private wcdbCloseMessageCursor: any = null + private wcdbGetLogs: any = null + private wcdbExecQuery: any = null + private wcdbListMessageDbs: any = null + private wcdbListMediaDbs: any = null + private wcdbGetMessageById: any = null + private wcdbGetEmoticonCdnUrl: any = null + private avatarUrlCache: Map = new Map() + private readonly avatarCacheTtlMs = 10 * 60 * 1000 + + setPaths(resourcesPath: string, userDataPath: string): void { + this.resourcesPath = resourcesPath + this.userDataPath = userDataPath + } + + setLogEnabled(enabled: boolean): void { + this.logEnabled = enabled + } + + /** + * 获取 DLL 路径 + */ + private getDllPath(): string { + const envDllPath = process.env.WCDB_DLL_PATH + if (envDllPath && envDllPath.length > 0) { + return envDllPath + } + + const envResourcesPath = process.env.WCDB_RESOURCES_PATH + if (envResourcesPath && envResourcesPath.length > 0) { + return join(envResourcesPath, 'wcdb_api.dll') + } + + if (this.resourcesPath && this.resourcesPath.length > 0) { + return join(this.resourcesPath, 'wcdb_api.dll') + } + + const fallbackBase = process.resourcesPath || join(process.cwd(), 'resources') + return join(fallbackBase, 'wcdb_api.dll') + } + + private isLogEnabled(): boolean { + if (process.env.WEFLOW_WORKER === '1') return false + if (process.env.WCDB_LOG_ENABLED === '1') return true + return this.logEnabled + } + + private writeLog(message: string, force = false): void { + if (!force && !this.isLogEnabled()) return + try { + const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd() + const dir = join(base, 'logs') + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + const line = `[${new Date().toISOString()}] ${message}\n` + appendFileSync(join(dir, 'wcdb.log'), line, { encoding: 'utf8' }) + } catch { } + } + + /** + * 递归查找 session.db 文件 + */ + private findSessionDb(dir: string, depth = 0): string | null { + if (depth > 5) return null + + try { + const entries = readdirSync(dir) + + for (const entry of entries) { + if (entry.toLowerCase() === 'session.db') { + const fullPath = join(dir, entry) + if (statSync(fullPath).isFile()) { + return fullPath + } + } + } + + for (const entry of entries) { + const fullPath = join(dir, entry) + try { + if (statSync(fullPath).isDirectory()) { + const found = this.findSessionDb(fullPath, depth + 1) + if (found) return found + } + } catch { } + } + } catch (e) { + console.error('查找 session.db 失败:', e) + } + + return null + } + + private resolveDbStoragePath(basePath: string, wxid: string): string | null { + if (!basePath) return null + const normalized = basePath.replace(/[\\\\/]+$/, '') + if (normalized.toLowerCase().endsWith('db_storage') && existsSync(normalized)) { + return normalized + } + const direct = join(normalized, 'db_storage') + if (existsSync(direct)) { + return direct + } + if (wxid) { + const viaWxid = join(normalized, wxid, 'db_storage') + if (existsSync(viaWxid)) { + return viaWxid + } + // 兼容目录名包含额外后缀(如 wxid_xxx_1234) + try { + const entries = readdirSync(normalized) + const lowerWxid = wxid.toLowerCase() + const candidates = entries.filter((entry) => { + const entryPath = join(normalized, entry) + try { + if (!statSync(entryPath).isDirectory()) return false + } catch { + return false + } + const lowerEntry = entry.toLowerCase() + return lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`) + }) + for (const entry of candidates) { + const candidate = join(normalized, entry, 'db_storage') + if (existsSync(candidate)) { + return candidate + } + } + } catch { } + } + return null + } + + /** + * 初始化 WCDB + */ + async initialize(): Promise { + if (this.initialized) return true + + try { + this.koffi = require('koffi') + const dllPath = this.getDllPath() + + if (!existsSync(dllPath)) { + console.error('WCDB DLL 不存在:', dllPath) + return false + } + + this.lib = this.koffi.load(dllPath) + + // 定义类型 + // wcdb_status wcdb_init() + this.wcdbInit = this.lib.func('int32 wcdb_init()') + + // wcdb_status wcdb_shutdown() + this.wcdbShutdown = this.lib.func('int32 wcdb_shutdown()') + + // wcdb_status wcdb_open_account(const char* session_db_path, const char* hex_key, wcdb_handle* out_handle) + // wcdb_handle 是 int64_t + this.wcdbOpenAccount = this.lib.func('int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)') + + // wcdb_status wcdb_close_account(wcdb_handle handle) + // C 接口是 int64, koffi 返回 handle 是 number 类型 + this.wcdbCloseAccount = this.lib.func('int32 wcdb_close_account(int64 handle)') + + // wcdb_status wcdb_set_my_wxid(wcdb_handle handle, const char* wxid) + try { + this.wcdbSetMyWxid = this.lib.func('int32 wcdb_set_my_wxid(int64 handle, const char* wxid)') + } catch { + this.wcdbSetMyWxid = null + } + + // void wcdb_free_string(char* ptr) + this.wcdbFreeString = this.lib.func('void wcdb_free_string(void* ptr)') + + // wcdb_status wcdb_get_sessions(wcdb_handle handle, char** out_json) + this.wcdbGetSessions = this.lib.func('int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)') + + // wcdb_status wcdb_get_messages(wcdb_handle handle, const char* username, int32_t limit, int32_t offset, char** out_json) + this.wcdbGetMessages = this.lib.func('int32 wcdb_get_messages(int64 handle, const char* username, int32 limit, int32 offset, _Out_ void** outJson)') + + // wcdb_status wcdb_get_message_count(wcdb_handle handle, const char* username, int32_t* out_count) + this.wcdbGetMessageCount = this.lib.func('int32 wcdb_get_message_count(int64 handle, const char* username, _Out_ int32* outCount)') + + // wcdb_status wcdb_get_display_names(wcdb_handle handle, const char* usernames_json, char** out_json) + this.wcdbGetDisplayNames = this.lib.func('int32 wcdb_get_display_names(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + + // wcdb_status wcdb_get_avatar_urls(wcdb_handle handle, const char* usernames_json, char** out_json) + this.wcdbGetAvatarUrls = this.lib.func('int32 wcdb_get_avatar_urls(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + + // wcdb_status wcdb_get_group_member_count(wcdb_handle handle, const char* chatroom_id, int32_t* out_count) + this.wcdbGetGroupMemberCount = this.lib.func('int32 wcdb_get_group_member_count(int64 handle, const char* chatroomId, _Out_ int32* outCount)') + + // wcdb_status wcdb_get_group_member_counts(wcdb_handle handle, const char* chatroom_ids_json, char** out_json) + try { + this.wcdbGetGroupMemberCounts = this.lib.func('int32 wcdb_get_group_member_counts(int64 handle, const char* chatroomIdsJson, _Out_ void** outJson)') + } catch { + this.wcdbGetGroupMemberCounts = null + } + + // wcdb_status wcdb_get_group_members(wcdb_handle handle, const char* chatroom_id, char** out_json) + this.wcdbGetGroupMembers = this.lib.func('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)') + + // wcdb_status wcdb_get_message_tables(wcdb_handle handle, const char* session_id, char** out_json) + this.wcdbGetMessageTables = this.lib.func('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)') + + // wcdb_status wcdb_get_message_meta(wcdb_handle handle, const char* db_path, const char* table_name, int32_t limit, int32_t offset, char** out_json) + this.wcdbGetMessageMeta = this.lib.func('int32 wcdb_get_message_meta(int64 handle, const char* dbPath, const char* tableName, int32 limit, int32 offset, _Out_ void** outJson)') + + // wcdb_status wcdb_get_contact(wcdb_handle handle, const char* username, char** out_json) + this.wcdbGetContact = this.lib.func('int32 wcdb_get_contact(int64 handle, const char* username, _Out_ void** outJson)') + + // wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json) + this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)') + + // wcdb_status wcdb_get_aggregate_stats(wcdb_handle handle, const char* session_ids_json, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) + this.wcdbGetAggregateStats = this.lib.func('int32 wcdb_get_aggregate_stats(int64 handle, const char* sessionIdsJson, int32 begin, int32 end, _Out_ void** outJson)') + + // wcdb_status wcdb_get_available_years(wcdb_handle handle, const char* session_ids_json, char** out_json) + try { + this.wcdbGetAvailableYears = this.lib.func('int32 wcdb_get_available_years(int64 handle, const char* sessionIdsJson, _Out_ void** outJson)') + } catch { + this.wcdbGetAvailableYears = null + } + + // wcdb_status wcdb_get_annual_report_stats(wcdb_handle handle, const char* session_ids_json, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) + try { + this.wcdbGetAnnualReportStats = this.lib.func('int32 wcdb_get_annual_report_stats(int64 handle, const char* sessionIdsJson, int32 begin, int32 end, _Out_ void** outJson)') + } catch { + this.wcdbGetAnnualReportStats = null + } + + // wcdb_status wcdb_get_annual_report_extras(wcdb_handle handle, const char* session_ids_json, int32_t begin_timestamp, int32_t end_timestamp, int32_t peak_day_begin, int32_t peak_day_end, char** out_json) + try { + this.wcdbGetAnnualReportExtras = this.lib.func('int32 wcdb_get_annual_report_extras(int64 handle, const char* sessionIdsJson, int32 begin, int32 end, int32 peakBegin, int32 peakEnd, _Out_ void** outJson)') + } catch { + this.wcdbGetAnnualReportExtras = null + } + + // wcdb_status wcdb_get_group_stats(wcdb_handle handle, const char* chatroom_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) + try { + this.wcdbGetGroupStats = this.lib.func('int32 wcdb_get_group_stats(int64 handle, const char* chatroomId, int32 begin, int32 end, _Out_ void** outJson)') + } catch { + this.wcdbGetGroupStats = null + } + + // wcdb_status wcdb_open_message_cursor(wcdb_handle handle, const char* session_id, int32_t batch_size, int32_t ascending, int32_t begin_timestamp, int32_t end_timestamp, wcdb_cursor* out_cursor) + this.wcdbOpenMessageCursor = this.lib.func('int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)') + + // wcdb_status wcdb_open_message_cursor_lite(wcdb_handle handle, const char* session_id, int32_t batch_size, int32_t ascending, int32_t begin_timestamp, int32_t end_timestamp, wcdb_cursor* out_cursor) + try { + this.wcdbOpenMessageCursorLite = this.lib.func('int32 wcdb_open_message_cursor_lite(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)') + } catch { + this.wcdbOpenMessageCursorLite = null + } + + // wcdb_status wcdb_fetch_message_batch(wcdb_handle handle, wcdb_cursor cursor, char** out_json, int32_t* out_has_more) + this.wcdbFetchMessageBatch = this.lib.func('int32 wcdb_fetch_message_batch(int64 handle, int64 cursor, _Out_ void** outJson, _Out_ int32* outHasMore)') + + // wcdb_status wcdb_close_message_cursor(wcdb_handle handle, wcdb_cursor cursor) + this.wcdbCloseMessageCursor = this.lib.func('int32 wcdb_close_message_cursor(int64 handle, int64 cursor)') + + // wcdb_status wcdb_get_logs(char** out_json) + this.wcdbGetLogs = this.lib.func('int32 wcdb_get_logs(_Out_ void** outJson)') + + // wcdb_status wcdb_exec_query(wcdb_handle handle, const char* db_kind, const char* db_path, const char* sql, char** out_json) + this.wcdbExecQuery = this.lib.func('int32 wcdb_exec_query(int64 handle, const char* kind, const char* path, const char* sql, _Out_ void** outJson)') + + // wcdb_status wcdb_get_emoticon_cdn_url(wcdb_handle handle, const char* db_path, const char* md5, char** out_url) + this.wcdbGetEmoticonCdnUrl = this.lib.func('int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)') + + // wcdb_status wcdb_list_message_dbs(wcdb_handle handle, char** out_json) + this.wcdbListMessageDbs = this.lib.func('int32 wcdb_list_message_dbs(int64 handle, _Out_ void** outJson)') + + // wcdb_status wcdb_list_media_dbs(wcdb_handle handle, char** out_json) + this.wcdbListMediaDbs = this.lib.func('int32 wcdb_list_media_dbs(int64 handle, _Out_ void** outJson)') + + // wcdb_status wcdb_get_message_by_id(wcdb_handle handle, const char* session_id, int32 local_id, char** out_json) + this.wcdbGetMessageById = this.lib.func('int32 wcdb_get_message_by_id(int64 handle, const char* sessionId, int32 localId, _Out_ void** outJson)') + + // 初始化 + const initResult = this.wcdbInit() + if (initResult !== 0) { + console.error('WCDB 初始化失败:', initResult) + return false + } + + this.initialized = true + return true + } catch (e) { + console.error('WCDB 初始化异常:', e) + return false + } + } + + /** + * 测试数据库连接 + */ + async testConnection(dbPath: string, hexKey: string, wxid: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> { + try { + // 如果当前已经有相同参数的活动连接,直接返回成功 + if (this.handle !== null && + this.currentPath === dbPath && + this.currentKey === hexKey && + this.currentWxid === wxid) { + return { success: true, sessionCount: 0 } + } + + if (!this.initialized) { + const initOk = await this.initialize() + if (!initOk) { + return { success: false, error: 'WCDB 初始化失败' } + } + } + + // 构建 db_storage 目录路径 + const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid) + this.writeLog(`testConnection dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`) + + if (!dbStoragePath || !existsSync(dbStoragePath)) { + return { success: false, error: `数据库目录不存在: ${dbPath}` } + } + + // 递归查找 session.db + const sessionDbPath = this.findSessionDb(dbStoragePath) + this.writeLog(`testConnection sessionDb=${sessionDbPath || 'null'}`) + + if (!sessionDbPath) { + return { success: false, error: `未找到 session.db 文件` } + } + + // 分配输出参数内存 + const handleOut = [0] + const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut) + + if (result !== 0) { + await this.printLogs() + let errorMsg = '数据库打开失败' + if (result === -1) errorMsg = '参数错误' + else if (result === -2) errorMsg = '密钥错误' + else if (result === -3) errorMsg = '数据库打开失败' + this.writeLog(`testConnection openAccount failed code=${result}`) + return { success: false, error: `${errorMsg} (错误码: ${result})` } + } + + const tempHandle = handleOut[0] + if (tempHandle <= 0) { + return { success: false, error: '无效的数据库句柄' } + } + + // 测试成功,使用 shutdown 清理所有资源(包括测试句柄) + // 这会中断当前活动连接,但 testConnection 本应该是独立测试 + try { + this.wcdbShutdown() + this.handle = null + this.currentPath = null + this.currentKey = null + this.currentWxid = null + this.initialized = false + } catch (closeErr) { + console.error('关闭测试数据库时出错:', closeErr) + } + + return { success: true, sessionCount: 0 } + } catch (e) { + console.error('测试连接异常:', e) + this.writeLog(`testConnection exception: ${String(e)}`) + return { success: false, error: String(e) } + } + } + + /** + * 打印 DLL 内部日志(仅在出错时调用) + */ + private async printLogs(force = false): Promise { + try { + if (!this.wcdbGetLogs) return + const outPtr = [null as any] + const result = this.wcdbGetLogs(outPtr) + if (result === 0 && outPtr[0]) { + try { + const jsonStr = this.koffi.decode(outPtr[0], 'char', -1) + this.writeLog(`wcdb_logs: ${jsonStr}`, force) + this.wcdbFreeString(outPtr[0]) + } catch (e) { + // ignore + } + } + } catch (e) { + console.error('获取日志失败:', e) + this.writeLog(`wcdb_logs failed: ${String(e)}`, force) + } + } + + private decodeJsonPtr(outPtr: any): string | null { + if (!outPtr) return null + try { + const jsonStr = this.koffi.decode(outPtr, 'char', -1) + this.wcdbFreeString(outPtr) + return jsonStr + } catch (e) { + try { this.wcdbFreeString(outPtr) } catch { } + return null + } + } + + private ensureReady(): boolean { + return this.initialized && this.handle !== null + } + + private normalizeTimestamp(input: number): number { + if (!input || input <= 0) return 0 + const asNumber = Number(input) + if (!Number.isFinite(asNumber)) return 0 + // Treat >1e12 as milliseconds. + const seconds = asNumber > 1e12 ? Math.floor(asNumber / 1000) : Math.floor(asNumber) + const maxInt32 = 2147483647 + return Math.min(Math.max(seconds, 0), maxInt32) + } + + private normalizeRange(beginTimestamp: number, endTimestamp: number): { begin: number; end: number } { + const normalizedBegin = this.normalizeTimestamp(beginTimestamp) + let normalizedEnd = this.normalizeTimestamp(endTimestamp) + if (normalizedEnd <= 0) { + normalizedEnd = this.normalizeTimestamp(Date.now()) + } + if (normalizedBegin > 0 && normalizedEnd < normalizedBegin) { + normalizedEnd = normalizedBegin + } + return { begin: normalizedBegin, end: normalizedEnd } + } + + isReady(): boolean { + return this.ensureReady() + } + + /** + * 打开数据库 + */ + async open(dbPath: string, hexKey: string, wxid: string): Promise { + try { + if (!this.initialized) { + const initOk = await this.initialize() + if (!initOk) return false + } + + // 检查是否已经是当前连接的参数,如果是则直接返回成功,实现"始终保持链接" + if (this.handle !== null && + this.currentPath === dbPath && + this.currentKey === hexKey && + this.currentWxid === wxid) { + return true + } + + // 如果参数不同,则先关闭原来的连接 + if (this.handle !== null) { + this.close() + // 重新初始化,因为 close 呼叫了 shutdown + const initOk = await this.initialize() + if (!initOk) return false + } + + const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid) + this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`) + + if (!dbStoragePath || !existsSync(dbStoragePath)) { + console.error('数据库目录不存在:', dbPath) + this.writeLog(`open failed: dbStorage not found for ${dbPath}`) + return false + } + + const sessionDbPath = this.findSessionDb(dbStoragePath) + this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`) + if (!sessionDbPath) { + console.error('未找到 session.db 文件') + this.writeLog('open failed: session.db not found') + return false + } + + const handleOut = [0] + const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut) + + if (result !== 0) { + console.error('打开数据库失败:', result) + await this.printLogs() + this.writeLog(`open failed: openAccount code=${result}`) + return false + } + + const handle = handleOut[0] + if (handle <= 0) { + return false + } + + this.handle = handle + this.currentPath = dbPath + this.currentKey = hexKey + this.currentWxid = wxid + this.initialized = true + if (this.wcdbSetMyWxid && wxid) { + try { + this.wcdbSetMyWxid(this.handle, wxid) + } catch (e) { + console.warn('设置 wxid 失败:', e) + } + } + this.writeLog(`open ok handle=${handle}`) + return true + } catch (e) { + console.error('打开数据库异常:', e) + this.writeLog(`open exception: ${String(e)}`) + return false + } + } + + /** + * 关闭数据库 + * 注意:wcdb_close_account 可能导致崩溃,使用 shutdown 代替 + */ + close(): void { + if (this.handle !== null || this.initialized) { + try { + // 不调用 closeAccount,直接 shutdown + this.wcdbShutdown() + } catch (e) { + console.error('WCDB shutdown 出错:', e) + } + this.handle = null + this.currentPath = null + this.currentKey = null + this.currentWxid = null + this.initialized = false + } + } + + /** + * 关闭服务(与 close 相同) + */ + shutdown(): void { + this.close() + } + + /** + * 检查是否已连接 + */ + isConnected(): boolean { + return this.initialized && this.handle !== null + } + + async getSessions(): Promise<{ success: boolean; sessions?: any[]; error?: string }> { + if (!this.ensureReady()) { + this.writeLog('getSessions skipped: not connected') + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetSessions(this.handle, outPtr) + if (result !== 0 || !outPtr[0]) { + this.writeLog(`getSessions failed: code=${result}`) + return { success: false, error: `获取会话失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析会话失败' } + this.writeLog(`getSessions ok size=${jsonStr.length}`) + const sessions = JSON.parse(jsonStr) + return { success: true, sessions } + } catch (e) { + this.writeLog(`getSessions exception: ${String(e)}`) + return { success: false, error: String(e) } + } + } + + async getMessages(sessionId: string, limit: number, offset: number): Promise<{ success: boolean; messages?: any[]; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessages(this.handle, sessionId, limit, offset, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取消息失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析消息失败' } + const messages = JSON.parse(jsonStr) + return { success: true, messages } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMessageCount(sessionId: string): Promise<{ success: boolean; count?: number; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outCount = [0] + const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount) + if (result !== 0) { + return { success: false, error: `获取消息总数失败: ${result}` } + } + return { success: true, count: outCount[0] } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (usernames.length === 0) return { success: true, map: {} } + try { + const outPtr = [null as any] + const result = this.wcdbGetDisplayNames(this.handle, JSON.stringify(usernames), outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取昵称失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析昵称失败' } + const map = JSON.parse(jsonStr) + return { success: true, map } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getAvatarUrls(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (usernames.length === 0) return { success: true, map: {} } + try { + const now = Date.now() + const resultMap: Record = {} + const toFetch: string[] = [] + const seen = new Set() + + for (const username of usernames) { + if (!username || seen.has(username)) continue + seen.add(username) + const cached = this.avatarUrlCache.get(username) + if (cached && cached.url && now - cached.updatedAt < this.avatarCacheTtlMs) { + resultMap[username] = cached.url + continue + } + toFetch.push(username) + } + + if (toFetch.length === 0) { + return { success: true, map: resultMap } + } + + const outPtr = [null as any] + const result = this.wcdbGetAvatarUrls(this.handle, JSON.stringify(toFetch), outPtr) + if (result !== 0 || !outPtr[0]) { + if (Object.keys(resultMap).length > 0) { + return { success: true, map: resultMap, error: `获取头像失败: ${result}` } + } + return { success: false, error: `获取头像失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析头像失败' } + const map = JSON.parse(jsonStr) as Record + for (const username of toFetch) { + const url = map[username] + if (url) { + resultMap[username] = url + this.avatarUrlCache.set(username, { url, updatedAt: now }) + } + } + return { success: true, map: resultMap } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getGroupMemberCount(chatroomId: string): Promise<{ success: boolean; count?: number; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outCount = [0] + const result = this.wcdbGetGroupMemberCount(this.handle, chatroomId, outCount) + if (result !== 0) { + return { success: false, error: `获取群成员数量失败: ${result}` } + } + return { success: true, count: outCount[0] } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getGroupMemberCounts(chatroomIds: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (chatroomIds.length === 0) return { success: true, map: {} } + if (!this.wcdbGetGroupMemberCounts) { + const map: Record = {} + for (const chatroomId of chatroomIds) { + const result = await this.getGroupMemberCount(chatroomId) + if (result.success && typeof result.count === 'number') { + map[chatroomId] = result.count + } + } + return { success: true, map } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetGroupMemberCounts(this.handle, JSON.stringify(chatroomIds), outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取群成员数量失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析群成员数量失败' } + const map = JSON.parse(jsonStr) + return { success: true, map } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getGroupMembers(chatroomId: string): Promise<{ success: boolean; members?: any[]; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetGroupMembers(this.handle, chatroomId, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取群成员失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析群成员失败' } + const members = JSON.parse(jsonStr) + return { success: true, members } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessageTables(this.handle, sessionId, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取消息表失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析消息表失败' } + const tables = JSON.parse(jsonStr) + return { success: true, tables } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMessageTableStats(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessageTableStats(this.handle, sessionId, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取表统计失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析表统计失败' } + const tables = JSON.parse(jsonStr) + return { success: true, tables } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMessageMeta(dbPath: string, tableName: string, limit: number, offset: number): Promise<{ success: boolean; rows?: any[]; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessageMeta(this.handle, dbPath, tableName, limit, offset, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取消息元数据失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析消息元数据失败' } + const rows = JSON.parse(jsonStr) + return { success: true, rows } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getContact(username: string): Promise<{ success: boolean; contact?: any; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetContact(this.handle, username, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取联系人失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析联系人失败' } + const contact = JSON.parse(jsonStr) + return { success: true, contact } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const normalizedBegin = this.normalizeTimestamp(beginTimestamp) + let normalizedEnd = this.normalizeTimestamp(endTimestamp) + if (normalizedEnd <= 0) { + normalizedEnd = this.normalizeTimestamp(Date.now()) + } + if (normalizedBegin > 0 && normalizedEnd < normalizedBegin) { + normalizedEnd = normalizedBegin + } + + const callAggregate = (ids: string[]) => { + const idsAreNumeric = ids.length > 0 && ids.every((id) => /^\d+$/.test(id)) + const payloadIds = idsAreNumeric ? ids.map((id) => Number(id)) : ids + + const outPtr = [null as any] + const result = this.wcdbGetAggregateStats(this.handle, JSON.stringify(payloadIds), normalizedBegin, normalizedEnd, outPtr) + + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取聚合统计失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) { + return { success: false, error: '解析聚合统计失败' } + } + + const data = JSON.parse(jsonStr) + return { success: true, data } + } + + let result = callAggregate(sessionIds) + if (result.success && result.data && result.data.total === 0 && result.data.idMap) { + const idMap = result.data.idMap as Record + const reverseMap: Record = {} + for (const [id, name] of Object.entries(idMap)) { + if (!name) continue + reverseMap[name] = id + } + const numericIds = sessionIds + .map((id) => reverseMap[id]) + .filter((id) => typeof id === 'string' && /^\d+$/.test(id)) + if (numericIds.length > 0) { + const retry = callAggregate(numericIds) + if (retry.success && retry.data) { + result = retry + } + } + } + + return result + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getAvailableYears(sessionIds: string[]): Promise<{ success: boolean; data?: number[]; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (!this.wcdbGetAvailableYears) { + return { success: false, error: '未支持获取年度列表' } + } + if (sessionIds.length === 0) return { success: true, data: [] } + try { + const outPtr = [null as any] + const result = this.wcdbGetAvailableYears(this.handle, JSON.stringify(sessionIds), outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取年度列表失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析年度列表失败' } + const data = JSON.parse(jsonStr) + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getAnnualReportStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (!this.wcdbGetAnnualReportStats) { + return this.getAggregateStats(sessionIds, beginTimestamp, endTimestamp) + } + try { + const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp) + const outPtr = [null as any] + const result = this.wcdbGetAnnualReportStats(this.handle, JSON.stringify(sessionIds), begin, end, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取年度统计失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析年度统计失败' } + const data = JSON.parse(jsonStr) + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getAnnualReportExtras( + sessionIds: string[], + beginTimestamp: number = 0, + endTimestamp: number = 0, + peakDayBegin: number = 0, + peakDayEnd: number = 0 + ): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (!this.wcdbGetAnnualReportExtras) { + return { success: false, error: '未支持年度扩展统计' } + } + if (sessionIds.length === 0) return { success: true, data: {} } + try { + const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp) + const outPtr = [null as any] + const result = this.wcdbGetAnnualReportExtras( + this.handle, + JSON.stringify(sessionIds), + begin, + end, + this.normalizeTimestamp(peakDayBegin), + this.normalizeTimestamp(peakDayEnd), + outPtr + ) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取年度扩展统计失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析年度扩展统计失败' } + const data = JSON.parse(jsonStr) + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getGroupStats(chatroomId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (!this.wcdbGetGroupStats) { + return this.getAggregateStats([chatroomId], beginTimestamp, endTimestamp) + } + try { + const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp) + const outPtr = [null as any] + const result = this.wcdbGetGroupStats(this.handle, chatroomId, begin, end, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取群聊统计失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析群聊统计失败' } + const data = JSON.parse(jsonStr) + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async openMessageCursor(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outCursor = [0] + const result = this.wcdbOpenMessageCursor( + this.handle, + sessionId, + batchSize, + ascending ? 1 : 0, + beginTimestamp, + endTimestamp, + outCursor + ) + if (result !== 0 || outCursor[0] <= 0) { + await this.printLogs(true) + this.writeLog( + `openMessageCursor failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`, + true + ) + return { success: false, error: `创建游标失败: ${result},请查看日志` } + } + return { success: true, cursor: outCursor[0] } + } catch (e) { + await this.printLogs(true) + this.writeLog(`openMessageCursor exception: ${String(e)}`, true) + return { success: false, error: '创建游标异常,请查看日志' } + } + } + + async openMessageCursorLite(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (!this.wcdbOpenMessageCursorLite) { + return this.openMessageCursor(sessionId, batchSize, ascending, beginTimestamp, endTimestamp) + } + try { + const outCursor = [0] + const result = this.wcdbOpenMessageCursorLite( + this.handle, + sessionId, + batchSize, + ascending ? 1 : 0, + beginTimestamp, + endTimestamp, + outCursor + ) + if (result !== 0 || outCursor[0] <= 0) { + await this.printLogs(true) + this.writeLog( + `openMessageCursorLite failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`, + true + ) + return { success: false, error: `创建游标失败: ${result},请查看日志` } + } + return { success: true, cursor: outCursor[0] } + } catch (e) { + await this.printLogs(true) + this.writeLog(`openMessageCursorLite exception: ${String(e)}`, true) + return { success: false, error: '创建游标异常,请查看日志' } + } + } + + async fetchMessageBatch(cursor: number): Promise<{ success: boolean; rows?: any[]; hasMore?: boolean; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const outHasMore = [0] + const result = this.wcdbFetchMessageBatch(this.handle, cursor, outPtr, outHasMore) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取批次失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析批次失败' } + const rows = JSON.parse(jsonStr) + return { success: true, rows, hasMore: outHasMore[0] === 1 } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async closeMessageCursor(cursor: number): Promise<{ success: boolean; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const result = this.wcdbCloseMessageCursor(this.handle, cursor) + if (result !== 0) { + return { success: false, error: `关闭游标失败: ${result}` } + } + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const result = this.wcdbExecQuery(this.handle, kind, path, sql, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `执行查询失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析查询结果失败' } + const rows = JSON.parse(jsonStr) + return { success: true, rows } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getEmoticonCdnUrl(dbPath: string, md5: string): Promise<{ success: boolean; url?: string; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetEmoticonCdnUrl(this.handle, dbPath, md5, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取表情 URL 失败: ${result}` } + } + const urlStr = this.decodeJsonPtr(outPtr[0]) + if (urlStr === null) return { success: false, error: '解析表情 URL 失败' } + return { success: true, url: urlStr || undefined } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async listMessageDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + try { + const outPtr = [null as any] + const result = this.wcdbListMessageDbs(this.handle, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取消息库列表失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析消息库列表失败' } + const data = JSON.parse(jsonStr) + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async listMediaDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + try { + const outPtr = [null as any] + const result = this.wcdbListMediaDbs(this.handle, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取媒体库列表失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析媒体库列表失败' } + const data = JSON.parse(jsonStr) + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessageById(this.handle, sessionId, localId, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `查询消息失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析消息失败' } + const message = JSON.parse(jsonStr) + // 处理 wcdb_get_message_by_id 返回空对象的情况 + if (Object.keys(message).length === 0) return { success: false, error: '未找到消息' } + return { success: true, message } + } catch (e) { + return { success: false, error: String(e) } + } + } +} + +export const wcdbService = new WcdbService() diff --git a/index.html b/index.html new file mode 100644 index 0000000..c9c6afb --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + WeFlow + + +
+ + + diff --git a/installer.nsh b/installer.nsh new file mode 100644 index 0000000..78ef827 --- /dev/null +++ b/installer.nsh @@ -0,0 +1,18 @@ +; 高 DPI 支持 +ManifestDPIAware true + +!include "WordFunc.nsh" + +!macro customInit + ; 设置 DPI 感知 + System::Call 'USER32::SetProcessDPIAware()' +!macroend + +; 在安装开始前修正安装目录 +!macro preInit + ; 如果安装目录不以 WeFlow 结尾,自动追加 + ${WordFind} "$INSTDIR" "\" "-1" $R0 + ${If} $R0 != "WeFlow" + StrCpy $INSTDIR "$INSTDIR\WeFlow" + ${EndIf} +!macroend diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ba153dd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,9332 @@ +{ + "name": "weflow", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "weflow", + "version": "1.0.0", + "hasInstallScript": true, + "dependencies": { + "better-sqlite3": "^12.5.0", + "echarts": "^5.5.1", + "echarts-for-react": "^3.0.2", + "electron-store": "^10.0.0", + "electron-updater": "^6.3.9", + "fzstd": "^0.1.1", + "html2canvas": "^1.4.1", + "jieba-wasm": "^2.2.0", + "jszip": "^3.10.1", + "koffi": "^2.9.0", + "lucide-react": "^0.562.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-router-dom": "^7.1.1", + "wechat-emojis": "^1.0.2", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@electron/rebuild": "^4.0.2", + "@types/better-sqlite3": "^7.6.13", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.3.4", + "electron": "^39.2.7", + "electron-builder": "^25.1.8", + "sass": "^1.83.0", + "sharp": "^0.34.5", + "typescript": "^5.6.3", + "vite": "^6.0.5", + "vite-plugin-electron": "^0.28.8", + "vite-plugin-electron-renderer": "^0.14.6" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmmirror.com/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmmirror.com/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/@electron/osx-sign/-/osx-sign-1.3.1.tgz", + "integrity": "sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmmirror.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@electron/rebuild/-/rebuild-4.0.2.tgz", + "integrity": "sha512-8iZWVPvOpCdIc5Pj5udQV3PeO7liJVC7BBUSizl1HCfP7ZxYc9Kqz0c3PDNj2HQ5cQfJ5JaBeJIYKPjAvLn2Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "got": "^11.7.0", + "graceful-fs": "^4.2.11", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^11.2.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^6.0.5", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/@electron/universal/-/universal-2.0.1.tgz", + "integrity": "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.2.7", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.3.3", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@npmcli/agent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/@npmcli/agent/-/agent-3.0.0.tgz", + "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@npmcli/fs": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/@npmcli/fs/-/fs-4.0.0.tgz", + "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmmirror.com/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmmirror.com/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmmirror.com/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.0.3", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmmirror.com/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmmirror.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.10", + "resolved": "https://registry.npmmirror.com/app-builder-bin/-/app-builder-bin-5.0.0-alpha.10.tgz", + "integrity": "sha512-Ev4jj3D7Bo+O0GPD2NMvJl+PGiBAfS7pUGawntBNpCbxtpncfUixqFj9z9Jme7V7s3LBGqsWZZP54fxBX3JKJw==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "25.1.8", + "resolved": "https://registry.npmmirror.com/app-builder-lib/-/app-builder-lib-25.1.8.tgz", + "integrity": "sha512-pCqe7dfsQFBABC1jeKZXQWhGcCPF3rPCXDdfqVKjIeWBcXzyC1iOWZdfFhGl+S9MyE/k//DFmC6FzuGAUudNDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.1", + "@electron/rebuild": "3.6.1", + "@electron/universal": "2.0.1", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "bluebird-lst": "^1.0.9", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chromium-pickle-js": "^0.2.0", + "config-file-ts": "0.2.8-rc1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "25.1.7", + "form-data": "^4.0.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "is-ci": "^3.0.0", + "isbinaryfile": "^5.0.0", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.0", + "resedit": "^1.7.0", + "sanitize-filename": "^1.6.3", + "semver": "^7.3.8", + "tar": "^6.1.12", + "temp-file": "^3.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "25.1.8", + "electron-builder-squirrel-windows": "25.1.8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/rebuild": { + "version": "3.6.1", + "resolved": "https://registry.npmmirror.com/@electron/rebuild/-/rebuild-3.6.1.tgz", + "integrity": "sha512-f6596ZHpEq/YskUd8emYvOUne89ij8mQgjYFA5ru25QwbrRO+t1SImofdDv7kKOuWCmVOuU5tvfkbgGxIl3E/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "fs-extra": "^10.0.0", + "got": "^11.7.0", + "node-abi": "^3.45.0", + "node-api-version": "^0.2.0", + "node-gyp": "^9.0.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^6.0.5", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/app-builder-lib/node_modules/@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/app-builder-lib/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmmirror.com/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/cacache/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/app-builder-lib/node_modules/cacache/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/app-builder-lib/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/app-builder-lib/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmmirror.com/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/app-builder-lib/node_modules/minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/app-builder-lib/node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/app-builder-lib/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/app-builder-lib/node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/node-gyp": { + "version": "9.4.1", + "resolved": "https://registry.npmmirror.com/node-gyp/-/node-gyp-9.4.1.tgz", + "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^6.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^12.13 || ^14.13 || >=16" + } + }, + "node_modules/app-builder-lib/node_modules/nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/app-builder-lib/node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/app-builder-lib/node_modules/ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "dev": true, + "license": "ISC" + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/atomically": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/atomically/-/atomically-2.1.0.tgz", + "integrity": "sha512-+gDffFXRW6sl/HCwbta7zK4uNqbPjv4YJEAdz7Vu+FLQHe77eZ4bvbJGi4hE0QPeJlMYMA3piXEr1UL3dAwx7Q==", + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.11", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/better-sqlite3": { + "version": "12.5.0", + "resolved": "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-12.5.0.tgz", + "integrity": "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/bluebird-lst": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/bluebird-lst/-/bluebird-lst-1.0.9.tgz", + "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.5.5" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "25.1.7", + "resolved": "https://registry.npmmirror.com/builder-util/-/builder-util-25.1.7.tgz", + "integrity": "sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.10", + "bluebird-lst": "^1.0.9", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "is-ci": "^3.0.0", + "js-yaml": "^4.1.0", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.2.10", + "resolved": "https://registry.npmmirror.com/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", + "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cacache": { + "version": "19.0.1", + "resolved": "https://registry.npmmirror.com/cacache/-/cacache-19.0.1.tgz", + "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmmirror.com/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/tar": { + "version": "7.5.2", + "resolved": "https://registry.npmmirror.com/tar/-/tar-7.5.2.tgz", + "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cacache/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmmirror.com/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001762", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", + "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmmirror.com/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/conf": { + "version": "14.0.0", + "resolved": "https://registry.npmmirror.com/conf/-/conf-14.0.0.tgz", + "integrity": "sha512-L6BuueHTRuJHQvQVc6YXYZRtN5vJUtOdCTLn0tRYYV5azfbAFcPghB5zEE40mVrV6w7slMTqUfkDomutIK14fw==", + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "atomically": "^2.0.3", + "debounce-fn": "^6.0.0", + "dot-prop": "^9.0.0", + "env-paths": "^3.0.0", + "json-schema-typed": "^8.0.1", + "semver": "^7.7.2", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/conf/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/config-file-ts": { + "version": "0.2.8-rc1", + "resolved": "https://registry.npmmirror.com/config-file-ts/-/config-file-ts-0.2.8-rc1.tgz", + "integrity": "sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.3.12", + "typescript": "^5.4.3" + } + }, + "node_modules/config-file-ts/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/config-file-ts/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmmirror.com/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmmirror.com/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debounce-fn": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/debounce-fn/-/debounce-fn-6.0.0.tgz", + "integrity": "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmmirror.com/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "25.1.8", + "resolved": "https://registry.npmmirror.com/dmg-builder/-/dmg-builder-25.1.8.tgz", + "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "25.1.8", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmmirror.com/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmmirror.com/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/echarts-for-react": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/echarts-for-react/-/echarts-for-react-3.0.5.tgz", + "integrity": "sha512-YpEI5Ty7O/2nvCfQ7ybNa+S90DwE8KYZWacGvJW4luUqywP7qStQ+pxDlYOmr4jGDu10mhEkiAuMKcUlT4W5vg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "size-sensor": "^1.0.1" + }, + "peerDependencies": { + "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", + "react": "^15.0.0 || >=16.0.0" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmmirror.com/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "39.2.7", + "resolved": "https://registry.npmmirror.com/electron/-/electron-39.2.7.tgz", + "integrity": "sha512-KU0uFS6LSTh4aOIC3miolcbizOFP7N1M46VTYVfqIgFiuA2ilfNaOHLDS9tCMvwwHRowAsvqBrh9NgMXcTOHCQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^22.7.7", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "25.1.8", + "resolved": "https://registry.npmmirror.com/electron-builder/-/electron-builder-25.1.8.tgz", + "integrity": "sha512-poRgAtUHHOnlzZnc9PK4nzG53xh74wj2Jy7jkTrqZ0MWPoHGh1M2+C//hGeYdA+4K8w4yiVCNYoLXF7ySj2Wig==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "25.1.8", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "dmg-builder": "25.1.8", + "fs-extra": "^10.1.0", + "is-ci": "^3.0.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "25.1.8", + "resolved": "https://registry.npmmirror.com/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-25.1.8.tgz", + "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "25.1.8", + "archiver": "^5.3.1", + "builder-util": "25.1.7", + "fs-extra": "^10.1.0" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "25.1.7", + "resolved": "https://registry.npmmirror.com/electron-publish/-/electron-publish-25.1.7.tgz", + "integrity": "sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-store": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/electron-store/-/electron-store-10.1.0.tgz", + "integrity": "sha512-oL8bRy7pVCLpwhmXy05Rh/L6O93+k9t6dqSw0+MckIc3OmCTZm6Mp04Q4f/J0rtu84Ky6ywkR8ivtGOmrq+16w==", + "license": "MIT", + "dependencies": { + "conf": "^14.0.0", + "type-fest": "^4.41.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/electron-updater": { + "version": "6.7.3", + "resolved": "https://registry.npmmirror.com/electron-updater/-/electron-updater-6.7.3.tgz", + "integrity": "sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.5.1", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/builder-util-runtime": { + "version": "9.5.1", + "resolved": "https://registry.npmmirror.com/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", + "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "22.19.3", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.19.3.tgz", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmmirror.com/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fzstd": { + "version": "0.1.1", + "resolved": "https://registry.npmmirror.com/fzstd/-/fzstd-0.1.1.tgz", + "integrity": "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==", + "license": "MIT" + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmmirror.com/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmmirror.com/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmmirror.com/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmmirror.com/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jieba-wasm": { + "version": "2.4.0", + "resolved": "https://registry.npmmirror.com/jieba-wasm/-/jieba-wasm-2.4.0.tgz", + "integrity": "sha512-ZvQdS+FGifrFXZIXSgOyOgEz+1wdy1P4vSvwe37FVtku9ycSdHTZbHqF5i9tMN1JucoAmeiLBeI6/YaqcGD+KA==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/koffi": { + "version": "2.14.1", + "resolved": "https://registry.npmmirror.com/koffi/-/koffi-2.14.1.tgz", + "integrity": "sha512-IMFL3IbRDXacSIjs7pPbPxgNlJ2hUtawQXU2QPdr6iw38jmv5AesAUG8HPX00xl0PPA2BbEa3noTw1YdHY+gHg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://buymeacoffee.com/koromix" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.562.0", + "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/make-fetch-happen": { + "version": "14.0.3", + "resolved": "https://registry.npmmirror.com/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", + "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/minipass-fetch/-/minipass-fetch-4.0.1.tgz", + "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmmirror.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "4.24.0", + "resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-4.24.0.tgz", + "integrity": "sha512-u2EC1CeNe25uVtX3EZbdQ275c74zdZmmpzrHEQh2aIYqoVjlglfUpOX9YY85x1nlBydEKDVaSmMNhR7N82Qj8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-gyp": { + "version": "11.5.0", + "resolved": "https://registry.npmmirror.com/node-gyp/-/node-gyp-11.5.0.tgz", + "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "tinyglobby": "^0.2.12", + "which": "^5.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/node-gyp/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/node-gyp/node_modules/tar": { + "version": "7.5.2", + "resolved": "https://registry.npmmirror.com/tar/-/tar-7.5.2.tgz", + "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/node-gyp/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmmirror.com/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmmirror.com/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proc-log": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.11.0", + "resolved": "https://registry.npmmirror.com/react-router/-/react-router-7.11.0.tgz", + "integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.11.0", + "resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.11.0.tgz", + "integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==", + "license": "MIT", + "dependencies": { + "react-router": "7.11.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmmirror.com/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmmirror.com/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmmirror.com/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sass": { + "version": "1.97.2", + "resolved": "https://registry.npmmirror.com/sass/-/sass-1.97.2.tgz", + "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sax": { + "version": "1.4.3", + "resolved": "https://registry.npmmirror.com/sax/-/sax-1.4.3.tgz", + "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", + "license": "BlueOak-1.0.0" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/size-sensor": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/size-sensor/-/size-sensor-1.0.2.tgz", + "integrity": "sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw==", + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmmirror.com/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmmirror.com/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ssri": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/ssri/-/ssri-12.0.0.tgz", + "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "license": "MIT" + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmmirror.com/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/tar/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmmirror.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/unique-filename/-/unique-filename-4.0.0.tgz", + "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/unique-slug": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/unique-slug/-/unique-slug-5.0.0.tgz", + "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-electron": { + "version": "0.28.8", + "resolved": "https://registry.npmmirror.com/vite-plugin-electron/-/vite-plugin-electron-0.28.8.tgz", + "integrity": "sha512-ir+B21oSGK9j23OEvt4EXyco9xDCaF6OGFe0V/8Zc0yL2+HMyQ6mmNQEIhXsEsZCSfIowBpwQBeHH4wVsfraeg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite-plugin-electron-renderer": "*" + }, + "peerDependenciesMeta": { + "vite-plugin-electron-renderer": { + "optional": true + } + } + }, + "node_modules/vite-plugin-electron-renderer": { + "version": "0.14.6", + "resolved": "https://registry.npmmirror.com/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", + "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/wechat-emojis": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wechat-emojis/-/wechat-emojis-1.0.2.tgz", + "integrity": "sha512-T1drHGy92rKm/Vo7LRkU4D4wdREpVTjAMEa4gR1NB9IAyck3qmmewFSrnEEIyZfsv3SXTA7X1kt6Smt0UKVCyw==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmmirror.com/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zustand": { + "version": "5.0.9", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..16fa836 --- /dev/null +++ b/package.json @@ -0,0 +1,95 @@ +{ + "name": "weflow", + "version": "1.0.0", + "description": "WeFlow - 微信聊天记录查看工具", + "main": "dist-electron/main.js", + "scripts": { + "dev": "vite", + "build": "tsc && vite build && electron-builder", + "preview": "vite preview", + "electron:dev": "vite --mode electron", + "electron:build": "npm run build", + "postinstall": "electron-rebuild" + }, + "dependencies": { + "better-sqlite3": "^12.5.0", + "echarts": "^5.5.1", + "echarts-for-react": "^3.0.2", + "electron-store": "^10.0.0", + "electron-updater": "^6.3.9", + "fzstd": "^0.1.1", + "html2canvas": "^1.4.1", + "jieba-wasm": "^2.2.0", + "jszip": "^3.10.1", + "koffi": "^2.9.0", + "lucide-react": "^0.562.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-router-dom": "^7.1.1", + "wechat-emojis": "^1.0.2", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@electron/rebuild": "^4.0.2", + "@types/better-sqlite3": "^7.6.13", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.3.4", + "electron": "^39.2.7", + "electron-builder": "^25.1.8", + "sass": "^1.83.0", + "sharp": "^0.34.5", + "typescript": "^5.6.3", + "vite": "^6.0.5", + "vite-plugin-electron": "^0.28.8", + "vite-plugin-electron-renderer": "^0.14.6" + }, + "build": { + "appId": "com.WeFlow.app", + "productName": "WeFlow", + "artifactName": "${productName}-${version}-Setup.${ext}", + "directories": { + "output": "release" + }, + "win": { + "target": [ + "nsis" + ], + "icon": "public/icon.ico" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "unicode": true, + "installerLanguages": [ + "zh_CN", + "en_US" + ], + "language": "2052", + "displayLanguageSelector": false, + "include": "installer.nsh", + "installerIcon": "public/icon.ico", + "uninstallerIcon": "public/icon.ico", + "installerHeaderIcon": "public/icon.ico", + "perMachine": false, + "allowElevation": true, + "installerSidebar": null, + "uninstallerSidebar": null + }, + "extraResources": [ + { + "from": "resources/", + "to": "resources/" + }, + { + "from": "public/icon.ico", + "to": "icon.ico" + } + ], + "files": [ + "dist/**/*", + "dist-electron/**/*" + ] + } +} diff --git a/public/assets/animal/发抖.png b/public/assets/animal/发抖.png new file mode 100644 index 0000000..2bc6cf2 Binary files /dev/null and b/public/assets/animal/发抖.png differ diff --git a/public/assets/animal/猪头.png b/public/assets/animal/猪头.png new file mode 100644 index 0000000..c14749e Binary files /dev/null and b/public/assets/animal/猪头.png differ diff --git a/public/assets/animal/跳跳.png b/public/assets/animal/跳跳.png new file mode 100644 index 0000000..ae1307a Binary files /dev/null and b/public/assets/animal/跳跳.png differ diff --git a/public/assets/animal/转圈.png b/public/assets/animal/转圈.png new file mode 100644 index 0000000..7aa3b5a Binary files /dev/null and b/public/assets/animal/转圈.png differ diff --git a/public/assets/blessing/庆祝.png b/public/assets/blessing/庆祝.png new file mode 100644 index 0000000..6b3c639 Binary files /dev/null and b/public/assets/blessing/庆祝.png differ diff --git a/public/assets/blessing/烟花.png b/public/assets/blessing/烟花.png new file mode 100644 index 0000000..58d2848 Binary files /dev/null and b/public/assets/blessing/烟花.png differ diff --git a/public/assets/blessing/爆竹.png b/public/assets/blessing/爆竹.png new file mode 100644 index 0000000..52ac8fb Binary files /dev/null and b/public/assets/blessing/爆竹.png differ diff --git a/public/assets/blessing/發.png b/public/assets/blessing/發.png new file mode 100644 index 0000000..2af454c Binary files /dev/null and b/public/assets/blessing/發.png differ diff --git a/public/assets/blessing/礼物.png b/public/assets/blessing/礼物.png new file mode 100644 index 0000000..6082fba Binary files /dev/null and b/public/assets/blessing/礼物.png differ diff --git a/public/assets/blessing/福.png b/public/assets/blessing/福.png new file mode 100644 index 0000000..d4a5b17 Binary files /dev/null and b/public/assets/blessing/福.png differ diff --git a/public/assets/blessing/红包.png b/public/assets/blessing/红包.png new file mode 100644 index 0000000..28b9698 Binary files /dev/null and b/public/assets/blessing/红包.png differ diff --git a/public/assets/face/666.png b/public/assets/face/666.png new file mode 100644 index 0000000..f947dc6 Binary files /dev/null and b/public/assets/face/666.png differ diff --git a/public/assets/face/Emm.png b/public/assets/face/Emm.png new file mode 100644 index 0000000..8fb9370 Binary files /dev/null and b/public/assets/face/Emm.png differ diff --git a/public/assets/face/亲亲.png b/public/assets/face/亲亲.png new file mode 100644 index 0000000..3190fa3 Binary files /dev/null and b/public/assets/face/亲亲.png differ diff --git a/public/assets/face/偷笑.png b/public/assets/face/偷笑.png new file mode 100644 index 0000000..51836d1 Binary files /dev/null and b/public/assets/face/偷笑.png differ diff --git a/public/assets/face/傲慢.png b/public/assets/face/傲慢.png new file mode 100644 index 0000000..cfd4cf5 Binary files /dev/null and b/public/assets/face/傲慢.png differ diff --git a/public/assets/face/再见.png b/public/assets/face/再见.png new file mode 100644 index 0000000..8ef994b Binary files /dev/null and b/public/assets/face/再见.png differ diff --git a/public/assets/face/加油.png b/public/assets/face/加油.png new file mode 100644 index 0000000..911ca55 Binary files /dev/null and b/public/assets/face/加油.png differ diff --git a/public/assets/face/发呆.png b/public/assets/face/发呆.png new file mode 100644 index 0000000..e6e388f Binary files /dev/null and b/public/assets/face/发呆.png differ diff --git a/public/assets/face/发怒.png b/public/assets/face/发怒.png new file mode 100644 index 0000000..c8cba00 Binary files /dev/null and b/public/assets/face/发怒.png differ diff --git a/public/assets/face/可怜.png b/public/assets/face/可怜.png new file mode 100644 index 0000000..1e75cbb Binary files /dev/null and b/public/assets/face/可怜.png differ diff --git a/public/assets/face/右哼哼.png b/public/assets/face/右哼哼.png new file mode 100644 index 0000000..e466e2a Binary files /dev/null and b/public/assets/face/右哼哼.png differ diff --git a/public/assets/face/叹气.png b/public/assets/face/叹气.png new file mode 100644 index 0000000..2840584 Binary files /dev/null and b/public/assets/face/叹气.png differ diff --git a/public/assets/face/吃瓜.png b/public/assets/face/吃瓜.png new file mode 100644 index 0000000..64f72ad Binary files /dev/null and b/public/assets/face/吃瓜.png differ diff --git a/public/assets/face/吐.png b/public/assets/face/吐.png new file mode 100644 index 0000000..b3e072e Binary files /dev/null and b/public/assets/face/吐.png differ diff --git a/public/assets/face/呲牙.png b/public/assets/face/呲牙.png new file mode 100644 index 0000000..abce5e0 Binary files /dev/null and b/public/assets/face/呲牙.png differ diff --git a/public/assets/face/咒骂.png b/public/assets/face/咒骂.png new file mode 100644 index 0000000..0a8c0bf Binary files /dev/null and b/public/assets/face/咒骂.png differ diff --git a/public/assets/face/哇.png b/public/assets/face/哇.png new file mode 100644 index 0000000..5d1c179 Binary files /dev/null and b/public/assets/face/哇.png differ diff --git a/public/assets/face/嘘.png b/public/assets/face/嘘.png new file mode 100644 index 0000000..a670056 Binary files /dev/null and b/public/assets/face/嘘.png differ diff --git a/public/assets/face/嘿哈.png b/public/assets/face/嘿哈.png new file mode 100644 index 0000000..d424a3f Binary files /dev/null and b/public/assets/face/嘿哈.png differ diff --git a/public/assets/face/囧.png b/public/assets/face/囧.png new file mode 100644 index 0000000..fc7fbfc Binary files /dev/null and b/public/assets/face/囧.png differ diff --git a/public/assets/face/困.png b/public/assets/face/困.png new file mode 100644 index 0000000..148c66f Binary files /dev/null and b/public/assets/face/困.png differ diff --git a/public/assets/face/坏笑.png b/public/assets/face/坏笑.png new file mode 100644 index 0000000..8585d82 Binary files /dev/null and b/public/assets/face/坏笑.png differ diff --git a/public/assets/face/大哭.png b/public/assets/face/大哭.png new file mode 100644 index 0000000..3c64886 Binary files /dev/null and b/public/assets/face/大哭.png differ diff --git a/public/assets/face/天啊.png b/public/assets/face/天啊.png new file mode 100644 index 0000000..67d1b97 Binary files /dev/null and b/public/assets/face/天啊.png differ diff --git a/public/assets/face/失望.png b/public/assets/face/失望.png new file mode 100644 index 0000000..d38c888 Binary files /dev/null and b/public/assets/face/失望.png differ diff --git a/public/assets/face/奸笑.png b/public/assets/face/奸笑.png new file mode 100644 index 0000000..895c591 Binary files /dev/null and b/public/assets/face/奸笑.png differ diff --git a/public/assets/face/好的.png b/public/assets/face/好的.png new file mode 100644 index 0000000..7005ce9 Binary files /dev/null and b/public/assets/face/好的.png differ diff --git a/public/assets/face/委屈.png b/public/assets/face/委屈.png new file mode 100644 index 0000000..b6491fd Binary files /dev/null and b/public/assets/face/委屈.png differ diff --git a/public/assets/face/害羞.png b/public/assets/face/害羞.png new file mode 100644 index 0000000..50ec786 Binary files /dev/null and b/public/assets/face/害羞.png differ diff --git a/public/assets/face/尴尬.png b/public/assets/face/尴尬.png new file mode 100644 index 0000000..6d4900e Binary files /dev/null and b/public/assets/face/尴尬.png differ diff --git a/public/assets/face/得意.png b/public/assets/face/得意.png new file mode 100644 index 0000000..0bac94b Binary files /dev/null and b/public/assets/face/得意.png differ diff --git a/public/assets/face/微笑.png b/public/assets/face/微笑.png new file mode 100644 index 0000000..a3195d6 Binary files /dev/null and b/public/assets/face/微笑.png differ diff --git a/public/assets/face/快哭了.png b/public/assets/face/快哭了.png new file mode 100644 index 0000000..f0558ba Binary files /dev/null and b/public/assets/face/快哭了.png differ diff --git a/public/assets/face/恐惧.png b/public/assets/face/恐惧.png new file mode 100644 index 0000000..6e935b2 Binary files /dev/null and b/public/assets/face/恐惧.png differ diff --git a/public/assets/face/悠闲.png b/public/assets/face/悠闲.png new file mode 100644 index 0000000..dd19c44 Binary files /dev/null and b/public/assets/face/悠闲.png differ diff --git a/public/assets/face/惊恐.png b/public/assets/face/惊恐.png new file mode 100644 index 0000000..8bfda02 Binary files /dev/null and b/public/assets/face/惊恐.png differ diff --git a/public/assets/face/惊讶.png b/public/assets/face/惊讶.png new file mode 100644 index 0000000..33feac5 Binary files /dev/null and b/public/assets/face/惊讶.png differ diff --git a/public/assets/face/愉快.png b/public/assets/face/愉快.png new file mode 100644 index 0000000..a78c8e6 Binary files /dev/null and b/public/assets/face/愉快.png differ diff --git a/public/assets/face/憨笑.png b/public/assets/face/憨笑.png new file mode 100644 index 0000000..b554894 Binary files /dev/null and b/public/assets/face/憨笑.png differ diff --git a/public/assets/face/打脸.png b/public/assets/face/打脸.png new file mode 100644 index 0000000..d12031a Binary files /dev/null and b/public/assets/face/打脸.png differ diff --git a/public/assets/face/抓狂.png b/public/assets/face/抓狂.png new file mode 100644 index 0000000..b440837 Binary files /dev/null and b/public/assets/face/抓狂.png differ diff --git a/public/assets/face/抠鼻.png b/public/assets/face/抠鼻.png new file mode 100644 index 0000000..e44adf6 Binary files /dev/null and b/public/assets/face/抠鼻.png differ diff --git a/public/assets/face/捂脸.png b/public/assets/face/捂脸.png new file mode 100644 index 0000000..ea8a13c Binary files /dev/null and b/public/assets/face/捂脸.png differ diff --git a/public/assets/face/撇嘴.png b/public/assets/face/撇嘴.png new file mode 100644 index 0000000..937ae74 Binary files /dev/null and b/public/assets/face/撇嘴.png differ diff --git a/public/assets/face/擦汗.png b/public/assets/face/擦汗.png new file mode 100644 index 0000000..b9256ad Binary files /dev/null and b/public/assets/face/擦汗.png differ diff --git a/public/assets/face/敲打.png b/public/assets/face/敲打.png new file mode 100644 index 0000000..5eb4480 Binary files /dev/null and b/public/assets/face/敲打.png differ diff --git a/public/assets/face/无语.png b/public/assets/face/无语.png new file mode 100644 index 0000000..443f9d6 Binary files /dev/null and b/public/assets/face/无语.png differ diff --git a/public/assets/face/旺柴.png b/public/assets/face/旺柴.png new file mode 100644 index 0000000..02000fb Binary files /dev/null and b/public/assets/face/旺柴.png differ diff --git a/public/assets/face/晕.png b/public/assets/face/晕.png new file mode 100644 index 0000000..8b0a5a2 Binary files /dev/null and b/public/assets/face/晕.png differ diff --git a/public/assets/face/机智.png b/public/assets/face/机智.png new file mode 100644 index 0000000..999d4b5 Binary files /dev/null and b/public/assets/face/机智.png differ diff --git a/public/assets/face/汗.png b/public/assets/face/汗.png new file mode 100644 index 0000000..3b940c5 Binary files /dev/null and b/public/assets/face/汗.png differ diff --git a/public/assets/face/流泪.png b/public/assets/face/流泪.png new file mode 100644 index 0000000..bdfe6fc Binary files /dev/null and b/public/assets/face/流泪.png differ diff --git a/public/assets/face/生病.png b/public/assets/face/生病.png new file mode 100644 index 0000000..39fdd91 Binary files /dev/null and b/public/assets/face/生病.png differ diff --git a/public/assets/face/疑问.png b/public/assets/face/疑问.png new file mode 100644 index 0000000..c2bb9c9 Binary files /dev/null and b/public/assets/face/疑问.png differ diff --git a/public/assets/face/白眼.png b/public/assets/face/白眼.png new file mode 100644 index 0000000..fa261a4 Binary files /dev/null and b/public/assets/face/白眼.png differ diff --git a/public/assets/face/皱眉.png b/public/assets/face/皱眉.png new file mode 100644 index 0000000..123bf08 Binary files /dev/null and b/public/assets/face/皱眉.png differ diff --git a/public/assets/face/睡.png b/public/assets/face/睡.png new file mode 100644 index 0000000..8b37848 Binary files /dev/null and b/public/assets/face/睡.png differ diff --git a/public/assets/face/破涕为笑.png b/public/assets/face/破涕为笑.png new file mode 100644 index 0000000..447dcb6 Binary files /dev/null and b/public/assets/face/破涕为笑.png differ diff --git a/public/assets/face/社会社会.png b/public/assets/face/社会社会.png new file mode 100644 index 0000000..e4a311b Binary files /dev/null and b/public/assets/face/社会社会.png differ diff --git a/public/assets/face/笑脸.png b/public/assets/face/笑脸.png new file mode 100644 index 0000000..e3ac78d Binary files /dev/null and b/public/assets/face/笑脸.png differ diff --git a/public/assets/face/翻白眼.png b/public/assets/face/翻白眼.png new file mode 100644 index 0000000..4ebf05d Binary files /dev/null and b/public/assets/face/翻白眼.png differ diff --git a/public/assets/face/耶.png b/public/assets/face/耶.png new file mode 100644 index 0000000..969fd94 Binary files /dev/null and b/public/assets/face/耶.png differ diff --git a/public/assets/face/脸红.png b/public/assets/face/脸红.png new file mode 100644 index 0000000..92ba41d Binary files /dev/null and b/public/assets/face/脸红.png differ diff --git a/public/assets/face/色.png b/public/assets/face/色.png new file mode 100644 index 0000000..630ae0e Binary files /dev/null and b/public/assets/face/色.png differ diff --git a/public/assets/face/苦涩.png b/public/assets/face/苦涩.png new file mode 100644 index 0000000..bf0dd64 Binary files /dev/null and b/public/assets/face/苦涩.png differ diff --git a/public/assets/face/衰.png b/public/assets/face/衰.png new file mode 100644 index 0000000..47ed471 Binary files /dev/null and b/public/assets/face/衰.png differ diff --git a/public/assets/face/裂开.png b/public/assets/face/裂开.png new file mode 100644 index 0000000..626d479 Binary files /dev/null and b/public/assets/face/裂开.png differ diff --git a/public/assets/face/让我看看.png b/public/assets/face/让我看看.png new file mode 100644 index 0000000..c3fafee Binary files /dev/null and b/public/assets/face/让我看看.png differ diff --git a/public/assets/face/调皮.png b/public/assets/face/调皮.png new file mode 100644 index 0000000..5aba419 Binary files /dev/null and b/public/assets/face/调皮.png differ diff --git a/public/assets/face/鄙视.png b/public/assets/face/鄙视.png new file mode 100644 index 0000000..3d32430 Binary files /dev/null and b/public/assets/face/鄙视.png differ diff --git a/public/assets/face/闭嘴.png b/public/assets/face/闭嘴.png new file mode 100644 index 0000000..d89f350 Binary files /dev/null and b/public/assets/face/闭嘴.png differ diff --git a/public/assets/face/阴险.png b/public/assets/face/阴险.png new file mode 100644 index 0000000..d37f39c Binary files /dev/null and b/public/assets/face/阴险.png differ diff --git a/public/assets/face/难过.png b/public/assets/face/难过.png new file mode 100644 index 0000000..636ae7b Binary files /dev/null and b/public/assets/face/难过.png differ diff --git a/public/assets/face/骷髅.png b/public/assets/face/骷髅.png new file mode 100644 index 0000000..f883579 Binary files /dev/null and b/public/assets/face/骷髅.png differ diff --git a/public/assets/face/鼓掌.png b/public/assets/face/鼓掌.png new file mode 100644 index 0000000..c6963bc Binary files /dev/null and b/public/assets/face/鼓掌.png differ diff --git a/public/assets/gesture/OK.png b/public/assets/gesture/OK.png new file mode 100644 index 0000000..2d0f6e5 Binary files /dev/null and b/public/assets/gesture/OK.png differ diff --git a/public/assets/gesture/勾引.png b/public/assets/gesture/勾引.png new file mode 100644 index 0000000..28e3733 Binary files /dev/null and b/public/assets/gesture/勾引.png differ diff --git a/public/assets/gesture/合十.png b/public/assets/gesture/合十.png new file mode 100644 index 0000000..eca2b73 Binary files /dev/null and b/public/assets/gesture/合十.png differ diff --git a/public/assets/gesture/弱.png b/public/assets/gesture/弱.png new file mode 100644 index 0000000..be8b1a8 Binary files /dev/null and b/public/assets/gesture/弱.png differ diff --git a/public/assets/gesture/强.png b/public/assets/gesture/强.png new file mode 100644 index 0000000..f81c624 Binary files /dev/null and b/public/assets/gesture/强.png differ diff --git a/public/assets/gesture/抱拳.png b/public/assets/gesture/抱拳.png new file mode 100644 index 0000000..51d17db Binary files /dev/null and b/public/assets/gesture/抱拳.png differ diff --git a/public/assets/gesture/拥抱.png b/public/assets/gesture/拥抱.png new file mode 100644 index 0000000..0bbcdb9 Binary files /dev/null and b/public/assets/gesture/拥抱.png differ diff --git a/public/assets/gesture/拳头.png b/public/assets/gesture/拳头.png new file mode 100644 index 0000000..91c10e0 Binary files /dev/null and b/public/assets/gesture/拳头.png differ diff --git a/public/assets/gesture/握手.png b/public/assets/gesture/握手.png new file mode 100644 index 0000000..9e6be93 Binary files /dev/null and b/public/assets/gesture/握手.png differ diff --git a/public/assets/gesture/胜利.png b/public/assets/gesture/胜利.png new file mode 100644 index 0000000..2a54535 Binary files /dev/null and b/public/assets/gesture/胜利.png differ diff --git a/public/assets/other/便便.png b/public/assets/other/便便.png new file mode 100644 index 0000000..9ee508f Binary files /dev/null and b/public/assets/other/便便.png differ diff --git a/public/assets/other/凋谢.png b/public/assets/other/凋谢.png new file mode 100644 index 0000000..b189bb9 Binary files /dev/null and b/public/assets/other/凋谢.png differ diff --git a/public/assets/other/咖啡.png b/public/assets/other/咖啡.png new file mode 100644 index 0000000..91b7c79 Binary files /dev/null and b/public/assets/other/咖啡.png differ diff --git a/public/assets/other/啤酒.png b/public/assets/other/啤酒.png new file mode 100644 index 0000000..81d40ba Binary files /dev/null and b/public/assets/other/啤酒.png differ diff --git a/public/assets/other/嘴唇.png b/public/assets/other/嘴唇.png new file mode 100644 index 0000000..858e854 Binary files /dev/null and b/public/assets/other/嘴唇.png differ diff --git a/public/assets/other/太阳.png b/public/assets/other/太阳.png new file mode 100644 index 0000000..04578c4 Binary files /dev/null and b/public/assets/other/太阳.png differ diff --git a/public/assets/other/心碎.png b/public/assets/other/心碎.png new file mode 100644 index 0000000..dc23ec8 Binary files /dev/null and b/public/assets/other/心碎.png differ diff --git a/public/assets/other/月亮.png b/public/assets/other/月亮.png new file mode 100644 index 0000000..20ed34e Binary files /dev/null and b/public/assets/other/月亮.png differ diff --git a/public/assets/other/炸弹.png b/public/assets/other/炸弹.png new file mode 100644 index 0000000..9ece24c Binary files /dev/null and b/public/assets/other/炸弹.png differ diff --git a/public/assets/other/爱心.png b/public/assets/other/爱心.png new file mode 100644 index 0000000..aa9b744 Binary files /dev/null and b/public/assets/other/爱心.png differ diff --git a/public/assets/other/玫瑰.png b/public/assets/other/玫瑰.png new file mode 100644 index 0000000..83cc3b2 Binary files /dev/null and b/public/assets/other/玫瑰.png differ diff --git a/public/assets/other/菜刀.png b/public/assets/other/菜刀.png new file mode 100644 index 0000000..77c5a13 Binary files /dev/null and b/public/assets/other/菜刀.png differ diff --git a/public/assets/other/蛋糕.png b/public/assets/other/蛋糕.png new file mode 100644 index 0000000..9a62501 Binary files /dev/null and b/public/assets/other/蛋糕.png differ diff --git a/public/icon.ico b/public/icon.ico new file mode 100644 index 0000000..c686f54 Binary files /dev/null and b/public/icon.ico differ diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..dee0f16 Binary files /dev/null and b/public/logo.png differ diff --git a/resources/WCDB.dll b/resources/WCDB.dll new file mode 100644 index 0000000..8127079 Binary files /dev/null and b/resources/WCDB.dll differ diff --git a/resources/silk_v3_decoder.exe b/resources/silk_v3_decoder.exe new file mode 100644 index 0000000..f612751 Binary files /dev/null and b/resources/silk_v3_decoder.exe differ diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll new file mode 100644 index 0000000..324246b Binary files /dev/null and b/resources/wcdb_api.dll differ diff --git a/resources/wx_key.dll b/resources/wx_key.dll new file mode 100644 index 0000000..8952a4b Binary files /dev/null and b/resources/wx_key.dll differ diff --git a/src/App.scss b/src/App.scss new file mode 100644 index 0000000..02be79a --- /dev/null +++ b/src/App.scss @@ -0,0 +1,309 @@ +.app-container { + height: 100vh; + display: flex; + flex-direction: column; + background: var(--bg-primary); + animation: appFadeIn 0.35s ease-out; +} + +.main-layout { + flex: 1; + display: flex; + overflow: hidden; +} + +.content { + flex: 1; + overflow: auto; + padding: 24px; +} + +@keyframes appFadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// 更新提示条 +.update-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 20px; + background: var(--primary); + color: white; + font-size: 14px; + + .update-text { + flex: 1; + + strong { + font-weight: 600; + } + } + + .update-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 14px; + border: none; + border-radius: 6px; + background: rgba(255, 255, 255, 0.2); + color: white; + font-size: 13px; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: rgba(255, 255, 255, 0.3); + } + } + + .dismiss-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: 4px; + background: transparent; + color: white; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.2s; + + &:hover { + opacity: 1; + } + } + + .update-progress { + display: flex; + align-items: center; + gap: 10px; + min-width: 150px; + + .progress-bar { + flex: 1; + height: 6px; + background: rgba(255, 255, 255, 0.3); + border-radius: 3px; + overflow: hidden; + + .progress-fill { + height: 100%; + background: white; + border-radius: 3px; + transition: width 0.2s ease; + } + } + + span { + font-size: 12px; + min-width: 35px; + } + } +} + +// 用户协议弹窗 +.agreement-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.agreement-modal { + width: 520px; + max-height: 80vh; + background: var(--bg-primary); + border-radius: 16px; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.agreement-header { + display: flex; + align-items: center; + gap: 12px; + padding: 24px 28px; + border-bottom: 1px solid var(--border-color); + + svg { + color: var(--primary); + } + + h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + } +} + +.agreement-content { + flex: 1; + padding: 20px 28px; + overflow-y: auto; + + > p { + margin: 0 0 16px; + font-size: 14px; + color: var(--text-secondary); + } +} + +.agreement-notice { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 16px; + padding: 12px 14px; + border-radius: 10px; + border: 1px solid rgba(255, 160, 0, 0.35); + background: rgba(255, 160, 0, 0.12); + color: var(--text-primary); + + strong { + font-size: 15px; + font-weight: 700; + } + + .agreement-notice-link { + font-size: 12px; + color: var(--text-secondary); + } + + a { + font-size: 12px; + color: var(--primary); + text-decoration: none; + word-break: break-all; + + &:hover { + text-decoration: underline; + } + } +} + +.agreement-text { + background: var(--bg-secondary); + border-radius: 10px; + padding: 20px; + max-height: 280px; + overflow-y: auto; + + h4 { + margin: 0 0 8px; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + + &:not(:first-child) { + margin-top: 16px; + } + } + + p { + margin: 0; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.6; + } + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; + } +} + +.agreement-footer { + padding: 20px 28px; + border-top: 1px solid var(--border-color); +} + +.agreement-checkbox { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + cursor: pointer; + + input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--primary); + cursor: pointer; + } + + span { + font-size: 14px; + color: var(--text-primary); + } +} + +.agreement-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + + .btn { + padding: 10px 24px; + } + + .btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border: none; + border-radius: 8px; + font-size: 14px; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: var(--border-color); + } + } + + .btn-primary { + background: var(--primary); + color: white; + border: none; + border-radius: 8px; + font-size: 14px; + cursor: pointer; + transition: opacity 0.2s; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + opacity: 0.9; + } + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..28691d2 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,325 @@ +import { useEffect, useState } from 'react' +import { Routes, Route, useNavigate, useLocation } from 'react-router-dom' +import TitleBar from './components/TitleBar' +import Sidebar from './components/Sidebar' +import RouteGuard from './components/RouteGuard' +import WelcomePage from './pages/WelcomePage' +import HomePage from './pages/HomePage' +import ChatPage from './pages/ChatPage' +import AnalyticsPage from './pages/AnalyticsPage' +import AnnualReportPage from './pages/AnnualReportPage' +import AnnualReportWindow from './pages/AnnualReportWindow' +import AgreementPage from './pages/AgreementPage' +import GroupAnalyticsPage from './pages/GroupAnalyticsPage' +import DataManagementPage from './pages/DataManagementPage' +import SettingsPage from './pages/SettingsPage' +import ExportPage from './pages/ExportPage' +import { useAppStore } from './stores/appStore' +import { themes, useThemeStore, type ThemeId } from './stores/themeStore' +import * as configService from './services/config' +import { Download, X, Shield } from 'lucide-react' +import './App.scss' + +function App() { + const navigate = useNavigate() + const location = useLocation() + const { setDbConnected } = useAppStore() + const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() + const isAgreementWindow = location.pathname === '/agreement-window' + const isOnboardingWindow = location.pathname === '/onboarding-window' + const [themeHydrated, setThemeHydrated] = useState(false) + + // 协议同意状态 + const [showAgreement, setShowAgreement] = useState(false) + const [agreementChecked, setAgreementChecked] = useState(false) + const [agreementLoading, setAgreementLoading] = useState(true) + + // 更新提示状态 + const [updateInfo, setUpdateInfo] = useState<{ version: string; releaseNotes: string } | null>(null) + const [isDownloading, setIsDownloading] = useState(false) + const [downloadProgress, setDownloadProgress] = useState(0) + + useEffect(() => { + const root = document.documentElement + const body = document.body + const appRoot = document.getElementById('app') + + if (isOnboardingWindow) { + root.style.background = 'transparent' + body.style.background = 'transparent' + body.style.overflow = 'hidden' + if (appRoot) { + appRoot.style.background = 'transparent' + appRoot.style.overflow = 'hidden' + } + } else { + root.style.background = 'var(--bg-primary)' + body.style.background = 'var(--bg-primary)' + body.style.overflow = '' + if (appRoot) { + appRoot.style.background = '' + appRoot.style.overflow = '' + } + } + }, [isOnboardingWindow]) + + // 应用主题 + useEffect(() => { + document.documentElement.setAttribute('data-theme', currentTheme) + document.documentElement.setAttribute('data-mode', themeMode) + + // 更新窗口控件颜色以适配主题 + const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a' + if (!isOnboardingWindow) { + window.electronAPI.window.setTitleBarOverlay({ symbolColor }) + } + }, [currentTheme, themeMode, isOnboardingWindow]) + + // 读取已保存的主题设置 + useEffect(() => { + const loadTheme = async () => { + try { + const [savedThemeId, savedThemeMode] = await Promise.all([ + configService.getThemeId(), + configService.getTheme() + ]) + if (savedThemeId && themes.some((theme) => theme.id === savedThemeId)) { + setTheme(savedThemeId as ThemeId) + } + if (savedThemeMode === 'light' || savedThemeMode === 'dark') { + setThemeMode(savedThemeMode) + } + } catch (e) { + console.error('读取主题配置失败:', e) + } finally { + setThemeHydrated(true) + } + } + loadTheme() + }, [setTheme, setThemeMode]) + + // 保存主题设置 + useEffect(() => { + if (!themeHydrated) return + const saveTheme = async () => { + try { + await Promise.all([ + configService.setThemeId(currentTheme), + configService.setTheme(themeMode) + ]) + } catch (e) { + console.error('保存主题配置失败:', e) + } + } + saveTheme() + }, [currentTheme, themeMode, themeHydrated]) + + // 检查是否已同意协议 + useEffect(() => { + const checkAgreement = async () => { + try { + const agreed = await configService.getAgreementAccepted() + if (!agreed) { + setShowAgreement(true) + } + } catch (e) { + console.error('检查协议状态失败:', e) + } finally { + setAgreementLoading(false) + } + } + checkAgreement() + }, []) + + const handleAgree = async () => { + if (!agreementChecked) return + await configService.setAgreementAccepted(true) + setShowAgreement(false) + } + + const handleDisagree = () => { + window.electronAPI.window.close() + } + + // 监听启动时的更新通知 + useEffect(() => { + const removeUpdateListener = window.electronAPI.app.onUpdateAvailable?.((info) => { + setUpdateInfo(info) + }) + const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress) => { + setDownloadProgress(progress) + }) + return () => { + removeUpdateListener?.() + removeProgressListener?.() + } + }, []) + + const handleUpdateNow = async () => { + setIsDownloading(true) + setDownloadProgress(0) + try { + await window.electronAPI.app.downloadAndInstall() + } catch (e) { + console.error('更新失败:', e) + setIsDownloading(false) + } + } + + const dismissUpdate = () => { + setUpdateInfo(null) + } + + // 启动时自动检查配置并连接数据库 + useEffect(() => { + if (isAgreementWindow || isOnboardingWindow) return + + const autoConnect = async () => { + try { + const dbPath = await configService.getDbPath() + const decryptKey = await configService.getDecryptKey() + const wxid = await configService.getMyWxid() + const onboardingDone = await configService.getOnboardingDone() + + // 如果配置完整,自动测试连接 + if (dbPath && decryptKey && wxid) { + if (!onboardingDone) { + await configService.setOnboardingDone(true) + } + console.log('检测到已保存的配置,正在自动连接...') + const result = await window.electronAPI.chat.connect() + + if (result.success) { + console.log('自动连接成功') + setDbConnected(true, dbPath) + // 如果当前在欢迎页,跳转到首页 + if (window.location.hash === '#/' || window.location.hash === '') { + navigate('/home') + } + } else { + console.log('自动连接失败:', result.error) + } + } + } catch (e) { + console.error('自动连接出错:', e) + } + } + + autoConnect() + }, [isAgreementWindow, isOnboardingWindow, navigate, setDbConnected]) + + // 独立协议窗口 + if (isAgreementWindow) { + return + } + + if (isOnboardingWindow) { + return + } + + // 主窗口 - 完整布局 + return ( +
+ + + {/* 用户协议弹窗 */} + {showAgreement && !agreementLoading && ( +
+
+
+ +

用户协议与隐私政策

+
+
+

欢迎使用WeFlow!在使用本软件前,请仔细阅读以下条款:

+
+ 这是免费软件,如果你是付费购买的话请骂死那个骗子。 + + 我们唯一的官方网站: + + https://github.com/hicccc77/WeFlow + + +
+
+

1. 数据安全

+

本软件所有数据处理均在本地完成,不会上传任何聊天记录、个人信息到服务器。您的数据完全由您自己掌控。

+ +

2. 使用须知

+

本软件仅供个人学习研究使用,请勿用于任何非法用途。使用本软件解密、查看、分析的数据应为您本人所有或已获得授权。

+ +

3. 免责声明

+

因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。请确保您的使用行为符合当地法律法规。

+ +

4. 隐私保护

+

本软件不收集任何用户数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。

+
+
+
+ +
+ + +
+
+
+
+ )} + + {/* 更新提示条 */} + {updateInfo && ( +
+ + 发现新版本 v{updateInfo.version} + + {isDownloading ? ( +
+
+
+
+ {downloadProgress.toFixed(0)}% +
+ ) : ( + <> + + + + )} +
+ )} + +
+ +
+ + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + +
+
+
+ ) +} + +export default App diff --git a/src/components/DateRangePicker.scss b/src/components/DateRangePicker.scss new file mode 100644 index 0000000..e51145b --- /dev/null +++ b/src/components/DateRangePicker.scss @@ -0,0 +1,214 @@ +.date-range-picker { + position: relative; + -webkit-app-region: no-drag; + + .picker-trigger { + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + height: 32px; + background: var(--bg-tertiary); + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; + + &:hover { + background: var(--bg-hover); + } + + svg { + color: var(--text-tertiary); + flex-shrink: 0; + } + + .picker-text { + font-size: 13px; + color: var(--text-primary); + white-space: nowrap; + } + + .clear-btn { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + background: var(--bg-hover); + border-radius: 50%; + cursor: pointer; + color: var(--text-tertiary); + margin-left: 4px; + + &:hover { + background: var(--border-color); + color: var(--text-primary); + } + } + } + + .picker-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + background: var(--card-bg); + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); + backdrop-filter: blur(20px); + border: 1px solid var(--border-color); + z-index: 1000; + display: flex; + overflow: hidden; + animation: dropdownFadeIn 0.15s ease-out; + } + + @keyframes dropdownFadeIn { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .quick-options { + display: flex; + flex-direction: column; + padding: 12px; + border-right: 1px solid var(--border-color); + min-width: 100px; + + .quick-option { + padding: 8px 12px; + border: none; + background: transparent; + border-radius: 8px; + cursor: pointer; + font-size: 13px; + color: var(--text-secondary); + text-align: left; + transition: all 0.15s; + white-space: nowrap; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + } + + + .calendar-section { + padding: 16px; + min-width: 280px; + } + + .calendar-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + + .nav-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + background: var(--bg-tertiary); + border-radius: 6px; + cursor: pointer; + color: var(--text-secondary); + transition: all 0.15s; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .month-year { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + } + + .calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; + } + + .weekday-header { + text-align: center; + font-size: 12px; + color: var(--text-tertiary); + padding: 8px 0; + font-weight: 500; + } + + .calendar-day { + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + color: var(--text-tertiary); + border-radius: 8px; + cursor: default; + position: relative; + + &.valid { + color: var(--text-primary); + cursor: pointer; + transition: all 0.15s; + + &:hover { + background: var(--bg-hover); + } + } + + &.today { + font-weight: 600; + color: var(--primary); + } + + &.in-range { + background: var(--primary-light); + border-radius: 0; + } + + &.start { + background: var(--primary); + color: #fff; + border-radius: 8px 0 0 8px; + + &.end { + border-radius: 8px; + } + } + + &.end { + background: var(--primary); + color: #fff; + border-radius: 0 8px 8px 0; + } + } + + .selection-hint { + text-align: center; + font-size: 12px; + color: var(--text-tertiary); + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-color); + } +} diff --git a/src/components/DateRangePicker.tsx b/src/components/DateRangePicker.tsx new file mode 100644 index 0000000..78d5b4f --- /dev/null +++ b/src/components/DateRangePicker.tsx @@ -0,0 +1,204 @@ +import { useState, useRef, useEffect } from 'react' +import { Calendar, ChevronLeft, ChevronRight, X } from 'lucide-react' +import './DateRangePicker.scss' + +interface DateRangePickerProps { + startDate: string + endDate: string + onStartDateChange: (date: string) => void + onEndDateChange: (date: string) => void + onRangeComplete?: () => void +} + +const MONTH_NAMES = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'] +const WEEKDAY_NAMES = ['日', '一', '二', '三', '四', '五', '六'] + +// 快捷选项 +const QUICK_OPTIONS = [ + { label: '最近7天', days: 7 }, + { label: '最近30天', days: 30 }, + { label: '最近90天', days: 90 }, + { label: '最近一年', days: 365 }, + { label: '全部时间', days: 0 }, +] + +function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChange, onRangeComplete }: DateRangePickerProps) { + const [isOpen, setIsOpen] = useState(false) + const [currentMonth, setCurrentMonth] = useState(new Date()) + const [selectingStart, setSelectingStart] = useState(true) + const containerRef = useRef(null) + + // 点击外部关闭 + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false) + } + } + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside) + } + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [isOpen]) + + const formatDisplayDate = (dateStr: string) => { + if (!dateStr) return '' + const date = new Date(dateStr) + return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` + } + + const getDisplayText = () => { + if (!startDate && !endDate) return '选择时间范围' + if (startDate && endDate) return `${formatDisplayDate(startDate)} - ${formatDisplayDate(endDate)}` + if (startDate) return `${formatDisplayDate(startDate)} - ?` + return `? - ${formatDisplayDate(endDate)}` + } + + const handleQuickOption = (days: number) => { + if (days === 0) { + onStartDateChange('') + onEndDateChange('') + } else { + const end = new Date() + const start = new Date() + start.setDate(start.getDate() - days) + onStartDateChange(start.toISOString().split('T')[0]) + onEndDateChange(end.toISOString().split('T')[0]) + } + setIsOpen(false) + setTimeout(() => onRangeComplete?.(), 0) + } + + const handleClear = (e: React.MouseEvent) => { + e.stopPropagation() + onStartDateChange('') + onEndDateChange('') + } + + + const getDaysInMonth = (date: Date) => { + return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate() + } + + const getFirstDayOfMonth = (date: Date) => { + return new Date(date.getFullYear(), date.getMonth(), 1).getDay() + } + + const handleDateClick = (day: number) => { + const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}` + + if (selectingStart) { + onStartDateChange(dateStr) + if (endDate && dateStr > endDate) { + onEndDateChange('') + } + setSelectingStart(false) + } else { + if (dateStr < startDate) { + onStartDateChange(dateStr) + onEndDateChange(startDate) + } else { + onEndDateChange(dateStr) + } + setSelectingStart(true) + setIsOpen(false) + setTimeout(() => onRangeComplete?.(), 0) + } + } + + const isInRange = (day: number) => { + if (!startDate || !endDate) return false + const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}` + return dateStr >= startDate && dateStr <= endDate + } + + const isStartDate = (day: number) => { + const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}` + return dateStr === startDate + } + + const isEndDate = (day: number) => { + const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}` + return dateStr === endDate + } + + const isToday = (day: number) => { + const today = new Date() + return currentMonth.getFullYear() === today.getFullYear() && + currentMonth.getMonth() === today.getMonth() && + day === today.getDate() + } + + const renderCalendar = () => { + const daysInMonth = getDaysInMonth(currentMonth) + const firstDay = getFirstDayOfMonth(currentMonth) + const days: (number | null)[] = [] + + for (let i = 0; i < firstDay; i++) { + days.push(null) + } + for (let i = 1; i <= daysInMonth; i++) { + days.push(i) + } + + return ( +
+ {WEEKDAY_NAMES.map(name => ( +
{name}
+ ))} + {days.map((day, index) => ( +
day && handleDateClick(day)} + > + {day} +
+ ))} +
+ ) + } + + return ( +
+ + )} + + + {isOpen && ( +
+
+ {QUICK_OPTIONS.map(opt => ( + + ))} +
+
+
+ + {currentMonth.getFullYear()}年 {MONTH_NAMES[currentMonth.getMonth()]} + +
+ {renderCalendar()} +
+ {selectingStart ? '请选择开始日期' : '请选择结束日期'} +
+
+
+ )} +
+ ) +} + +export default DateRangePicker diff --git a/src/components/RouteGuard.tsx b/src/components/RouteGuard.tsx new file mode 100644 index 0000000..cd775c5 --- /dev/null +++ b/src/components/RouteGuard.tsx @@ -0,0 +1,29 @@ +import { useEffect } from 'react' +import { useNavigate, useLocation } from 'react-router-dom' +import { useAppStore } from '../stores/appStore' + +interface RouteGuardProps { + children: React.ReactNode +} + +// 不需要数据库连接的页面 +const PUBLIC_ROUTES = ['/', '/home', '/settings', '/data-management'] + +function RouteGuard({ children }: RouteGuardProps) { + const navigate = useNavigate() + const location = useLocation() + const isDbConnected = useAppStore(state => state.isDbConnected) + + useEffect(() => { + const isPublicRoute = PUBLIC_ROUTES.includes(location.pathname) + + // 未连接数据库且不在公开页面,跳转到欢迎页 + if (!isDbConnected && !isPublicRoute) { + navigate('/', { replace: true }) + } + }, [isDbConnected, location.pathname, navigate]) + + return <>{children} +} + +export default RouteGuard diff --git a/src/components/Sidebar.scss b/src/components/Sidebar.scss new file mode 100644 index 0000000..dc76196 --- /dev/null +++ b/src/components/Sidebar.scss @@ -0,0 +1,107 @@ +.sidebar { + width: 200px; + background: var(--bg-secondary); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + padding: 16px 0; + transition: width 0.25s ease; + + &.collapsed { + width: 64px; + + .nav-menu, + .sidebar-footer { + padding: 0 8px; + } + + .nav-label { + display: none; + } + + .nav-item { + justify-content: center; + padding: 10px; + gap: 0; + } + } +} + +.nav-menu { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + padding: 0 8px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + border-radius: 9999px; + color: var(--text-secondary); + text-decoration: none; + transition: all 0.2s ease; + white-space: nowrap; + border: none; + background: transparent; + cursor: pointer; + font-family: inherit; + width: 100%; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } + + &.active { + background: var(--primary); + color: white; + } +} + +.nav-icon { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.nav-label { + font-size: 14px; + font-weight: 500; +} + +.sidebar-footer { + padding: 0 8px; + border-top: 1px solid var(--border-color); + padding-top: 12px; + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.collapse-btn { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 8px; + border: none; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + border-radius: 9999px; + transition: all 0.2s ease; + margin-top: 4px; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..55d7dce --- /dev/null +++ b/src/components/Sidebar.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react' +import { NavLink, useLocation } from 'react-router-dom' +import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download } from 'lucide-react' +import './Sidebar.scss' + +function Sidebar() { + const location = useLocation() + const [collapsed, setCollapsed] = useState(false) + + const isActive = (path: string) => { + return location.pathname === path || location.pathname.startsWith(`${path}/`) + } + + return ( + + ) +} + +export default Sidebar diff --git a/src/components/TitleBar.scss b/src/components/TitleBar.scss new file mode 100644 index 0000000..f17c998 --- /dev/null +++ b/src/components/TitleBar.scss @@ -0,0 +1,23 @@ +.title-bar { + height: 41px; + background: var(--bg-secondary); + display: flex; + align-items: center; + padding-left: 16px; + border-bottom: 1px solid var(--border-color); + -webkit-app-region: drag; + flex-shrink: 0; + gap: 8px; +} + +.title-logo { + width: 20px; + height: 20px; + object-fit: contain; +} + +.titles { + font-size: 15px; + font-weight: 500; + color: var(--text-secondary); +} diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx new file mode 100644 index 0000000..77fe4e3 --- /dev/null +++ b/src/components/TitleBar.tsx @@ -0,0 +1,12 @@ +import './TitleBar.scss' + +function TitleBar() { + return ( +
+ WeFlow + WeFlow +
+ ) +} + +export default TitleBar diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..c48b958 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { HashRouter } from 'react-router-dom' +import App from './App' +import './styles/main.scss' + +ReactDOM.createRoot(document.getElementById('app')!).render( + + + + + +) diff --git a/src/pages/AgreementPage.scss b/src/pages/AgreementPage.scss new file mode 100644 index 0000000..eb76ccf --- /dev/null +++ b/src/pages/AgreementPage.scss @@ -0,0 +1,83 @@ +.agreement-page { + height: 100vh; + display: flex; + flex-direction: column; + background: var(--bg-primary); +} + +.agreement-titlebar { + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + -webkit-app-region: drag; + flex-shrink: 0; + + span { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + } +} + +.agreement-content { + flex: 1; + padding: 32px 48px; + overflow-y: auto; + + h2 { + margin: 0 0 24px; + font-size: 22px; + font-weight: 600; + color: var(--text-primary); + padding-bottom: 12px; + border-bottom: 2px solid var(--primary); + + &:not(:first-child) { + margin-top: 40px; + } + } + + h3 { + margin: 24px 0 12px; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } + + p { + margin: 0 0 12px; + font-size: 14px; + color: var(--text-secondary); + line-height: 1.8; + text-align: justify; + } + + .agreement-footer-text { + margin-top: 40px; + padding-top: 20px; + border-top: 1px solid var(--border-color); + font-size: 13px; + color: var(--text-tertiary); + text-align: center; + } + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; + + &:hover { + background: var(--text-tertiary); + } + } +} diff --git a/src/pages/AgreementPage.tsx b/src/pages/AgreementPage.tsx new file mode 100644 index 0000000..242e361 --- /dev/null +++ b/src/pages/AgreementPage.tsx @@ -0,0 +1,52 @@ +import './AgreementPage.scss' + +function AgreementPage() { + return ( +
+
+ 用户协议与隐私政策 +
+
+ {/* 协议内容 - 请替换为完整的协议文本 */} +

用户协议

+ +

一、总则

+

欢迎使用WeFlow(WeFlow)软件。请在使用本软件前仔细阅读本协议。一旦您开始使用本软件,即表示您已充分理解并同意本协议的全部内容。

+ +

二、软件说明

+

WeFlow是一款本地化的微信聊天记录查看与分析工具,所有数据处理均在用户本地设备上完成。

+ +

三、使用条款

+

1. 本软件仅供个人学习、研究使用,严禁用于任何商业用途或非法目的。

+

2. 用户应确保所查看、分析的数据为本人所有或已获得合法授权。

+

3. 用户不得利用本软件侵犯他人隐私、窃取他人信息或从事其他违法活动。

+ +

四、免责声明

+

1. 本软件按"现状"提供,开发者不对软件的适用性、可靠性、准确性作任何明示或暗示的保证。

+

2. 因使用或无法使用本软件而产生的任何直接、间接、偶然、特殊或后果性损害,开发者不承担任何责任。

+

3. 用户因违反本协议或相关法律法规而产生的一切后果由用户自行承担。

+ +

五、知识产权

+

本软件的所有权、知识产权及相关权益均归开发者所有。未经授权,不得复制、修改、传播本软件。

+ +

隐私政策

+ +

一、数据收集

+

本软件不收集、不上传、不存储任何用户个人信息或聊天数据。所有数据处理均在本地完成。

+ +

二、数据安全

+

您的聊天记录和个人数据完全存储在您的本地设备上,本软件不会将任何数据传输至外部服务器。

+ +

三、网络请求

+

本软件仅在检查更新时会访问更新服务器获取版本信息,不涉及任何用户数据的传输。

+ +

四、第三方服务

+

本软件不集成任何第三方数据分析、广告或追踪服务。

+ +

最后更新日期:2025年1月

+
+
+ ) +} + +export default AgreementPage diff --git a/src/pages/AnalyticsPage.scss b/src/pages/AnalyticsPage.scss new file mode 100644 index 0000000..702983a --- /dev/null +++ b/src/pages/AnalyticsPage.scss @@ -0,0 +1,295 @@ +// 加载和错误状态 +.loading-container, +.error-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + min-height: 400px; + gap: 16px; + color: var(--text-secondary); + + .spin { + animation: spin 1s linear infinite; + } + + p.loading-status { + margin: 0; + font-size: 14px; + color: var(--text-primary); + } + + .progress-bar-wrapper { + width: 300px; + height: 8px; + background: var(--bg-tertiary); + border-radius: 999px; + overflow: hidden; + position: relative; + border: 1px solid var(--border-color); + } + + .progress-bar-fill { + position: absolute; + left: 0; + top: 0; + height: 100%; + background: var(--primary-gradient); + transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 0 10px rgba(139, 115, 85, 0.3); + } + + .progress-percent { + font-size: 12px; + font-weight: 600; + color: var(--primary); + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +// 统计卡片 +.stats-overview { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + display: flex; + align-items: center; + gap: 16px; + padding: 20px; + background: var(--card-bg); + border-radius: 12px; + border: 1px solid var(--border-color); + + .stat-icon { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background: var(--primary-light); + border-radius: 12px; + color: var(--primary); + } + + .stat-info { + display: flex; + flex-direction: column; + gap: 4px; + + .stat-value { + font-size: 24px; + font-weight: 600; + color: var(--text-primary); + } + + .stat-label { + font-size: 13px; + color: var(--text-tertiary); + } + } +} + +.time-range { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: var(--bg-tertiary); + border-radius: 8px; + margin-bottom: 24px; + font-size: 13px; + color: var(--text-secondary); + + svg { + color: var(--text-tertiary); + } +} + +.charts-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + margin-bottom: 24px; +} + +.chart-card { + background: var(--card-bg); + border-radius: 12px; + border: 1px solid var(--border-color); + padding: 20px; + + &.wide { + grid-column: span 2; + } + + h3 { + font-size: 15px; + font-weight: 500; + color: var(--text-primary); + margin: 0 0 16px; + } +} + +.rankings-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ranking-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--bg-primary); + border-radius: 8px; + transition: background 0.2s; + + &:hover { + background: var(--bg-tertiary); + } + + .rank { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-tertiary); + border-radius: 8px; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + flex-shrink: 0; + + &.top { + background: var(--primary); + color: white; + } + } + + .contact-avatar { + width: 40px; + height: 40px; + flex-shrink: 0; + position: relative; + + img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + } + + .avatar-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-tertiary); + border-radius: 50%; + color: var(--text-tertiary); + } + + .medal { + position: absolute; + right: -4px; + bottom: -4px; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + border: 2px solid var(--bg-primary); + + &.medal-1 { + background: linear-gradient(135deg, #ffd700, #ffb800); + color: #fff; + box-shadow: 0 2px 4px rgba(255, 184, 0, 0.4); + } + + &.medal-2 { + background: linear-gradient(135deg, #c0c0c0, #a8a8a8); + color: #fff; + box-shadow: 0 2px 4px rgba(168, 168, 168, 0.4); + } + + &.medal-3 { + background: linear-gradient(135deg, #cd7f32, #b87333); + color: #fff; + box-shadow: 0 2px 4px rgba(184, 115, 51, 0.4); + } + + svg { + width: 10px; + height: 10px; + } + } + } + + .contact-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + + .contact-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .contact-stats { + font-size: 12px; + color: var(--text-tertiary); + } + } + + .message-count { + font-size: 14px; + font-weight: 500; + color: var(--primary); + flex-shrink: 0; + } +} + +// 响应式 +@media (max-width: 1200px) { + .stats-overview { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 800px) { + .stats-overview { + grid-template-columns: 1fr; + } + + .charts-grid { + grid-template-columns: 1fr; + + .chart-card.wide { + grid-column: span 1; + } + } +} \ No newline at end of file diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx new file mode 100644 index 0000000..7c6cf45 --- /dev/null +++ b/src/pages/AnalyticsPage.tsx @@ -0,0 +1,309 @@ +import { useState, useEffect } from 'react' +import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react' +import ReactECharts from 'echarts-for-react' +import { useAnalyticsStore } from '../stores/analyticsStore' +import { useThemeStore } from '../stores/themeStore' +import './AnalyticsPage.scss' +import './DataManagementPage.scss' + +function AnalyticsPage() { + const [isLoading, setIsLoading] = useState(false) + const [loadingStatus, setLoadingStatus] = useState('') + const [error, setError] = useState(null) + const [progress, setProgress] = useState(0) + + const themeMode = useThemeStore((state) => state.themeMode) + const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded } = useAnalyticsStore() + const loadData = async (forceRefresh = false) => { + if (isLoaded && !forceRefresh) return + setIsLoading(true) + setError(null) + setProgress(0) + + // 监听后台推送的进度 + const removeListener = window.electronAPI.analytics.onProgress?.((payload: { status: string; progress: number }) => { + setLoadingStatus(payload.status) + setProgress(payload.progress) + }) + + try { + setLoadingStatus('正在统计消息数据...') + const statsResult = await window.electronAPI.analytics.getOverallStatistics() + if (statsResult.success && statsResult.data) { + setStatistics(statsResult.data) + } else { + setError(statsResult.error || '加载统计数据失败') + setIsLoading(false) + return + } + setLoadingStatus('正在分析联系人排名...') + const rankingsResult = await window.electronAPI.analytics.getContactRankings(20) + if (rankingsResult.success && rankingsResult.data) { + setRankings(rankingsResult.data) + } + setLoadingStatus('正在计算时间分布...') + const timeResult = await window.electronAPI.analytics.getTimeDistribution() + if (timeResult.success && timeResult.data) { + setTimeDistribution(timeResult.data) + } + markLoaded() + } catch (e) { + setError(String(e)) + } finally { + setIsLoading(false) + if (removeListener) removeListener() + } + } + + useEffect(() => { loadData() }, []) + + const handleRefresh = () => loadData(true) + + const formatDate = (timestamp: number | null) => { + if (!timestamp) return '-' + const date = new Date(timestamp * 1000) + return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` + } + + const formatNumber = (num: number) => { + if (num >= 10000) return (num / 10000).toFixed(1) + '万' + return num.toLocaleString() + } + + const getChartLabelColors = () => { + if (typeof window === 'undefined') { + return { text: '#333333', line: '#999999' } + } + const styles = getComputedStyle(document.documentElement) + const text = styles.getPropertyValue('--text-primary').trim() || '#333333' + const line = styles.getPropertyValue('--text-tertiary').trim() || '#999999' + return { text, line } + } + + const chartLabelColors = getChartLabelColors() + + const getTypeChartOption = () => { + if (!statistics) return {} + const data = [ + { name: '文本', value: statistics.textMessages }, + { name: '图片', value: statistics.imageMessages }, + { name: '语音', value: statistics.voiceMessages }, + { name: '视频', value: statistics.videoMessages }, + { name: '表情', value: statistics.emojiMessages }, + { name: '其他', value: statistics.otherMessages }, + ].filter(d => d.value > 0) + return { + tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }, + series: [{ + type: 'pie', + radius: ['40%', '70%'], + avoidLabelOverlap: false, + itemStyle: { borderRadius: 8, borderColor: 'transparent', borderWidth: 0 }, + label: { + show: true, + formatter: '{b}\n{d}%', + textStyle: { + color: chartLabelColors.text, + textShadowBlur: 0, + textShadowColor: 'transparent', + textShadowOffsetX: 0, + textShadowOffsetY: 0, + textBorderWidth: 0, + textBorderColor: 'transparent', + }, + }, + labelLine: { + lineStyle: { + color: chartLabelColors.line, + shadowBlur: 0, + shadowColor: 'transparent', + }, + }, + emphasis: { + itemStyle: { + shadowBlur: 0, + shadowOffsetX: 0, + shadowOffsetY: 0, + }, + label: { + color: chartLabelColors.text, + textShadowBlur: 0, + textShadowColor: 'transparent', + textBorderWidth: 0, + textBorderColor: 'transparent', + }, + labelLine: { + lineStyle: { + color: chartLabelColors.line, + shadowBlur: 0, + shadowColor: 'transparent', + }, + }, + }, + data, + }] + } + } + + const getSendReceiveOption = () => { + if (!statistics) return {} + return { + tooltip: { trigger: 'item' }, + series: [{ + type: 'pie', radius: ['50%', '70%'], data: [ + { name: '发送', value: statistics.sentMessages, itemStyle: { color: '#07c160' } }, + { name: '接收', value: statistics.receivedMessages, itemStyle: { color: '#1989fa' } } + ], + label: { + show: true, + formatter: '{b}: {c}', + textStyle: { + color: chartLabelColors.text, + textShadowBlur: 0, + textShadowColor: 'transparent', + textShadowOffsetX: 0, + textShadowOffsetY: 0, + textBorderWidth: 0, + textBorderColor: 'transparent', + }, + }, + labelLine: { + lineStyle: { + color: chartLabelColors.line, + shadowBlur: 0, + shadowColor: 'transparent', + }, + }, + emphasis: { + itemStyle: { + shadowBlur: 0, + shadowOffsetX: 0, + shadowOffsetY: 0, + }, + label: { + color: chartLabelColors.text, + textShadowBlur: 0, + textShadowColor: 'transparent', + textBorderWidth: 0, + textBorderColor: 'transparent', + }, + labelLine: { + lineStyle: { + color: chartLabelColors.line, + shadowBlur: 0, + shadowColor: 'transparent', + }, + }, + }, + }] + } + } + + const getHourlyOption = () => { + if (!timeDistribution) return {} + const hours = Array.from({ length: 24 }, (_, i) => i) + const data = hours.map(h => timeDistribution.hourlyDistribution[h] || 0) + return { + tooltip: { trigger: 'axis' }, + xAxis: { type: 'category', data: hours.map(h => `${h}时`) }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', data, itemStyle: { color: '#07c160', borderRadius: [4, 4, 0, 0] } }] + } + } + + if (isLoading && !isLoaded) { + return ( +
+ +

{loadingStatus}

+
+
+
+ {progress}% +
+ ) + } + + if (error && !isLoaded) { + return (

{error}

) + } + + + return ( + <> +
+

私聊分析

+ +
+
+
+
+
+
+
+ {formatNumber(statistics?.totalMessages || 0)} + 总消息数 +
+
+
+
+
+ {formatNumber(statistics?.sentMessages || 0)} + 发送消息 +
+
+
+
+
+ {formatNumber(statistics?.receivedMessages || 0)} + 接收消息 +
+
+
+
+
+ {statistics?.activeDays || 0} + 活跃天数 +
+
+
+ {statistics && ( +
+ + 数据范围: {formatDate(statistics.firstMessageTime)} - {formatDate(statistics.lastMessageTime)} +
+ )} +
+

消息类型分布

+

发送/接收比例

+

每小时消息分布

+
+
+
+

聊天排名 Top 20

+
+ {rankings.map((contact, index) => ( +
+ {index + 1} +
+ {contact.avatarUrl ? :
} + {index < 3 &&
} +
+
+ {contact.displayName} + 发送 {contact.sentCount} / 接收 {contact.receivedCount} +
+ {formatNumber(contact.messageCount)} 条 +
+ ))} +
+
+
+ + ) +} + +export default AnalyticsPage diff --git a/src/pages/AnnualReportPage.scss b/src/pages/AnnualReportPage.scss new file mode 100644 index 0000000..e5839c4 --- /dev/null +++ b/src/pages/AnnualReportPage.scss @@ -0,0 +1,116 @@ +.annual-report-page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100%; + text-align: center; +} + +.header-icon { + color: var(--primary); + margin-bottom: 16px; +} + +.page-title { + font-size: 32px; + font-weight: 700; + color: var(--text-primary); + margin: 0 0 12px; +} + +.page-desc { + font-size: 15px; + color: var(--text-secondary); + margin: 0 0 48px; +} + +.year-grid { + display: flex; + flex-wrap: wrap; + gap: 16px; + justify-content: center; + max-width: 600px; + margin-bottom: 48px; +} + +.year-card { + width: 120px; + height: 100px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--card-bg); + border: 2px solid var(--border-color); + border-radius: 16px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--primary); + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); + } + + &.selected { + border-color: var(--primary); + background: var(--primary-light); + + .year-number { + color: var(--primary); + } + } + + .year-number { + font-size: 32px; + font-weight: 700; + color: var(--text-primary); + line-height: 1; + } + + .year-label { + font-size: 14px; + color: var(--text-tertiary); + margin-top: 4px; + } +} + +.generate-btn { + display: flex; + align-items: center; + gap: 10px; + padding: 16px 40px; + background: linear-gradient(135deg, var(--primary) 0%, color-mix(in srgb, var(--primary) 80%, #000) 100%); + border: none; + border-radius: 50px; + color: #fff; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + box-shadow: 0 4px 16px color-mix(in srgb, var(--primary) 30%, transparent); + + &:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 40%, transparent); + } + + &:active:not(:disabled) { + transform: translateY(0); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/src/pages/AnnualReportPage.tsx b/src/pages/AnnualReportPage.tsx new file mode 100644 index 0000000..7931764 --- /dev/null +++ b/src/pages/AnnualReportPage.tsx @@ -0,0 +1,110 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { Calendar, Loader2, Sparkles } from 'lucide-react' +import './AnnualReportPage.scss' + +function AnnualReportPage() { + const navigate = useNavigate() + const [availableYears, setAvailableYears] = useState([]) + const [selectedYear, setSelectedYear] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [isGenerating, setIsGenerating] = useState(false) + const [loadError, setLoadError] = useState(null) + + useEffect(() => { + loadAvailableYears() + }, []) + + const loadAvailableYears = async () => { + setIsLoading(true) + setLoadError(null) + try { + const result = await window.electronAPI.annualReport.getAvailableYears() + if (result.success && result.data && result.data.length > 0) { + setAvailableYears(result.data) + setSelectedYear(result.data[0]) + } else if (!result.success) { + setLoadError(result.error || '加载年度数据失败') + } + } catch (e) { + console.error(e) + setLoadError(String(e)) + } finally { + setIsLoading(false) + } + } + + const handleGenerateReport = async () => { + if (!selectedYear) return + setIsGenerating(true) + try { + navigate(`/annual-report/view?year=${selectedYear}`) + } catch (e) { + console.error('生成报告失败:', e) + } finally { + setIsGenerating(false) + } + } + + if (isLoading) { + return ( +
+ +

正在加载年份数据...

+
+ ) + } + + if (availableYears.length === 0) { + return ( +
+ +

暂无聊天记录

+

+ {loadError || '请先解密数据库后再生成年度报告'} +

+
+ ) + } + + return ( +
+ +

年度报告

+

选择年份,生成你的微信聊天年度回顾

+ +
+ {availableYears.map(year => ( +
setSelectedYear(year)} + > + {year} + +
+ ))} +
+ + +
+ ) +} + +export default AnnualReportPage diff --git a/src/pages/AnnualReportWindow.scss b/src/pages/AnnualReportWindow.scss new file mode 100644 index 0000000..db26b11 --- /dev/null +++ b/src/pages/AnnualReportWindow.scss @@ -0,0 +1,1281 @@ +.annual-report-window { + // 使用全局主题变量,带回退值 + --ar-primary: var(--primary, #07C160); + --ar-accent: var(--accent, #F2AA00); + --ar-text-main: var(--text-primary, #222222); + --ar-text-sub: var(--text-secondary, #555555); + --ar-bg-color: var(--bg-primary, #F9F8F6); + --ar-card-bg: var(--bg-secondary, rgba(255, 255, 255, 0.5)); + --ar-card-bg-hover: var(--bg-tertiary, rgba(255, 255, 255, 0.8)); + --ar-rank-bg: var(--bg-secondary, #f0f0f0); + --ar-rank-color: var(--text-secondary, #666); + + width: 100%; + height: 100vh; + background: var(--chat-pattern); + background-color: var(--ar-bg-color); + // overflow-y: auto; // Moved to .report-scroll-view + overflow: hidden; // Contain everything + position: relative; + -webkit-app-region: no-drag; // 确保主容器不可拖动 + + // 隐藏滚动条 + /* scrollbar-width: none; */ + // Moved + /* -ms-overflow-style: none; */ +} + +.report-scroll-view { + position: absolute; + inset: 0; + overflow-y: auto; + overflow-x: hidden; + z-index: 1; + + // 隐藏滚动条 + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } +} + +// 背景装饰圆点 - 毛玻璃效果 +.bg-decoration { + position: absolute; // Changed from fixed + inset: 0; + pointer-events: none; + z-index: 0; + overflow: hidden; +} + +.deco-circle { + position: absolute; + border-radius: 50%; + background: rgba(0, 0, 0, 0.03); + backdrop-filter: blur(40px); + -webkit-backdrop-filter: blur(40px); + border: 1px solid rgba(0, 0, 0, 0.05); + + &.c1 { + width: 280px; + height: 280px; + top: -80px; + right: -60px; + animation: float1 20s ease-in-out infinite; + } + + &.c2 { + width: 200px; + height: 200px; + bottom: 15%; + left: -70px; + animation: float2 25s ease-in-out infinite; + } + + &.c3 { + width: 120px; + height: 120px; + top: 45%; + right: -40px; + animation: float3 18s ease-in-out infinite; + } + + &.c4 { + width: 90px; + height: 90px; + top: 25%; + left: 8%; + animation: float1 22s ease-in-out infinite reverse; + } + + &.c5 { + width: 60px; + height: 60px; + bottom: 25%; + right: 12%; + animation: float2 15s ease-in-out infinite reverse; + } +} + +@keyframes float1 { + + 0%, + 100% { + transform: translate(0, 0); + } + + 50% { + transform: translate(-15px, 15px); + } +} + +@keyframes float2 { + + 0%, + 100% { + transform: translate(0, 0); + } + + 50% { + transform: translate(12px, -12px); + } +} + +@keyframes float3 { + + 0%, + 100% { + transform: translate(0, 0); + } + + 50% { + transform: translate(-8px, -15px); + } +} + +.annual-report-window { + + // 所有子元素默认不可拖动 + * { + -webkit-app-region: no-drag; + } + + // 背景渐变灯光 + &::before { + content: ""; + position: fixed; + inset: 0; + background: + radial-gradient(circle 500px at 0% 0%, rgba(7, 193, 96, 0.06), transparent), + radial-gradient(circle 500px at 100% 0%, rgba(242, 170, 0, 0.05), transparent), + radial-gradient(circle 500px at 0% 100%, rgba(242, 170, 0, 0.05), transparent), + radial-gradient(circle 500px at 100% 100%, rgba(7, 193, 96, 0.06), transparent); + pointer-events: none; + z-index: 0; + } + + &.loading, + &.error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + color: var(--ar-text-sub); + + p { + font-size: 16px; + } + } + + &.loading { + .loading-ring { + position: relative; + width: 160px; + height: 160px; + + svg { + width: 100%; + height: 100%; + transform: rotate(-90deg); + } + + .ring-bg { + fill: none; + stroke: rgba(0, 0, 0, 0.08); + stroke-width: 6; + } + + .ring-progress { + fill: none; + stroke: var(--ar-primary); + stroke-width: 6; + stroke-linecap: round; + stroke-dasharray: 264; + transition: stroke-dashoffset 0.3s ease; + } + + .ring-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 36px; + font-weight: 600; + color: var(--ar-primary); + } + } + + .loading-stage { + font-size: 20px; + font-weight: 600; + color: var(--ar-text-main); + margin-top: 24px; + } + + .loading-hint { + font-size: 14px; + color: var(--ar-text-sub); + margin-top: 4px; + } + } +} + +.report-container { + width: 80%; + margin: 0 auto; + padding: 32px 5% 60px; + padding-top: 48px; + position: relative; + z-index: 1; + -webkit-app-region: no-drag; +} + +.exporting-snapshot *::selection { + background: transparent; + color: inherit; +} + +.exporting-snapshot * { + caret-color: transparent; +} + +.exporting-snapshot { + .hero-title, + .label-text, + .hero-desc, + .stat-num, + .stat-unit, + .hl, + .gold { + background: transparent !important; + box-shadow: none !important; + } +} + +.section { + min-height: 80vh; + display: flex; + flex-direction: column; + justify-content: center; + padding: 60px 0; +} + +.label-text { + font-size: 12px; + letter-spacing: 3px; + text-transform: uppercase; + color: var(--ar-text-sub); + margin-bottom: 14px; + font-weight: 600; +} + +.hero-title { + font-size: clamp(28px, 5vw, 44px); + font-weight: 700; + line-height: 1.2; + margin-bottom: 16px; + color: var(--ar-text-main); +} + +.hero-desc { + font-size: 16px; + line-height: 1.8; + color: var(--ar-text-sub); + max-width: 500px; + + &.active-time { + font-size: 18px; + margin-bottom: 16px; + } +} + +.big-stat { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 8px; + margin: 20px 0; +} + +.stat-num { + font-size: clamp(40px, 8vw, 64px); + font-weight: 700; + color: var(--ar-primary); + line-height: 1; +} + +.stat-unit { + font-size: 18px; + color: var(--ar-text-sub); +} + +.divider { + width: 50px; + height: 3px; + background: var(--ar-accent); + margin: 24px 0; + border: none; + opacity: 0.8; +} + +.hl { + color: var(--ar-primary); + font-weight: 600; +} + +.gold { + color: var(--ar-accent); + font-weight: 600; +} + +// 头像组件 +.avatar { + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + border: 2px solid #fff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + color: #fff; + font-weight: 600; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + &.sm { + width: 38px; + height: 38px; + font-size: 13px; + } + + &.md { + width: 48px; + height: 48px; + font-size: 16px; + } + + &.lg { + width: 64px; + height: 64px; + font-size: 20px; + border: 3px solid #fff; + box-shadow: 0 6px 20px rgba(7, 193, 96, 0.2); + } +} + +// 月度好友环形布局 +.monthly-orbit { + --radius: 180px; + position: relative; + width: 100%; + max-width: 500px; + height: 500px; + margin: 30px auto 0; +} + +.monthly-center { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + text-align: center; + z-index: 2; + + .avatar { + width: 80px; + height: 80px; + } +} + +.monthly-item { + position: absolute; + left: 50%; + top: 50%; + width: 90px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + text-align: center; + transform: translate(-50%, -50%) rotate(calc(var(--i) * 30deg)) translateY(calc(-1 * var(--radius))) rotate(calc(var(--i) * -30deg)); + z-index: 1; + + .avatar { + width: 48px; + height: 48px; + } +} + +.month-label { + font-size: 11px; + color: var(--ar-text-sub); + letter-spacing: 1px; +} + +.month-name { + font-size: 11px; + color: var(--ar-text-sub); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; +} + +// 热力图 +.heatmap-wrapper { + margin-top: 24px; + width: 100%; +} + +.heatmap-header { + display: grid; + grid-template-columns: 28px 1fr; + gap: 3px; + margin-bottom: 6px; + color: var(--ar-text-sub); + font-size: 10px; +} + +.time-labels { + display: grid; + grid-template-columns: repeat(24, 1fr); + gap: 3px; + + span { + text-align: center; + } +} + +.heatmap { + display: grid; + grid-template-columns: 28px 1fr; + gap: 3px; +} + +.heatmap-week-col { + display: grid; + grid-template-rows: repeat(7, 1fr); + gap: 3px; + font-size: 10px; + color: var(--ar-text-sub); +} + +.week-label { + display: flex; + align-items: center; +} + +.heatmap-grid { + display: grid; + grid-template-columns: repeat(24, 1fr); + gap: 3px; +} + +.h-cell { + aspect-ratio: 1; + border-radius: 2px; + min-height: 10px; + transition: transform 0.15s; + + &:hover { + transform: scale(1.3); + z-index: 1; + } +} + +// 好友列表 +.friend-list { + margin-top: 20px; +} + +.friend-item { + display: flex; + align-items: center; + gap: 14px; + padding: 14px; + background: var(--ar-card-bg); + border-radius: 10px; + margin-bottom: 10px; + transition: background 0.2s; + + &:hover { + background: var(--ar-card-bg-hover); + } + + .rank { + width: 26px; + height: 26px; + border-radius: 50%; + background: var(--ar-rank-bg); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + color: var(--ar-rank-color); + flex-shrink: 0; + + &.top { + background: linear-gradient(135deg, #ffd700, #ffb800); + color: #fff; + } + } + + .avatar { + width: 40px; + height: 40px; + font-size: 14px; + } + + .info { + flex: 1; + min-width: 0; + } + + .name { + font-weight: 600; + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .count { + font-size: 12px; + color: var(--ar-text-sub); + } + + .percent { + font-size: 13px; + color: var(--ar-primary); + font-weight: 600; + flex-shrink: 0; + } +} + +// 领奖台布局 +.podium { + display: flex; + align-items: flex-end; + justify-content: center; + gap: 12px; + margin-top: 40px; + padding: 0 20px; +} + +.podium-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + position: relative; + + .avatar { + width: 56px; + height: 56px; + border: 3px solid var(--ar-card-bg); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + + &.first { + .avatar { + width: 72px; + height: 72px; + border-color: #ffd700; + } + + .crown { + font-size: 28px; + margin-bottom: -8px; + animation: crownBounce 2s ease-in-out infinite; + } + + .podium-stand { + height: 100px; + background: linear-gradient(180deg, #ffd700, #ffb800); + } + } + + &.second { + .podium-stand { + height: 70px; + background: linear-gradient(180deg, #e0e0e0, #c0c0c0); + } + } + + &.third { + .podium-stand { + height: 50px; + background: linear-gradient(180deg, #cd9b6a, #b87333); + } + } +} + +.podium-name { + font-size: 13px; + font-weight: 600; + color: var(--ar-text-main); + max-width: 90px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; +} + +.podium-count { + font-size: 11px; + color: var(--ar-primary); + font-weight: 500; +} + +.podium-stand { + width: 90px; + border-radius: 8px 8px 0 0; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 12px; + margin-top: 8px; +} + +.podium-rank { + font-size: 24px; + font-weight: 700; + color: rgba(255, 255, 255, 0.9); + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +@keyframes crownBounce { + + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-4px); + } +} + +// 第4-5名列表 +.runner-up-list { + margin-top: 24px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.runner-up-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: var(--ar-card-bg); + border-radius: 10px; + + .avatar { + width: 36px; + height: 36px; + } +} + +.runner-up-rank { + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--ar-rank-bg); + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + color: var(--ar-rank-color); +} + +.runner-up-name { + flex: 1; + font-size: 13px; + font-weight: 500; + color: var(--ar-text-main); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.runner-up-count { + font-size: 12px; + color: var(--ar-primary); + font-weight: 500; +} + +// 结尾 +.ending { + text-align: center; + align-items: center; +} + +.ending-year { + font-size: 100px; + font-weight: 700; + color: var(--ar-primary); + opacity: 0.1; + margin-top: 30px; + user-select: none; +} + +.ending-brand { + font-size: 14px; + letter-spacing: 4px; + color: var(--ar-text-sub); + margin-top: 20px; + font-weight: 600; +} + +// 双向奔赴 - 新样式 +.mutual-visual { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + margin: 40px 0 24px; +} + +.mutual-side { + display: flex; + align-items: center; + gap: 12px; + + &.friend { + flex-direction: row; + } +} + +.mutual-arrow { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + + .arrow-count { + font-size: 14px; + font-weight: 600; + color: var(--ar-primary); + } + + .arrow-line { + font-size: 20px; + color: var(--ar-text-sub); + opacity: 0.5; + } + + &.reverse { + .arrow-count { + color: var(--ar-accent); + } + } +} + +.mutual-center { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 0 20px; + + .mutual-icon { + font-size: 32px; + } + + .mutual-ratio { + font-size: 18px; + font-weight: 700; + color: var(--ar-accent); + } +} + +.mutual-name-tag { + font-size: 20px; + font-weight: 600; + color: var(--ar-text-main); + text-align: center; + margin-bottom: 12px; +} + +// 常用语列表 +.phrase-list { + margin-top: 24px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.phrase-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + background: var(--ar-card-bg); + border-radius: 12px; + transition: transform 0.2s; + + &:hover { + transform: translateX(4px); + } +} + +.phrase-text { + font-size: 16px; + font-weight: 500; + color: var(--ar-text-main); +} + +.phrase-count { + font-size: 14px; + color: var(--ar-primary); + font-weight: 600; +} + +// 加载动画 +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + + +// 顶部拖动区域 +.drag-region { + position: absolute; // Changed from fixed + top: 0; + left: 0; + right: 0; // Changed from right: 138px (since it's now inside the window container) + height: 32px; + -webkit-app-region: drag !important; + z-index: 100; +} + +// 浮动操作按钮 +.fab-container { + position: fixed; + bottom: 64px; + right: 40px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + z-index: 99; + pointer-events: auto; +} + +.fab-main { + width: 56px; + height: 56px; + border-radius: 50%; + border: none; + background: var(--ar-primary); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 4px 16px rgba(7, 193, 96, 0.4); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + order: 99; + + &:hover { + transform: scale(1.05); + box-shadow: 0 6px 24px rgba(7, 193, 96, 0.5); + } + + .fab-container.open & { + transform: rotate(45deg); + background: var(--ar-text-sub); + } +} + +.fab-item { + width: 56px; + height: 56px; + border-radius: 50%; + border: none; + background: var(--ar-card-bg); + color: var(--ar-text-main); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + opacity: 0; + transform: scale(0.5) translateY(20px); + pointer-events: none; + + .fab-container.open & { + opacity: 1; + transform: scale(1) translateY(0); + pointer-events: auto; + + &:nth-child(1) { + transition-delay: 0.05s; + } + + &:nth-child(2) { + transition-delay: 0.1s; + } + } + + &:hover { + background: var(--ar-primary); + color: #fff; + } +} + +// 导出遮罩和弹窗 +.export-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +// 导出进度弹窗 +.export-progress-modal { + background: var(--bg-primary, #fff); + padding: 40px 48px; + border-radius: 20px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + animation: scaleIn 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.9); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +.export-spinner { + position: relative; + width: 72px; + height: 72px; + display: flex; + align-items: center; + justify-content: center; + + .spinner-ring { + position: absolute; + inset: 0; + border: 3px solid rgba(0, 0, 0, 0.08); + border-top-color: var(--ar-primary); + border-radius: 50%; + animation: spinRing 1s linear infinite; + } + + .spinner-icon { + color: var(--ar-primary); + animation: pulse 1.5s ease-in-out infinite; + } +} + +@keyframes spinRing { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@keyframes pulse { + + 0%, + 100% { + opacity: 0.6; + transform: scale(1); + } + + 50% { + opacity: 1; + transform: scale(1.1); + } +} + +.export-title { + font-size: 18px; + font-weight: 600; + color: var(--ar-text-main); + margin: 0; +} + +.export-status { + font-size: 14px; + color: var(--ar-text-sub); + margin: 0; +} + +.export-modal { + background: var(--bg-primary, #fff); + padding: 0; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + min-width: 280px; + color: var(--ar-text-main); + overflow: hidden; + + p { + margin-top: 12px; + color: var(--ar-text-sub); + } + + &.section-selector { + width: 420px; + max-width: 90vw; + } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + + h3 { + font-size: 18px; + font-weight: 600; + margin: 0; + } + + .close-btn { + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: var(--ar-text-sub); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + } +} + +.section-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + padding: 20px 24px; + max-height: 320px; + overflow-y: auto; +} + +.section-card { + position: relative; + padding: 16px 12px; + border-radius: 10px; + background: var(--ar-card-bg); + border: 2px solid transparent; + cursor: pointer; + text-align: center; + font-size: 13px; + font-weight: 500; + transition: all 0.2s; + + &:hover { + background: var(--ar-card-bg-hover); + } + + &.selected { + border-color: var(--ar-primary); + background: rgba(7, 193, 96, 0.08); + } + + .card-check { + position: absolute; + top: 6px; + right: 6px; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--ar-primary); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transform: scale(0.5); + transition: all 0.2s; + } + + &.selected .card-check { + opacity: 1; + transform: scale(1); + } +} + +.modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-top: 1px solid rgba(0, 0, 0, 0.08); + background: rgba(0, 0, 0, 0.02); + + .select-all-btn { + background: none; + border: none; + padding: 8px 16px; + font-size: 14px; + color: var(--ar-text-sub); + cursor: pointer; + border-radius: 6px; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + } + + .confirm-btn { + background: var(--ar-primary); + border: none; + padding: 10px 24px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + color: #fff; + cursor: pointer; + transition: all 0.2s; + + &:hover:not(:disabled) { + opacity: 0.9; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} + +// 词云样式 +.word-cloud-wrapper { + margin: 24px auto 0; + padding: 0; + max-width: 520px; + display: flex; + justify-content: center; + --cloud-scale: clamp(0.72, 80vw / 520, 1); +} + +.word-cloud-inner { + position: relative; + width: 520px; + height: 520px; + margin: 0; + border-radius: 50%; + transform: scale(var(--cloud-scale)); + transform-origin: center; + + &::before { + content: ""; + position: absolute; + inset: -6%; + background: + radial-gradient(circle at 35% 45%, rgba(7, 193, 96, 0.12), transparent 55%), + radial-gradient(circle at 65% 50%, rgba(242, 170, 0, 0.1), transparent 58%), + radial-gradient(circle at 50% 65%, rgba(0, 0, 0, 0.04), transparent 60%); + filter: blur(18px); + border-radius: 50%; + pointer-events: none; + z-index: 0; + } +} + +.word-tag { + display: inline-block; + padding: 0; + background: transparent; + border-radius: 0; + border: none; + line-height: 1.2; + white-space: nowrap; + transition: transform 0.2s ease, color 0.2s ease; + cursor: default; + color: var(--ar-text-main); + font-weight: 600; + opacity: 0; + animation: wordPopIn 0.55s ease forwards; + position: absolute; + z-index: 1; + transform: translate(-50%, -50%) scale(0.8); + + &:hover { + transform: translate(-50%, -50%) scale(1.08); + color: var(--ar-primary); + z-index: 2; + } +} + +@keyframes wordPopIn { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.6); + } + + 100% { + opacity: var(--final-opacity, 1); + transform: translate(-50%, -50%) scale(1); + } +} + +.word-cloud-note { + margin-top: 24px; + font-size: 14px !important; + color: var(--ar-text-sub) !important; + text-align: center; +} diff --git a/src/pages/AnnualReportWindow.tsx b/src/pages/AnnualReportWindow.tsx new file mode 100644 index 0000000..e29f684 --- /dev/null +++ b/src/pages/AnnualReportWindow.tsx @@ -0,0 +1,1076 @@ +import { useState, useEffect, useRef } from 'react' +import { Loader2, Download, Image, Check, X } from 'lucide-react' +import html2canvas from 'html2canvas' +import { useThemeStore } from '../stores/themeStore' +import './AnnualReportWindow.scss' + +// SVG 背景图案 (用于导出) +const PATTERN_LIGHT_SVG = `` + +const PATTERN_DARK_SVG = `` + +// 绘制 SVG 图案背景到 canvas +const drawPatternBackground = async (ctx: CanvasRenderingContext2D, width: number, height: number, bgColor: string, isDark: boolean) => { + // 先填充背景色 + ctx.fillStyle = bgColor + ctx.fillRect(0, 0, width, height) + + // 加载 SVG 图案 + const svgString = isDark ? PATTERN_DARK_SVG : PATTERN_LIGHT_SVG + const blob = new Blob([svgString], { type: 'image/svg+xml' }) + const url = URL.createObjectURL(blob) + + return new Promise((resolve) => { + const img = new window.Image() + img.onload = () => { + // 平铺绘制图案 + const pattern = ctx.createPattern(img, 'repeat') + if (pattern) { + ctx.fillStyle = pattern + ctx.fillRect(0, 0, width, height) + } + URL.revokeObjectURL(url) + resolve() + } + img.onerror = () => { + URL.revokeObjectURL(url) + resolve() + } + img.src = url + }) +} + +interface TopContact { + username: string + displayName: string + avatarUrl?: string + messageCount: number + sentCount: number + receivedCount: number +} + +interface MonthlyTopFriend { + month: number + displayName: string + avatarUrl?: string + messageCount: number +} + +interface AnnualReportData { + year: number + totalMessages: number + totalFriends: number + coreFriends: TopContact[] + monthlyTopFriends: MonthlyTopFriend[] + peakDay: { date: string; messageCount: number; topFriend?: string; topFriendCount?: number } | null + longestStreak: { friendName: string; days: number; startDate: string; endDate: string } | null + activityHeatmap: { data: number[][] } + midnightKing: { displayName: string; count: number; percentage: number } | null + selfAvatarUrl?: string + mutualFriend?: { displayName: string; avatarUrl?: string; sentCount: number; receivedCount: number; ratio: number } | null + socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null + responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null + topPhrases?: { phrase: string; count: number }[] +} + +interface SectionInfo { + id: string + name: string + ref: React.RefObject +} + +// 头像组件 +const Avatar = ({ url, name, size = 'md' }: { url?: string; name: string; size?: 'sm' | 'md' | 'lg' }) => { + const [imgError, setImgError] = useState(false) + const initial = name?.[0] || '友' + + return ( +
+ {url && !imgError ? ( + setImgError(true)} crossOrigin="anonymous" /> + ) : ( + {initial} + )} +
+ ) +} + +// 热力图组件 +const Heatmap = ({ data }: { data: number[][] }) => { + const maxHeat = Math.max(...data.flat()) + const weekLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] + + return ( +
+
+
+
+ {[0, 6, 12, 18].map(h => ( + {h} + ))} +
+
+
+
+ {weekLabels.map(w =>
{w}
)} +
+
+ {data.map((row, wi) => + row.map((val, hi) => { + const alpha = maxHeat > 0 ? (val / maxHeat * 0.85 + 0.1).toFixed(2) : '0.1' + return ( +
+ ) + }) + )} +
+
+
+ ) +} + +// 词云组件 +const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => { + const maxCount = words.length > 0 ? words[0].count : 1 + const topWords = words.slice(0, 32) + const baseSize = 520 + + // 使用确定性随机数生成器 + const seededRandom = (seed: number) => { + const x = Math.sin(seed) * 10000 + return x - Math.floor(x) + } + + // 计算词云位置 + const placedItems: { x: number; y: number; w: number; h: number }[] = [] + + const canPlace = (x: number, y: number, w: number, h: number): boolean => { + const halfW = w / 2 + const halfH = h / 2 + const dx = x - 50 + const dy = y - 50 + const dist = Math.sqrt(dx * dx + dy * dy) + const maxR = 49 - Math.max(halfW, halfH) + if (dist > maxR) return false + + const pad = 1.8 + for (const p of placedItems) { + if ((x - halfW - pad) < (p.x + p.w / 2) && + (x + halfW + pad) > (p.x - p.w / 2) && + (y - halfH - pad) < (p.y + p.h / 2) && + (y + halfH + pad) > (p.y - p.h / 2)) { + return false + } + } + return true + } + + const wordItems = topWords.map((item, i) => { + const ratio = item.count / maxCount + const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20) + const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65)) + const delay = (i * 0.04).toFixed(2) + + // 计算词语宽度 + const charCount = Math.max(1, item.phrase.length) + const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase) + const hasLatin = /[A-Za-z0-9]/.test(item.phrase) + const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6 + const widthPx = fontSize * (charCount * widthFactor) + const heightPx = fontSize * 1.1 + const widthPct = (widthPx / baseSize) * 100 + const heightPct = (heightPx / baseSize) * 100 + + // 寻找位置 + let x = 50, y = 50 + let placedOk = false + const tries = i === 0 ? 1 : 420 + + for (let t = 0; t < tries; t++) { + if (i === 0) { + x = 50 + y = 50 + } else { + const idx = i + t * 0.28 + const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6) + const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35 + x = 50 + radius * Math.cos(angle) + y = 50 + radius * Math.sin(angle) + } + if (canPlace(x, y, widthPct, heightPct)) { + placedOk = true + break + } + } + + if (!placedOk) return null + placedItems.push({ x, y, w: widthPct, h: heightPct }) + + return ( + + {item.phrase} + + ) + }).filter(Boolean) + + return ( +
+
+ {wordItems} +
+
+ ) +} + +function AnnualReportWindow() { + const [reportData, setReportData] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [isExporting, setIsExporting] = useState(false) + const [exportProgress, setExportProgress] = useState('') + const [showExportModal, setShowExportModal] = useState(false) + const [selectedSections, setSelectedSections] = useState>(new Set()) + const [fabOpen, setFabOpen] = useState(false) + const [loadingProgress, setLoadingProgress] = useState(0) + const [loadingStage, setLoadingStage] = useState('正在初始化...') + + const { currentTheme, themeMode } = useThemeStore() + + // 应用主题到独立窗口 + useEffect(() => { + document.documentElement.setAttribute('data-theme', currentTheme) + document.documentElement.setAttribute('data-mode', themeMode) + }, [currentTheme, themeMode]) + + // Section refs + const sectionRefs = { + cover: useRef(null), + overview: useRef(null), + bestFriend: useRef(null), + monthlyFriends: useRef(null), + mutualFriend: useRef(null), + socialInitiative: useRef(null), + peakDay: useRef(null), + streak: useRef(null), + heatmap: useRef(null), + midnightKing: useRef(null), + responseSpeed: useRef(null), + topPhrases: useRef(null), + ranking: useRef(null), + ending: useRef(null), + } + + const containerRef = useRef(null) + + useEffect(() => { + const params = new URLSearchParams(window.location.hash.split('?')[1] || '') + const yearParam = params.get('year') + const year = yearParam ? parseInt(yearParam) : new Date().getFullYear() + generateReport(year) + }, []) + + const generateReport = async (year: number) => { + setIsLoading(true) + setError(null) + setLoadingProgress(0) + + const removeProgressListener = window.electronAPI.annualReport.onProgress?.((payload: { status: string; progress: number }) => { + setLoadingProgress(payload.progress) + setLoadingStage(payload.status) + }) + + try { + const result = await window.electronAPI.annualReport.generateReport(year) + removeProgressListener?.() + setLoadingProgress(100) + setLoadingStage('完成') + + if (result.success && result.data) { + setTimeout(() => { + setReportData(result.data!) + setIsLoading(false) + }, 300) + } else { + setError(result.error || '生成报告失败') + setIsLoading(false) + } + } catch (e) { + removeProgressListener?.() + setError(String(e)) + setIsLoading(false) + } + } + + const formatNumber = (num: number) => num.toLocaleString() + + const getMostActiveTime = (data: number[][]) => { + let maxHour = 0, maxWeekday = 0, maxVal = 0 + data.forEach((row, w) => { + row.forEach((val, h) => { + if (val > maxVal) { maxVal = val; maxHour = h; maxWeekday = w } + }) + }) + const weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] + return { weekday: weekdayNames[maxWeekday], hour: maxHour } + } + + const formatTime = (seconds: number) => { + if (seconds < 60) return `${seconds}秒` + if (seconds < 3600) return `${Math.round(seconds / 60)}分钟` + return `${Math.round(seconds / 3600)}小时` + } + + // 获取可用的板块列表 + const getAvailableSections = (): SectionInfo[] => { + if (!reportData) return [] + const sections: SectionInfo[] = [ + { id: 'cover', name: '封面', ref: sectionRefs.cover }, + { id: 'overview', name: '年度概览', ref: sectionRefs.overview }, + ] + if (reportData.coreFriends[0]) { + sections.push({ id: 'bestFriend', name: '年度挚友', ref: sectionRefs.bestFriend }) + } + sections.push({ id: 'monthlyFriends', name: '月度好友', ref: sectionRefs.monthlyFriends }) + if (reportData.mutualFriend) { + sections.push({ id: 'mutualFriend', name: '双向奔赴', ref: sectionRefs.mutualFriend }) + } + if (reportData.socialInitiative) { + sections.push({ id: 'socialInitiative', name: '社交主动性', ref: sectionRefs.socialInitiative }) + } + if (reportData.peakDay) { + sections.push({ id: 'peakDay', name: '巅峰时刻', ref: sectionRefs.peakDay }) + } + if (reportData.longestStreak) { + sections.push({ id: 'streak', name: '聊天火花', ref: sectionRefs.streak }) + } + sections.push({ id: 'heatmap', name: '作息规律', ref: sectionRefs.heatmap }) + if (reportData.midnightKing) { + sections.push({ id: 'midnightKing', name: '深夜好友', ref: sectionRefs.midnightKing }) + } + if (reportData.responseSpeed) { + sections.push({ id: 'responseSpeed', name: '回应速度', ref: sectionRefs.responseSpeed }) + } + if (reportData.topPhrases && reportData.topPhrases.length > 0) { + sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases }) + } + sections.push({ id: 'ranking', name: '好友排行', ref: sectionRefs.ranking }) + sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending }) + return sections + } + + // 导出单个板块 - 统一 16:9 尺寸 + const exportSection = async (section: SectionInfo): Promise<{ name: string; data: string } | null> => { + const element = section.ref.current + if (!element) { + return null + } + + // 固定输出尺寸 1920x1080 (16:9) + const OUTPUT_WIDTH = 1920 + const OUTPUT_HEIGHT = 1080 + + try { + const selection = window.getSelection() + if (selection && selection.rangeCount > 0) selection.removeAllRanges() + const activeEl = document.activeElement as HTMLElement | null + activeEl?.blur?.() + document.body.classList.add('exporting-snapshot') + document.documentElement.classList.add('exporting-snapshot') + + const originalStyle = element.style.cssText + element.style.minHeight = 'auto' + element.style.padding = '40px 20px' + element.style.background = 'transparent' + element.style.backgroundColor = 'transparent' + element.style.boxShadow = 'none' + + // 修复词云 + const wordCloudInner = element.querySelector('.word-cloud-inner') as HTMLElement + const wordTags = element.querySelectorAll('.word-tag') as NodeListOf + let wordCloudOriginalStyle = '' + const wordTagOriginalStyles: string[] = [] + + if (wordCloudInner) { + wordCloudOriginalStyle = wordCloudInner.style.cssText + wordCloudInner.style.transform = 'none' + } + + wordTags.forEach((tag, i) => { + wordTagOriginalStyles[i] = tag.style.cssText + tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1') + tag.style.animation = 'none' + }) + + await new Promise(r => setTimeout(r, 50)) + + const computedStyle = getComputedStyle(document.documentElement) + const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6' + + const canvas = await html2canvas(element, { + backgroundColor: 'transparent', // 透明背景,让 SVG 图案显示 + scale: 2, + useCORS: true, + allowTaint: true, + logging: false, + onclone: (clonedDoc) => { + clonedDoc.body.classList.add('exporting-snapshot') + clonedDoc.documentElement.classList.add('exporting-snapshot') + clonedDoc.getSelection?.()?.removeAllRanges() + }, + }) + + // 恢复样式 + element.style.cssText = originalStyle + if (wordCloudInner) { + wordCloudInner.style.cssText = wordCloudOriginalStyle + } + wordTags.forEach((tag, i) => { + tag.style.cssText = wordTagOriginalStyles[i] + }) + document.body.classList.remove('exporting-snapshot') + document.documentElement.classList.remove('exporting-snapshot') + + // 创建固定 16:9 尺寸的画布 + const outputCanvas = document.createElement('canvas') + outputCanvas.width = OUTPUT_WIDTH + outputCanvas.height = OUTPUT_HEIGHT + const ctx = outputCanvas.getContext('2d')! + + // 绘制带 SVG 图案的背景 + const isDark = themeMode === 'dark' + await drawPatternBackground(ctx, OUTPUT_WIDTH, OUTPUT_HEIGHT, bgColor, isDark) + + // 边距 (留出更多空白) + const PADDING = 80 + const contentWidth = OUTPUT_WIDTH - PADDING * 2 + const contentHeight = OUTPUT_HEIGHT - PADDING * 2 + + // 计算缩放和居中位置 + const srcRatio = canvas.width / canvas.height + const dstRatio = contentWidth / contentHeight + let drawWidth: number, drawHeight: number, drawX: number, drawY: number + + if (srcRatio > dstRatio) { + // 源图更宽,以宽度为准 + drawWidth = contentWidth + drawHeight = contentWidth / srcRatio + drawX = PADDING + drawY = PADDING + (contentHeight - drawHeight) / 2 + } else { + // 源图更高,以高度为准 + drawHeight = contentHeight + drawWidth = contentHeight * srcRatio + drawX = PADDING + (contentWidth - drawWidth) / 2 + drawY = PADDING + } + + ctx.drawImage(canvas, drawX, drawY, drawWidth, drawHeight) + + return { name: section.name, data: outputCanvas.toDataURL('image/png') } + } catch (e) { + document.body.classList.remove('exporting-snapshot') + return null + } + } + + // 导出整个报告为长图 + const exportFullReport = async () => { + if (!containerRef.current) { + return + } + setIsExporting(true) + setExportProgress('正在生成长图...') + + try { + const selection = window.getSelection() + if (selection && selection.rangeCount > 0) selection.removeAllRanges() + const activeEl = document.activeElement as HTMLElement | null + activeEl?.blur?.() + document.body.classList.add('exporting-snapshot') + document.documentElement.classList.add('exporting-snapshot') + + const container = containerRef.current + const sections = container.querySelectorAll('.section') + const originalStyles: string[] = [] + + sections.forEach((section, i) => { + const el = section as HTMLElement + originalStyles[i] = el.style.cssText + el.style.minHeight = 'auto' + el.style.padding = '40px 0' + }) + + // 修复词云导出问题 + const wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement + const wordTags = container.querySelectorAll('.word-tag') as NodeListOf + let wordCloudOriginalStyle = '' + const wordTagOriginalStyles: string[] = [] + + if (wordCloudInner) { + wordCloudOriginalStyle = wordCloudInner.style.cssText + wordCloudInner.style.transform = 'none' + } + + wordTags.forEach((tag, i) => { + wordTagOriginalStyles[i] = tag.style.cssText + tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1') + tag.style.animation = 'none' + }) + + // 等待样式生效 + await new Promise(r => setTimeout(r, 100)) + + // 获取计算后的背景色 + const computedStyle = getComputedStyle(document.documentElement) + const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6' + + const canvas = await html2canvas(container, { + backgroundColor: 'transparent', // 透明背景 + scale: 2, + useCORS: true, + allowTaint: true, + logging: false, + onclone: (clonedDoc) => { + clonedDoc.body.classList.add('exporting-snapshot') + clonedDoc.documentElement.classList.add('exporting-snapshot') + clonedDoc.getSelection?.()?.removeAllRanges() + }, + }) + + // 恢复原始样式 + sections.forEach((section, i) => { + const el = section as HTMLElement + el.style.cssText = originalStyles[i] + }) + + if (wordCloudInner) { + wordCloudInner.style.cssText = wordCloudOriginalStyle + } + + wordTags.forEach((tag, i) => { + tag.style.cssText = wordTagOriginalStyles[i] + }) + document.body.classList.remove('exporting-snapshot') + document.documentElement.classList.remove('exporting-snapshot') + + // 创建带 SVG 图案背景的输出画布 + const outputCanvas = document.createElement('canvas') + outputCanvas.width = canvas.width + outputCanvas.height = canvas.height + const ctx = outputCanvas.getContext('2d')! + + // 绘制 SVG 图案背景 + const isDark = themeMode === 'dark' + await drawPatternBackground(ctx, canvas.width, canvas.height, bgColor, isDark) + + // 绘制内容 + ctx.drawImage(canvas, 0, 0) + + const dataUrl = outputCanvas.toDataURL('image/png') + const link = document.createElement('a') + link.download = `${reportData?.year}年度报告.png` + link.href = dataUrl + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } catch (e) { + alert('导出失败: ' + String(e)) + } finally { + document.body.classList.remove('exporting-snapshot') + document.documentElement.classList.remove('exporting-snapshot') + setIsExporting(false) + setExportProgress('') + } + } + + // 导出选中的板块 + const exportSelectedSections = async () => { + const sections = getAvailableSections().filter(s => selectedSections.has(s.id)) + if (sections.length === 0) { + alert('请至少选择一个板块') + return + } + + setIsExporting(true) + setShowExportModal(false) + + const exportedImages: { name: string; data: string }[] = [] + + for (let i = 0; i < sections.length; i++) { + const section = sections[i] + setExportProgress(`正在导出: ${section.name} (${i + 1}/${sections.length})`) + + const result = await exportSection(section) + if (result) { + exportedImages.push(result) + } + } + + if (exportedImages.length === 0) { + alert('导出失败') + setIsExporting(false) + setExportProgress('') + return + } + + const dirResult = await window.electronAPI.dialog.openDirectory({ + title: '选择导出文件夹', + properties: ['openDirectory', 'createDirectory'] + }) + if (dirResult.canceled || !dirResult.filePaths?.[0]) { + setIsExporting(false) + setExportProgress('') + return + } + + setExportProgress('正在写入文件...') + const exportResult = await window.electronAPI.annualReport.exportImages({ + baseDir: dirResult.filePaths[0], + folderName: `${reportData?.year}年度报告_分模块`, + images: exportedImages.map((img) => ({ + name: `${reportData?.year}年度报告_${img.name}.png`, + dataUrl: img.data + })) + }) + + if (!exportResult.success) { + alert('导出失败: ' + (exportResult.error || '未知错误')) + } + + setIsExporting(false) + setExportProgress('') + setSelectedSections(new Set()) + } + + // 切换板块选择 + const toggleSection = (id: string) => { + const newSet = new Set(selectedSections) + if (newSet.has(id)) { + newSet.delete(id) + } else { + newSet.add(id) + } + setSelectedSections(newSet) + } + + // 全选/取消全选 + const toggleAll = () => { + const sections = getAvailableSections() + if (selectedSections.size === sections.length) { + setSelectedSections(new Set()) + } else { + setSelectedSections(new Set(sections.map(s => s.id))) + } + } + + if (isLoading) { + return ( +
+
+ + + + + {loadingProgress}% +
+

{loadingStage}

+

进行中

+
+ ) + } + + if (error) { + return ( +
+

生成报告失败: {error}

+
+ ) + } + + if (!reportData) { + return ( +
+

暂无数据

+
+ ) + } + + const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases } = reportData + const topFriend = coreFriends[0] + const mostActive = getMostActiveTime(activityHeatmap.data) + const socialStoryName = topFriend?.displayName || '好友' + + return ( +
+
+ + {/* 背景装饰 */} +
+
+
+
+
+
+
+ + {/* 浮动操作按钮 */} +
+ + + +
+ + {/* 导出进度 */} + {isExporting && ( +
+
+
+
+ +
+

正在导出

+

{exportProgress}

+
+
+ )} + + {/* 模块选择弹窗 */} + {showExportModal && ( +
setShowExportModal(false)}> +
e.stopPropagation()}> +
+

选择要导出的板块

+ +
+
+ {getAvailableSections().map(section => ( +
toggleSection(section.id)} + > +
+ {selectedSections.has(section.id) && } +
+ {section.name} +
+ ))} +
+
+ + +
+
+
+ )} + +
+
+ + {/* 封面 */} +
+
WEFLOW · ANNUAL REPORT
+

{year}年
微信聊天报告

+
+

每一条消息背后
都藏着一段独特的故事

+
+ + {/* 年度概览 */} +
+
年度概览
+

你和你的朋友们
互相发过

+
+ {formatNumber(totalMessages)} + 条消息 +
+

+ 在这段时光里,你与 {formatNumber(totalFriends)} 位好友交换过喜怒哀乐。 +
每一个对话,都是一段故事的开始。 +

+
+ + {/* 年度挚友 */} + {topFriend && ( +
+
年度挚友
+

{topFriend.displayName}

+
+ {formatNumber(topFriend.messageCount)} + 条消息 +
+

+ 你发出 {formatNumber(topFriend.sentCount)} 条 · + TA发来 {formatNumber(topFriend.receivedCount)} 条 +

+
+

+ 在一起,就可以 +

+
+ )} + + {/* 月度好友 */} +
+
月度好友
+

{year}年月度好友

+

根据12个月的聊天习惯

+
+ {monthlyTopFriends.map((m, i) => ( +
+
{m.month}月
+ +
{m.displayName}
+
+ ))} +
+ +
+
+

只要你想
我一直在

+
+ + {/* 双向奔赴 */} + {mutualFriend && ( +
+
双向奔赴
+

默契与平衡

+
+
+ +
+ {formatNumber(mutualFriend.sentCount)} +
+
+
+
+
🤝
+
{mutualFriend.ratio}
+
+
+
+ {formatNumber(mutualFriend.receivedCount)} +
+
+ +
+
+
{mutualFriend.displayName}
+

+ 你们的互动比例接近 {mutualFriend.ratio}。 +
你来我往,势均力敌。 +

+
+ )} + + {/* 社交主动性 */} + {socialInitiative && ( +
+
社交主动性
+

主动才有故事

+
+ {socialInitiative.initiativeRate}% + 的对话由你发起 +
+

+ 面对 {socialStoryName} 的时候,你总是那个先开口的人。 +

+
+ )} + + {/* 巅峰时刻 */} + {peakDay && ( +
+
巅峰时刻
+

{peakDay.date}

+

一天里你一共发了

+
+ {formatNumber(peakDay.messageCount)} + 条消息 +
+

+ 在这个快节奏的世界,有人正陪在你身边听你慢慢地讲 +
那天,你和 {peakDay.topFriend || '好友'} 的 {formatNumber(peakDay.topFriendCount || 0)} 条消息见证着这一切 +
有些话,只想对你说 +

+
+ )} + + {/* 聊天火花 */} + {longestStreak && ( +
+
持之以恒
+

聊天火花

+

{longestStreak.friendName} 持续了

+
+ {longestStreak.days} + +
+

+ 从 {longestStreak.startDate} 到 {longestStreak.endDate} +

+

陪伴,是最长情的告白

+
+ )} + + {/* 作息规律 */} +
+
作息规律
+

时间的痕迹

+

+ 在 {mostActive.weekday} {String(mostActive.hour).padStart(2, '0')}:00 最活跃 +

+ +
+ + {/* 深夜好友 */} + {midnightKing && ( +
+
深夜好友
+

当城市睡去

+

这一年你留下了

+
+ {midnightKing.count} + 条深夜的消息 +
+

+ 其中 {midnightKing.displayName} 常常在深夜中陪着你。 +
你和Ta的对话占深夜期间聊天的 {midnightKing.percentage}%。 +

+
+ )} + + {/* 回应速度 */} + {responseSpeed && ( +
+
回应速度
+

念念不忘,必有回响

+
+ {formatTime(responseSpeed.avgResponseTime)} + 是你的平均回复时间 +
+

+ 你回复 {responseSpeed.fastestFriend} 最快 +
平均只需 {formatTime(responseSpeed.fastestTime)} +

+
+ )} + + {/* 年度常用语 - 词云 */} + {topPhrases && topPhrases.length > 0 && ( +
+
年度常用语
+

你在{year}年的年度常用语

+

+ 这一年,你说得最多的是: +
+ + {topPhrases.slice(0, 3).map(p => p.phrase).join('、')} + +

+ +

颜色越深代表出现频率越高

+
+ )} + + {/* 好友排行 */} +
+
好友排行
+

聊得最多的人

+ + {/* 领奖台 - 前三名 */} +
+ {/* 第二名 - 左边 */} + {coreFriends[1] && ( +
+ +
{coreFriends[1].displayName}
+
{formatNumber(coreFriends[1].messageCount)} 条
+
+ 2 +
+
+ )} + + {/* 第一名 - 中间最高 */} + {coreFriends[0] && ( +
+
👑
+ +
{coreFriends[0].displayName}
+
{formatNumber(coreFriends[0].messageCount)} 条
+
+ 1 +
+
+ )} + + {/* 第三名 - 右边 */} + {coreFriends[2] && ( +
+ +
{coreFriends[2].displayName}
+
{formatNumber(coreFriends[2].messageCount)} 条
+
+ 3 +
+
+ )} +
+
+ + {/* 结尾 */} +
+

尾声

+

+ 我们总是在向前走 +
却很少有机会回头看看 +
如果这份报告让你有所触动,不妨把它分享给你在意的人 +
愿新的一年, +
所有期待,皆有回声。 +

+
{year}
+
WEFLOW
+
+
+
+
+ ) +} + +export default AnnualReportWindow diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss new file mode 100644 index 0000000..9d36dbf --- /dev/null +++ b/src/pages/ChatPage.scss @@ -0,0 +1,1845 @@ +.chat-page { + display: flex; + height: 100%; + gap: 16px; + + // 独立窗口模式 - EchoTrace 特色风格(使用主题变量) + &.standalone { + height: 100vh; + gap: 0; + background: var(--bg-gradient); + + .session-sidebar { + flex: none; + background: var(--card-bg); + border-radius: 0; + border-right: 1px solid var(--border-color); + -webkit-app-region: no-drag; + backdrop-filter: blur(20px); + + .session-header { + padding-top: 38px; + padding-bottom: 12px; + padding-left: 16px; + padding-right: 16px; + background: transparent; + -webkit-app-region: drag; + + h2 { + display: none; + } + + .header-actions { + display: none; + } + + .search-row { + display: flex; + align-items: center; + gap: 8px; + -webkit-app-region: no-drag; + min-width: 0; + } + + .search-box { + flex: 1; + min-width: 0; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 8px 12px; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s; + + &:focus-within { + background: var(--bg-hover); + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-light); + } + + input { + flex: 1; + border: none; + background: transparent; + outline: none; + color: var(--text-primary); + font-size: 13px; + + &::placeholder { + color: var(--text-tertiary); + } + } + + svg { + color: var(--text-tertiary); + flex-shrink: 0; + } + + .close-search { + width: 18px; + height: 18px; + padding: 0; + border: none; + background: var(--bg-hover); + border-radius: 50%; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + &:hover { + background: var(--border-color); + color: var(--text-primary); + } + } + } + + .refresh-btn { + width: 32px; + height: 32px; + padding: 0; + border: none; + background: var(--bg-tertiary); + border-radius: 8px; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .spin { + animation: spin 1s linear infinite; + } + } + } + + .session-list { + background: transparent; + } + + .session-item { + border-bottom: 1px solid var(--border-color); + padding: 12px 16px; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + } + + &.active { + background: var(--primary-light); + border-left: 3px solid var(--primary); + padding-left: 13px; + } + + .session-name { + color: var(--text-primary); + font-size: 14px; + font-weight: 500; + } + + .session-time { + color: var(--text-tertiary); + font-size: 11px; + } + + .session-summary { + color: var(--text-secondary); + font-size: 12px; + } + } + + .session-avatar { + border-radius: 50%; + width: 44px !important; + height: 44px !important; + } + + .unread-badge { + background: var(--primary-gradient); + box-shadow: 0 2px 8px var(--primary-light); + } + + .connection-error { + background: rgba(220, 53, 69, 0.08); + border: 1px solid rgba(220, 53, 69, 0.2); + margin: 8px 16px; + border-radius: 10px; + + svg, + span { + color: var(--danger); + } + + button { + background: var(--danger); + border-radius: 6px; + } + } + + .empty-sessions { + color: var(--text-tertiary); + + svg { + color: var(--text-tertiary); + opacity: 0.5; + } + } + + .skeleton-item { + .skeleton-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--bg-tertiary); + } + + .skeleton-content .skeleton-line { + background: var(--bg-tertiary); + } + } + + .loading-sessions { + background: transparent; + } + + .session-list { + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + opacity: 0.3; + border-radius: 3px; + + &:hover { + opacity: 0.5; + } + } + } + } + + .message-area { + border-radius: 0; + background: var(--bg-secondary); + -webkit-app-region: no-drag; + + .message-header { + height: auto; + padding: 0 24px; + padding-top: 38px; + padding-bottom: 12px; + background: var(--card-bg); + border-bottom: 1px solid var(--border-color); + -webkit-app-region: drag; + backdrop-filter: blur(10px); + + .session-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + -webkit-app-region: no-drag; + } + + .header-info { + -webkit-app-region: no-drag; + + h3 { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + } + + .header-subtitle { + font-size: 11px; + color: var(--text-tertiary); + margin-top: 2px; + } + } + + .header-actions { + display: flex; + align-items: center; + gap: 8px; + -webkit-app-region: no-drag; + + .icon-btn { + width: 34px; + height: 34px; + border: none; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + transition: all 0.2s; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18); + } + + &.active { + background: var(--primary); + color: #fff; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .spin { + animation: spin 1s linear infinite; + } + } + } + } + + .message-content-wrapper { + flex: 1; + display: flex; + overflow: hidden; + } + + .message-list { + flex: 1; + background: var(--chat-pattern); + background-color: var(--bg-secondary); + padding: 20px 24px; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + opacity: 0.3; + border-radius: 3px; + + &:hover { + opacity: 0.5; + } + } + + .scroll-to-bottom { + background: var(--primary); + border: none; + color: #fff; + box-shadow: 0 4px 15px var(--primary-light); + + &:hover { + background: var(--primary-hover); + box-shadow: 0 6px 20px var(--primary-light); + } + } + } + + .message-wrapper { + margin-bottom: 16px; + } + + .message-bubble { + max-width: 65%; + + &.sent { + .bubble-content { + background: var(--primary-gradient); + color: #fff; + border-radius: 18px 18px 4px 18px; + padding: 10px 14px; + font-size: 14px; + line-height: 1.5; + box-shadow: 0 2px 10px var(--primary-light); + } + } + + &.received { + .bubble-content { + background: var(--card-bg); + color: var(--text-primary); + border-radius: 18px 18px 18px 4px; + padding: 10px 14px; + font-size: 14px; + line-height: 1.5; + backdrop-filter: blur(10px); + border: 1px solid var(--border-color); + } + } + + &.system { + max-width: 100%; + + .bubble-content { + background: transparent; + color: var(--text-tertiary); + font-size: 12px; + padding: 4px 0; + } + } + + &.emoji { + .bubble-content { + background: transparent !important; + padding: 0; + box-shadow: none; + border: none; + } + } + } + + .bubble-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + } + + .sender-name { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 4px; + } + + .quoted-message { + background: var(--bg-tertiary); + border-left: 2px solid var(--primary); + padding: 6px 10px; + margin-bottom: 8px; + border-radius: 4px; + font-size: 12px; + + .quoted-sender { + color: var(--primary); + } + + .quoted-text { + color: var(--text-secondary); + } + } + + .time-divider, + .date-divider { + span { + font-size: 11px; + color: var(--text-tertiary); + } + } + + .date-divider span { + background: var(--bg-tertiary); + padding: 4px 12px; + border-radius: 12px; + } + + .load-more-trigger { + color: var(--text-tertiary); + font-size: 12px; + } + + .empty-chat { + color: var(--text-tertiary); + + svg { + color: var(--text-tertiary); + opacity: 0.4; + } + } + + .loading-messages { + color: var(--text-tertiary); + } + } + + .resize-handle { + width: 4px; + margin-left: -2px; + margin-right: -2px; + background: transparent; + cursor: col-resize; + -webkit-app-region: no-drag; + transition: background 0.2s; + position: relative; + z-index: 10; + + &:hover { + background: var(--primary); + opacity: 0.4; + } + } + + &.resizing .resize-handle { + background: var(--primary); + } + } +} + +// 左侧会话列表 +.session-sidebar { + flex: 0 0 30%; + min-width: 280px; + max-width: 400px; + display: flex; + flex-direction: column; + background: var(--bg-secondary); + border-radius: 16px; + overflow: hidden; +} + +.session-header { + padding: 16px 16px 12px; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 56px; + + h2 { + font-size: 18px; + font-weight: 600; + margin: 0; + color: var(--text-primary); + white-space: nowrap; + } + + .header-actions { + display: flex; + gap: 4px; + } + + .icon-btn { + width: 32px; + height: 32px; + border: none; + background: transparent; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + + &:hover { + background: var(--bg-hover); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .spin { + animation: spin 1s linear infinite; + } + } + + .search-row { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + } + + .search-box { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--bg-primary); + border-radius: 8px; + animation: searchExpand 0.25s ease-out; + min-width: 0; + + svg { + color: var(--text-tertiary); + flex-shrink: 0; + } + + input { + flex: 1; + border: none; + background: transparent; + outline: none; + font-size: 14px; + color: var(--text-primary); + min-width: 0; + + &::placeholder { + color: var(--text-tertiary); + } + } + + .close-search { + width: 20px; + height: 20px; + border: none; + background: var(--bg-tertiary); + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + flex-shrink: 0; + + &:hover { + background: var(--border-color); + color: var(--text-primary); + } + } + } +} + +@keyframes searchExpand { + from { + opacity: 0; + transform: scaleX(0.8); + transform-origin: right center; + } + + to { + opacity: 1; + transform: scaleX(1); + transform-origin: right center; + } +} + +.session-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + + // 滚动条样式 + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + + &:hover { + background: rgba(0, 0, 0, 0.3); + } + } +} + +.session-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + cursor: pointer; + transition: background 0.15s; + border-bottom: 1px solid var(--border-color); + + &:last-child { + border-bottom: none; + } + + &:hover { + background: var(--bg-hover); + } + + &.active { + background: var(--primary-light); + } +} + +.session-avatar { + width: 48px; + height: 48px; + border-radius: 8px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; + position: relative; + + img { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 0.3s ease; + + &.loaded { + opacity: 1; + } + } + + .avatar-letter { + color: white; + font-size: 18px; + font-weight: 600; + } + + .avatar-skeleton { + position: absolute; + inset: 0; + background: var(--bg-tertiary); + animation: pulse 1.5s infinite; + } + + &.group { + background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); + } + + &.loading { + background: var(--bg-tertiary); + animation: pulse 1.5s infinite; + } +} + +.session-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.session-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.session-name { + font-size: 15px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + +.session-time { + font-size: 12px; + color: var(--text-tertiary); + flex-shrink: 0; +} + +.session-bottom { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.session-summary { + font-size: 13px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + +.unread-badge { + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: #f56c6c; + color: white; + font-size: 11px; + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +// 右侧消息区域 +.message-area { + flex: 1 1 70%; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: var(--bg-secondary); + border-radius: 16px; + overflow: hidden; + -webkit-app-region: no-drag; +} + +.message-header { + padding: 16px 20px; + display: flex; + align-items: center; + gap: 12px; + border-bottom: 1px solid var(--border-color); + + .session-avatar { + width: 40px; + height: 40px; + } + + .header-info { + flex: 1; + + h3 { + font-size: 16px; + font-weight: 600; + margin: 0; + color: var(--text-primary); + } + + .header-subtitle { + font-size: 12px; + color: var(--text-tertiary); + margin-top: 2px; + } + } + + .refresh-messages-btn { + .spin { + animation: spin 1s linear infinite; + } + } + + .header-actions { + display: flex; + align-items: center; + gap: 8px; + } + + .icon-btn { + width: 34px; + height: 34px; + border: none; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18); + } + + &.active { + background: var(--primary); + color: #fff; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} + +.message-content-wrapper { + flex: 1; + display: flex; + min-width: 0; + min-height: 0; + overflow: hidden; + -webkit-app-region: no-drag; +} + +.message-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; + padding: 20px 24px; + display: flex; + flex-direction: column; + gap: 16px; + background: var(--chat-pattern); + background-color: var(--bg-tertiary); + position: relative; + -webkit-app-region: no-drag !important; + + // 滚动条样式 + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + + &:hover { + background: rgba(0, 0, 0, 0.3); + } + } +} + +.message-list * { + -webkit-app-region: no-drag !important; +} + +// 回到底部按钮 +.scroll-to-bottom { + position: sticky; + bottom: 20px; + align-self: center; + padding: 8px 16px; + border-radius: 20px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + color: var(--text-secondary); + font-size: 13px; + z-index: 10; + opacity: 0; + transform: translateY(20px); + pointer-events: none; + transition: all 0.3s ease; + + &.show { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + } + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + } +} + +// 拖动调节条 +.resize-handle { + width: 4px; + cursor: col-resize; + background: transparent; + transition: background 0.2s; + flex-shrink: 0; + + &:hover { + background: var(--primary); + } +} + +// 拖动时禁用选择 +.chat-page.resizing { + user-select: none; + + .resize-handle { + background: var(--primary); + } +} + +// 消息包裹器 - 控制整行对齐 +.message-wrapper { + display: flex; + flex-direction: column; + -webkit-app-region: no-drag; + + &.sent { + align-items: flex-end; + } + + &.received { + align-items: flex-start; + } + + &.system { + align-items: center; + } +} + +.message-wrapper.new-message { + animation: messagePop 0.35s ease-out; +} + +@keyframes messagePop { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.load-more-trigger { + text-align: center; + padding: 12px; + color: var(--text-tertiary); + font-size: 13px; + + &.loading { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } +} + +// 消息气泡 +.message-bubble { + display: flex; + gap: 10px; + max-width: 70%; + align-items: flex-start; + -webkit-app-region: no-drag; + + // 自己发送的消息 - 右侧绿色 + &.sent { + flex-direction: row-reverse; + + .bubble-content { + background: var(--primary); + color: white; + border-radius: 18px 18px 4px 18px; + } + } + + // 对方发送的消息 - 左侧白色 + &.received { + .bubble-content { + background: var(--bg-secondary); + color: var(--text-primary); + border-radius: 18px 18px 18px 4px; + } + } + + &.system { + max-width: 85%; + + .bubble-avatar { + display: none; + } + + .bubble-body { + width: 100%; + } + + .bubble-content { + background: rgba(0, 0, 0, 0.04); + color: var(--text-tertiary); + font-size: 12px; + text-align: center; + padding: 8px 16px; + border-radius: 16px; + } + } +} + +.bubble-avatar { + width: 36px; + height: 36px; + border-radius: 8px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .avatar-letter { + font-size: 14px; + font-weight: 600; + color: white; + } +} + +.bubble-content { + padding: 10px 14px; + font-size: 14px; + line-height: 1.6; + word-break: break-word; +} + +// 表情包消息 +.message-bubble.emoji { + .bubble-content { + background: transparent; + padding: 0; + } +} + +.emoji-image { + max-width: 120px; + max-height: 120px; + min-width: 48px; + min-height: 48px; + border-radius: 8px; + object-fit: contain; +} + +.emoji-loading { + width: 90px; + height: 90px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-tertiary); + border-radius: 8px; + font-size: 12px; + color: var(--text-tertiary); + + .spin { + animation: spin 1s linear infinite; + } +} + +.emoji-unavailable { + width: 100px; + height: 80px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + background: var(--bg-tertiary); + border-radius: 8px; + color: var(--text-quaternary); + + svg { + opacity: 0.5; + } + + span { + font-size: 10px; + opacity: 0.7; + } +} + +// 图片消息 +.message-bubble.image { + .bubble-content { + background: transparent; + padding: 0; + } +} + +.image-message { + max-width: 260px; + max-height: 220px; + border-radius: 10px; + object-fit: cover; + display: block; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08); + -webkit-app-region: no-drag; +} + +.image-message-wrapper { + position: relative; + display: inline-block; + -webkit-app-region: no-drag; +} + +.image-update-button { + position: absolute; + right: 8px; + top: 8px; + width: 26px; + height: 26px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.55); + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 0.15s ease, opacity 0.15s ease; + -webkit-app-region: no-drag; +} + +.image-update-button:hover { + transform: scale(1.05); + opacity: 0.9; +} + +.image-loading, +.image-unavailable { + width: 160px; + height: 120px; + border-radius: 10px; + background: var(--bg-tertiary); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + color: var(--text-tertiary); + font-size: 12px; + + .spin { + animation: spin 1s linear infinite; + } +} + +.image-unavailable { + border: none; + cursor: pointer; + background: var(--bg-tertiary); + text-align: center; + -webkit-app-region: no-drag; + transition: transform 0.15s ease, box-shadow 0.15s ease; + + svg { + opacity: 0.6; + } +} + +.image-unavailable.clicked { + transform: scale(0.98); + box-shadow: 0 0 0 2px var(--primary-light); +} + +.image-unavailable:disabled { + cursor: default; + opacity: 0.7; +} + +.image-action { + font-size: 11px; + color: var(--text-quaternary); +} + +.image-preview-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + + img { + max-width: 88vw; + max-height: 88vh; + border-radius: 12px; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35); + } +} + +.image-preview-close { + position: absolute; + top: 20px; + right: 20px; + width: 36px; + height: 36px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.6); + color: #fff; + cursor: pointer; +} + +// 语音消息 +.voice-message { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-radius: 16px; + background: rgba(0, 0, 0, 0.04); + min-width: 140px; + cursor: pointer; +} + +.message-bubble.sent .voice-message { + background: rgba(255, 255, 255, 0.18); +} + +.voice-play-btn { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background: var(--primary); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.voice-message.playing .voice-play-btn { + box-shadow: 0 0 0 6px rgba(0, 0, 0, 0.06); +} + +.voice-wave { + display: flex; + align-items: flex-end; + gap: 3px; + height: 18px; + + span { + width: 3px; + height: 8px; + border-radius: 2px; + background: var(--text-tertiary); + opacity: 0.6; + } +} + +.voice-message.playing .voice-wave span { + animation: voicePulse 0.9s ease-in-out infinite; +} + +.voice-message.playing .voice-wave span:nth-child(2) { + animation-delay: 0.1s; +} + +.voice-message.playing .voice-wave span:nth-child(3) { + animation-delay: 0.2s; +} + +.voice-message.playing .voice-wave span:nth-child(4) { + animation-delay: 0.3s; +} + +.voice-message.playing .voice-wave span:nth-child(5) { + animation-delay: 0.4s; +} + +.voice-hint { + font-size: 12px; + color: var(--text-quaternary); +} + +.voice-info { + display: flex; + align-items: baseline; + gap: 8px; + font-size: 13px; + color: var(--text-secondary); +} + +.voice-label { + font-weight: 600; + color: var(--text-primary); +} + +.voice-duration { + color: var(--text-tertiary); +} + +.voice-loading, +.voice-error { + font-size: 12px; + color: var(--text-tertiary); +} + +.voice-error { + color: #d9480f; +} + +@keyframes voicePulse { + 0% { + height: 6px; + opacity: 0.5; + } + 50% { + height: 16px; + opacity: 1; + } + 100% { + height: 6px; + opacity: 0.5; + } +} + +// 群聊发送者名称 +.sender-name { + font-size: 12px; + color: var(--text-tertiary); + margin-bottom: 4px; +} + +// 引用消息样式 +.quoted-message { + background: rgba(0, 0, 0, 0.04); + border-left: 2px solid var(--primary); + padding: 6px 10px; + margin-bottom: 8px; + border-radius: 4px; + font-size: 13px; + + .quoted-sender { + color: var(--primary); + font-weight: 500; + margin-right: 4px; + + &::after { + content: ':'; + } + } + + .quoted-text { + color: var(--text-secondary); + } +} + +// 自己发送的消息中的引用样式 +.message-bubble.sent .quoted-message { + background: rgba(255, 255, 255, 0.15); + border-left-color: rgba(255, 255, 255, 0.5); + + .quoted-sender { + color: rgba(255, 255, 255, 0.9); + } + + .quoted-text { + color: rgba(255, 255, 255, 0.8); + } +} + + + +// 气泡内容区域(包含名字和内容) +.bubble-body { + display: flex; + flex-direction: column; + max-width: 100%; + -webkit-app-region: no-drag; +} + +.bubble-content { + -webkit-app-region: no-drag; +} + +// 时间分隔 +.time-divider { + display: flex; + align-items: center; + justify-content: center; + padding: 8px 0; + width: 100%; + align-self: center; + + span { + font-size: 12px; + color: var(--text-tertiary); + } +} + +// 日期分隔 +.date-divider { + display: flex; + align-items: center; + justify-content: center; + padding: 8px 0; + width: 100%; + align-self: center; + + span { + font-size: 12px; + color: var(--text-tertiary); + background: rgba(0, 0, 0, 0.04); + padding: 4px 12px; + border-radius: 16px; + } +} + +// 空状态 +.empty-chat { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + gap: 12px; + + svg { + width: 64px; + height: 64px; + opacity: 0.5; + } + + p { + font-size: 14px; + margin: 0; + } +} + +.empty-sessions { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + text-align: center; + color: var(--text-tertiary); + + svg { + width: 48px; + height: 48px; + margin-bottom: 12px; + opacity: 0.5; + } + + p { + font-size: 13px; + margin: 0; + + &.hint { + font-size: 12px; + margin-top: 4px; + } + } +} + +// 加载状态 +.loading-sessions { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 16px; +} + +.skeleton-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 0; + + .skeleton-avatar { + width: 48px; + height: 48px; + border-radius: 8px; + background: var(--bg-tertiary); + animation: pulse 1.5s infinite; + } + + .skeleton-content { + flex: 1; + + .skeleton-line { + height: 14px; + background: var(--bg-tertiary); + border-radius: 4px; + animation: pulse 1.5s infinite; + + &:first-child { + width: 50%; + margin-bottom: 8px; + } + + &:last-child { + width: 80%; + height: 12px; + } + } + } +} + +@keyframes pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +// 连接错误 +.connection-error { + margin: 0 16px 12px; + padding: 10px 12px; + background: #fef0f0; + border-radius: 8px; + display: flex; + align-items: center; + gap: 8px; + + svg { + color: #f56c6c; + flex-shrink: 0; + } + + span { + flex: 1; + font-size: 13px; + color: #f56c6c; + } + + button { + padding: 4px 12px; + font-size: 12px; + background: #f56c6c; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + + &:hover { + background: #f78989; + } + } +} + +// 消息加载 +.loading-messages { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--text-tertiary); + + svg { + animation: spin 1s linear infinite; + } +} + +// 会话详情面板 +.detail-panel { + width: 280px; + min-width: 280px; + background: var(--card-bg); + border-left: 1px solid var(--border-color); + display: flex; + flex-direction: column; + overflow: hidden; + animation: slideInRight 0.2s ease; + + .detail-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid var(--border-color); + + h4 { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin: 0; + } + + .close-btn { + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: var(--text-secondary); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + } + + .detail-loading { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--text-secondary); + font-size: 13px; + + .spin { + animation: spin 1s linear infinite; + } + } + + .detail-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + font-size: 13px; + } + + .detail-content { + flex: 1; + overflow-y: auto; + padding: 16px; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + border-radius: 2px; + } + } + + .detail-section { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + + .section-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + + svg { + opacity: 0.7; + } + } + } + + .detail-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; + border-bottom: 1px solid var(--border-color); + font-size: 13px; + + &:last-child { + border-bottom: none; + } + + svg { + color: var(--text-tertiary); + flex-shrink: 0; + } + + .label { + color: var(--text-secondary); + flex-shrink: 0; + } + + .value { + flex: 1; + text-align: right; + color: var(--text-primary); + word-break: break-all; + + &.highlight { + color: var(--primary); + font-weight: 600; + } + } + } + + .table-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + .table-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: var(--bg-secondary); + border-radius: 8px; + font-size: 12px; + + .db-name { + color: var(--text-primary); + font-weight: 500; + } + + .table-count { + color: var(--primary); + font-weight: 500; + } + } +} + +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(20px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx new file mode 100644 index 0000000..0004cd5 --- /dev/null +++ b/src/pages/ChatPage.tsx @@ -0,0 +1,1465 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon } from 'lucide-react' +import { useChatStore } from '../stores/chatStore' +import type { ChatSession, Message } from '../types/models' +import { getEmojiPath } from 'wechat-emojis' +import './ChatPage.scss' + +interface ChatPageProps { + // 保留接口以备将来扩展 +} + + +interface SessionDetail { + wxid: string + displayName: string + remark?: string + nickName?: string + alias?: string + avatarUrl?: string + messageCount: number + firstMessageTime?: number + latestMessageTime?: number + messageTables: { dbName: string; tableName: string; count: number }[] +} + +// 头像组件 - 支持骨架屏加载 +function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: number }) { + const [imageLoaded, setImageLoaded] = useState(false) + const [imageError, setImageError] = useState(false) + const imgRef = useRef(null) + const isGroup = session.username.includes('@chatroom') + + const getAvatarLetter = (): string => { + const name = session.displayName || session.username + if (!name) return '?' + const chars = [...name] + return chars[0] || '?' + } + + // 当 avatarUrl 变化时重置状态 + useEffect(() => { + setImageLoaded(false) + setImageError(false) + }, [session.avatarUrl]) + + // 检查图片是否已经从缓存加载完成 + useEffect(() => { + if (imgRef.current?.complete && imgRef.current?.naturalWidth > 0) { + setImageLoaded(true) + } + }, [session.avatarUrl]) + + const hasValidUrl = session.avatarUrl && !imageError + + return ( +
+ {hasValidUrl ? ( + <> + {!imageLoaded &&
} + setImageLoaded(true)} + onError={() => setImageError(true)} + /> + + ) : ( + {getAvatarLetter()} + )} +
+ ) +} + +function ChatPage(_props: ChatPageProps) { + const { + isConnected, + isConnecting, + connectionError, + sessions, + filteredSessions, + currentSessionId, + isLoadingSessions, + messages, + isLoadingMessages, + isLoadingMore, + hasMoreMessages, + searchKeyword, + setConnected, + setConnecting, + setConnectionError, + setSessions, + setFilteredSessions, + setCurrentSession, + setLoadingSessions, + setMessages, + appendMessages, + setLoadingMessages, + setLoadingMore, + setHasMoreMessages, + setSearchKeyword + } = useChatStore() + + const messageListRef = useRef(null) + const searchInputRef = useRef(null) + const sidebarRef = useRef(null) + const [currentOffset, setCurrentOffset] = useState(0) + const [myAvatarUrl, setMyAvatarUrl] = useState(undefined) + const [showScrollToBottom, setShowScrollToBottom] = useState(false) + const [sidebarWidth, setSidebarWidth] = useState(260) + const [isResizing, setIsResizing] = useState(false) + const [showDetailPanel, setShowDetailPanel] = useState(false) + const [sessionDetail, setSessionDetail] = useState(null) + const [isLoadingDetail, setIsLoadingDetail] = useState(false) + const [highlightedMessageKeys, setHighlightedMessageKeys] = useState([]) + const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) + + + const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys]) + const messageKeySetRef = useRef>(new Set()) + const lastMessageTimeRef = useRef(0) + const sessionMapRef = useRef>(new Map()) + const sessionsRef = useRef([]) + const currentSessionRef = useRef(null) + const isLoadingMessagesRef = useRef(false) + const isLoadingMoreRef = useRef(false) + const isConnectedRef = useRef(false) + const searchKeywordRef = useRef('') + const preloadImageKeysRef = useRef>(new Set()) + const lastPreloadSessionRef = useRef(null) + + // 加载当前用户头像 + const loadMyAvatar = useCallback(async () => { + try { + const result = await window.electronAPI.chat.getMyAvatarUrl() + if (result.success && result.avatarUrl) { + setMyAvatarUrl(result.avatarUrl) + } + } catch (e) { + console.error('加载用户头像失败:', e) + } + }, []) + + // 加载会话详情 + const loadSessionDetail = useCallback(async (sessionId: string) => { + setIsLoadingDetail(true) + try { + const result = await window.electronAPI.chat.getSessionDetail(sessionId) + if (result.success && result.detail) { + setSessionDetail(result.detail) + } + } catch (e) { + console.error('加载会话详情失败:', e) + } finally { + setIsLoadingDetail(false) + } + }, []) + + // 切换详情面板 + const toggleDetailPanel = useCallback(() => { + if (!showDetailPanel && currentSessionId) { + loadSessionDetail(currentSessionId) + } + setShowDetailPanel(!showDetailPanel) + }, [showDetailPanel, currentSessionId, loadSessionDetail]) + + // 连接数据库 + const connect = useCallback(async () => { + setConnecting(true) + setConnectionError(null) + try { + const result = await window.electronAPI.chat.connect() + if (result.success) { + setConnected(true) + await loadSessions() + await loadMyAvatar() + } else { + setConnectionError(result.error || '连接失败') + } + } catch (e) { + setConnectionError(String(e)) + } finally { + setConnecting(false) + } + }, [loadMyAvatar]) + + // 加载会话列表 + const loadSessions = async (options?: { silent?: boolean }) => { + if (options?.silent) { + setIsRefreshingSessions(true) + } else { + setLoadingSessions(true) + } + try { + const result = await window.electronAPI.chat.getSessions() + if (result.success && result.sessions) { + const nextSessions = options?.silent ? mergeSessions(result.sessions) : result.sessions + setSessions(nextSessions) + } else if (!result.success) { + setConnectionError(result.error || '获取会话失败') + } + } catch (e) { + console.error('加载会话失败:', e) + setConnectionError('加载会话失败') + } finally { + if (options?.silent) { + setIsRefreshingSessions(false) + } else { + setLoadingSessions(false) + } + } + } + + // 刷新会话列表 + const handleRefresh = async () => { + await loadSessions({ silent: true }) + } + + // 刷新当前会话消息(增量更新新消息) + const [isRefreshingMessages, setIsRefreshingMessages] = useState(false) + const handleRefreshMessages = async () => { + if (!currentSessionId || isRefreshingMessages) return + setIsRefreshingMessages(true) + try { + // 获取最新消息并增量添加 + const result = await window.electronAPI.chat.getLatestMessages(currentSessionId, 50) + if (!result.success || !result.messages) { + return + } + const existing = new Set(messages.map(getMessageKey)) + const lastMsg = messages[messages.length - 1] + const lastTime = lastMsg?.createTime ?? 0 + const newMessages = result.messages.filter((msg) => { + const key = getMessageKey(msg) + if (existing.has(key)) return false + if (lastTime > 0 && msg.createTime < lastTime) return false + return true + }) + if (newMessages.length > 0) { + appendMessages(newMessages, false) + flashNewMessages(newMessages.map(getMessageKey)) + // 滚动到底部 + requestAnimationFrame(() => { + if (messageListRef.current) { + messageListRef.current.scrollTop = messageListRef.current.scrollHeight + } + }) + } + } catch (e) { + console.error('刷新消息失败:', e) + } finally { + setIsRefreshingMessages(false) + } + } + + // 加载消息 + const loadMessages = async (sessionId: string, offset = 0) => { + const listEl = messageListRef.current + const session = sessionMapRef.current.get(sessionId) + const unreadCount = session?.unreadCount ?? 0 + const messageLimit = offset === 0 && unreadCount > 99 ? 30 : 50 + + if (offset === 0) { + setLoadingMessages(true) + setMessages([]) + } else { + setLoadingMore(true) + } + + // 记录加载前的第一条消息元素 + const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null + + try { + const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit) + if (result.success && result.messages) { + if (offset === 0) { + setMessages(result.messages) + // 首次加载滚动到底部 + requestAnimationFrame(() => { + if (messageListRef.current) { + messageListRef.current.scrollTop = messageListRef.current.scrollHeight + } + }) + } else { + appendMessages(result.messages, true) + // 加载更多后保持位置:让之前的第一条消息保持在原来的视觉位置 + if (firstMsgEl && listEl) { + requestAnimationFrame(() => { + listEl.scrollTop = firstMsgEl.offsetTop - 80 + }) + } + } + setHasMoreMessages(result.hasMore ?? false) + setCurrentOffset(offset + result.messages.length) + } else if (!result.success) { + setConnectionError(result.error || '加载消息失败') + setHasMoreMessages(false) + } + } catch (e) { + console.error('加载消息失败:', e) + setConnectionError('加载消息失败') + setHasMoreMessages(false) + } finally { + setLoadingMessages(false) + setLoadingMore(false) + } + } + + // 选择会话 + const handleSelectSession = (session: ChatSession) => { + if (session.username === currentSessionId) return + setCurrentSession(session.username) + setCurrentOffset(0) + loadMessages(session.username, 0) + // 重置详情面板 + setSessionDetail(null) + if (showDetailPanel) { + loadSessionDetail(session.username) + } + } + + // 搜索过滤 + const handleSearch = (keyword: string) => { + setSearchKeyword(keyword) + if (!keyword.trim()) { + setFilteredSessions(sessions) + return + } + const lower = keyword.toLowerCase() + const filtered = sessions.filter(s => + s.displayName?.toLowerCase().includes(lower) || + s.username.toLowerCase().includes(lower) || + s.summary.toLowerCase().includes(lower) + ) + setFilteredSessions(filtered) + } + + // 关闭搜索框 + const handleCloseSearch = () => { + setSearchKeyword('') + setFilteredSessions(sessions) + } + + // 滚动加载更多 + 显示/隐藏回到底部按钮 + const handleScroll = useCallback(() => { + if (!messageListRef.current) return + + const { scrollTop, clientHeight, scrollHeight } = messageListRef.current + + // 显示回到底部按钮:距离底部超过 300px + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + setShowScrollToBottom(distanceFromBottom > 300) + + // 预加载:当滚动到顶部 30% 区域时开始加载 + if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) { + const threshold = clientHeight * 0.3 + if (scrollTop < threshold) { + loadMessages(currentSessionId, currentOffset) + } + } + }, [isLoadingMore, isLoadingMessages, hasMoreMessages, currentSessionId, currentOffset]) + + const getMessageKey = useCallback((msg: Message): string => { + if (msg.localId && msg.localId > 0) return `l:${msg.localId}` + return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}` + }, []) + + const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => { + return ( + prev.username === next.username && + prev.type === next.type && + prev.unreadCount === next.unreadCount && + prev.summary === next.summary && + prev.sortTimestamp === next.sortTimestamp && + prev.lastTimestamp === next.lastTimestamp && + prev.lastMsgType === next.lastMsgType && + prev.displayName === next.displayName && + prev.avatarUrl === next.avatarUrl + ) + }, []) + + const mergeSessions = useCallback((nextSessions: ChatSession[]) => { + if (sessionsRef.current.length === 0) return nextSessions + const prevMap = new Map(sessionsRef.current.map((s) => [s.username, s])) + return nextSessions.map((next) => { + const prev = prevMap.get(next.username) + if (!prev) return next + return isSameSession(prev, next) ? prev : next + }) + }, [isSameSession]) + + const flashNewMessages = useCallback((keys: string[]) => { + if (keys.length === 0) return + setHighlightedMessageKeys((prev) => [...prev, ...keys]) + window.setTimeout(() => { + setHighlightedMessageKeys((prev) => prev.filter((k) => !keys.includes(k))) + }, 2500) + }, []) + + // 滚动到底部 + const scrollToBottom = useCallback(() => { + if (messageListRef.current) { + messageListRef.current.scrollTo({ + top: messageListRef.current.scrollHeight, + behavior: 'smooth' + }) + } + }, []) + + // 拖动调节侧边栏宽度 + const handleResizeStart = useCallback((e: React.MouseEvent) => { + e.preventDefault() + setIsResizing(true) + + const startX = e.clientX + const startWidth = sidebarWidth + + const handleMouseMove = (e: MouseEvent) => { + const delta = e.clientX - startX + const newWidth = Math.min(Math.max(startWidth + delta, 200), 400) + setSidebarWidth(newWidth) + } + + const handleMouseUp = () => { + setIsResizing(false) + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + }, [sidebarWidth]) + + // 初始化连接 + useEffect(() => { + if (!isConnected && !isConnecting) { + connect() + } + }, []) + + useEffect(() => { + const nextSet = new Set() + for (const msg of messages) { + nextSet.add(getMessageKey(msg)) + } + messageKeySetRef.current = nextSet + const lastMsg = messages[messages.length - 1] + lastMessageTimeRef.current = lastMsg?.createTime ?? 0 + }, [messages, getMessageKey]) + + useEffect(() => { + currentSessionRef.current = currentSessionId + }, [currentSessionId]) + + useEffect(() => { + if (currentSessionId !== lastPreloadSessionRef.current) { + preloadImageKeysRef.current.clear() + lastPreloadSessionRef.current = currentSessionId + } + }, [currentSessionId]) + + useEffect(() => { + if (!currentSessionId || messages.length === 0) return + const preloadEdgeCount = 40 + const maxPreload = 30 + const head = messages.slice(0, preloadEdgeCount) + const tail = messages.slice(-preloadEdgeCount) + const candidates = [...head, ...tail] + const queued = preloadImageKeysRef.current + const seen = new Set() + const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = [] + for (const msg of candidates) { + if (payloads.length >= maxPreload) break + if (msg.localType !== 3) continue + const cacheKey = msg.imageMd5 || msg.imageDatName || `local:${msg.localId}` + if (!msg.imageMd5 && !msg.imageDatName) continue + if (imageDataUrlCache.has(cacheKey)) continue + const taskKey = `${currentSessionId}|${cacheKey}` + if (queued.has(taskKey) || seen.has(taskKey)) continue + queued.add(taskKey) + seen.add(taskKey) + payloads.push({ + sessionId: currentSessionId, + imageMd5: msg.imageMd5 || undefined, + imageDatName: msg.imageDatName + }) + } + if (payloads.length > 0) { + window.electronAPI.image.preload(payloads).catch(() => {}) + } + }, [currentSessionId, messages]) + + useEffect(() => { + const nextMap = new Map() + for (const session of sessions) { + nextMap.set(session.username, session) + } + sessionMapRef.current = nextMap + }, [sessions]) + + useEffect(() => { + sessionsRef.current = sessions + }, [sessions]) + + useEffect(() => { + isLoadingMessagesRef.current = isLoadingMessages + isLoadingMoreRef.current = isLoadingMore + }, [isLoadingMessages, isLoadingMore]) + + useEffect(() => { + isConnectedRef.current = isConnected + }, [isConnected]) + + useEffect(() => { + searchKeywordRef.current = searchKeyword + }, [searchKeyword]) + + useEffect(() => { + if (!searchKeyword.trim()) return + const lower = searchKeyword.toLowerCase() + const filtered = sessions.filter(s => + s.displayName?.toLowerCase().includes(lower) || + s.username.toLowerCase().includes(lower) || + s.summary.toLowerCase().includes(lower) + ) + setFilteredSessions(filtered) + }, [sessions, searchKeyword, setFilteredSessions]) + + + // 格式化会话时间(相对时间)- 与原项目一致 + const formatSessionTime = (timestamp: number): string => { + if (!Number.isFinite(timestamp) || timestamp <= 0) return '' + + const now = Date.now() + const msgTime = timestamp * 1000 + const diff = now - msgTime + + const minutes = Math.floor(diff / 60000) + const hours = Math.floor(diff / 3600000) + + if (minutes < 1) return '刚刚' + if (minutes < 60) return `${minutes}分钟前` + if (hours < 24) return `${hours}小时前` + + // 超过24小时显示日期 + const date = new Date(msgTime) + const nowDate = new Date() + + if (date.getFullYear() === nowDate.getFullYear()) { + return `${date.getMonth() + 1}/${date.getDate()}` + } + + return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` + } + + // 获取当前会话信息 + const currentSession = sessions.find(s => s.username === currentSessionId) + + // 判断是否为群聊 + const isGroupChat = (username: string) => username.includes('@chatroom') + + // 渲染日期分隔 + const shouldShowDateDivider = (msg: Message, prevMsg?: Message): boolean => { + if (!prevMsg) return true + const date = new Date(msg.createTime * 1000).toDateString() + const prevDate = new Date(prevMsg.createTime * 1000).toDateString() + return date !== prevDate + } + + const formatDateDivider = (timestamp: number): string => { + if (!Number.isFinite(timestamp) || timestamp <= 0) return '未知时间' + const date = new Date(timestamp * 1000) + const now = new Date() + const isToday = date.toDateString() === now.toDateString() + + if (isToday) return '今天' + + const yesterday = new Date(now) + yesterday.setDate(yesterday.getDate() - 1) + if (date.toDateString() === yesterday.toDateString()) return '昨天' + + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) + } + + return ( +
+ {/* 左侧会话列表 */} +
+
+
+
+ + handleSearch(e.target.value)} + /> + {searchKeyword && ( + + )} +
+ +
+
+ + {connectionError && ( +
+ + {connectionError} + +
+ )} + + {isLoadingSessions ? ( +
+ {[1, 2, 3, 4, 5].map(i => ( +
+
+
+
+
+
+
+ ))} +
+ ) : filteredSessions.length > 0 ? ( +
+ {filteredSessions.map(session => ( +
handleSelectSession(session)} + > + +
+
+ {session.displayName || session.username} + {formatSessionTime(session.lastTimestamp || session.sortTimestamp)} +
+
+ {session.summary || '暂无消息'} + {session.unreadCount > 0 && ( + + {session.unreadCount > 99 ? '99+' : session.unreadCount} + + )} +
+
+
+ ))} +
+ ) : ( +
+ +

暂无会话

+

请先在数据管理页面解密数据库

+
+ )} +
+ + {/* 拖动调节条 */} +
+ + {/* 右侧消息区域 */} +
+ {currentSession ? ( + <> +
+ +
+

{currentSession.displayName || currentSession.username}

+ {isGroupChat(currentSession.username) && ( +
群聊
+ )} +
+
+ + +
+
+ +
+ {isLoadingMessages ? ( +
+ + 加载消息中... +
+ ) : ( +
+ {hasMoreMessages && ( +
+ {isLoadingMore ? ( + <> + + 加载更多... + + ) : ( + 向上滚动加载更多 + )} +
+ )} + + {messages.map((msg, index) => { + const prevMsg = index > 0 ? messages[index - 1] : undefined + const showDateDivider = shouldShowDateDivider(msg, prevMsg) + + // 显示时间:第一条消息,或者与上一条消息间隔超过5分钟 + const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300) + const isSent = msg.isSend === 1 + const isSystem = msg.localType === 10000 + + // 系统消息居中显示 + const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') + + const messageKey = getMessageKey(msg) + return ( +
+ {showDateDivider && ( +
+ {formatDateDivider(msg.createTime)} +
+ )} + +
+ ) + })} + + {/* 回到底部按钮 */} +
+ + 回到底部 +
+
+ )} + + {/* 会话详情面板 */} + {showDetailPanel && ( +
+
+

会话详情

+ +
+ {isLoadingDetail ? ( +
+ + 加载中... +
+ ) : sessionDetail ? ( +
+
+
+ + 微信ID + {sessionDetail.wxid} +
+ {sessionDetail.remark && ( +
+ 备注 + {sessionDetail.remark} +
+ )} + {sessionDetail.nickName && ( +
+ 昵称 + {sessionDetail.nickName} +
+ )} + {sessionDetail.alias && ( +
+ 微信号 + {sessionDetail.alias} +
+ )} +
+ +
+
+ + 消息统计 +
+
+ 消息总数 + + {Number.isFinite(sessionDetail.messageCount) + ? sessionDetail.messageCount.toLocaleString() + : '—'} + +
+ {sessionDetail.firstMessageTime && ( +
+ + 首条消息 + + {Number.isFinite(sessionDetail.firstMessageTime) + ? new Date(sessionDetail.firstMessageTime * 1000).toLocaleDateString('zh-CN') + : '—'} + +
+ )} + {sessionDetail.latestMessageTime && ( +
+ + 最新消息 + + {Number.isFinite(sessionDetail.latestMessageTime) + ? new Date(sessionDetail.latestMessageTime * 1000).toLocaleDateString('zh-CN') + : '—'} + +
+ )} +
+ + {Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 && ( +
+
+ + 数据库分布 +
+
+ {sessionDetail.messageTables.map((t, i) => ( +
+ {t.dbName} + {t.count.toLocaleString()} 条 +
+ ))} +
+
+ )} +
+ ) : ( +
暂无详情
+ )} +
+ )} +
+ + ) : ( +
+ +

选择一个会话开始查看聊天记录

+
+ )} +
+
+ ) +} + +// 前端表情包缓存 +const emojiDataUrlCache = new Map() +const imageDataUrlCache = new Map() +const voiceDataUrlCache = new Map() +const senderAvatarCache = new Map() +const senderAvatarLoading = new Map>() + +// 消息气泡组件 +function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: { + message: Message; + session: ChatSession; + showTime?: boolean; + myAvatarUrl?: string; + isGroupChat?: boolean; +}) { + const isSystem = message.localType === 10000 + const isEmoji = message.localType === 47 + const isImage = message.localType === 3 + const isVoice = message.localType === 34 + const isSent = message.isSend === 1 + const [senderAvatarUrl, setSenderAvatarUrl] = useState(undefined) + const [senderName, setSenderName] = useState(undefined) + const [emojiError, setEmojiError] = useState(false) + const [emojiLoading, setEmojiLoading] = useState(false) + const [imageError, setImageError] = useState(false) + const [imageLoading, setImageLoading] = useState(false) + const [imageHasUpdate, setImageHasUpdate] = useState(false) + const [imageClicked, setImageClicked] = useState(false) + const imageUpdateCheckedRef = useRef(null) + const imageClickTimerRef = useRef(null) + const [voiceError, setVoiceError] = useState(false) + const [voiceLoading, setVoiceLoading] = useState(false) + const [isVoicePlaying, setIsVoicePlaying] = useState(false) + const voiceAudioRef = useRef(null) + const [showImagePreview, setShowImagePreview] = useState(false) + + // 从缓存获取表情包 data URL + const cacheKey = message.emojiMd5 || message.emojiCdnUrl || '' + const [emojiLocalPath, setEmojiLocalPath] = useState( + () => emojiDataUrlCache.get(cacheKey) + ) + const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}` + const [imageLocalPath, setImageLocalPath] = useState( + () => imageDataUrlCache.get(imageCacheKey) + ) + const voiceCacheKey = `voice:${message.localId}` + const [voiceDataUrl, setVoiceDataUrl] = useState( + () => voiceDataUrlCache.get(voiceCacheKey) + ) + + const formatTime = (timestamp: number): string => { + if (!Number.isFinite(timestamp) || timestamp <= 0) return '未知时间' + const date = new Date(timestamp * 1000) + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }) + ' ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + } + + const detectImageMimeFromBase64 = useCallback((base64: string): string => { + try { + const head = window.atob(base64.slice(0, 48)) + const bytes = new Uint8Array(head.length) + for (let i = 0; i < head.length; i++) { + bytes[i] = head.charCodeAt(i) + } + if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image/gif' + if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) return 'image/png' + if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return 'image/jpeg' + if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && + bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) { + return 'image/webp' + } + } catch {} + return 'image/jpeg' + }, []) + + // 获取头像首字母 + const getAvatarLetter = (name: string): string => { + if (!name) return '?' + const chars = [...name] + return chars[0] || '?' + } + + // 下载表情包 + const downloadEmoji = () => { + if (!message.emojiCdnUrl || emojiLoading) return + + // 先检查缓存 + const cached = emojiDataUrlCache.get(cacheKey) + if (cached) { + setEmojiLocalPath(cached) + setEmojiError(false) + return + } + + setEmojiLoading(true) + setEmojiError(false) + window.electronAPI.chat.downloadEmoji(message.emojiCdnUrl, message.emojiMd5).then((result: { success: boolean; localPath?: string; error?: string }) => { + if (result.success && result.localPath) { + emojiDataUrlCache.set(cacheKey, result.localPath) + setEmojiLocalPath(result.localPath) + } else { + setEmojiError(true) + } + }).catch(() => { + setEmojiError(true) + }).finally(() => { + setEmojiLoading(false) + }) + } + + // 群聊中获取发送者信息 + useEffect(() => { + if (isGroupChat && !isSent && message.senderUsername) { + const sender = message.senderUsername + const cached = senderAvatarCache.get(sender) + if (cached) { + setSenderAvatarUrl(cached.avatarUrl) + setSenderName(cached.displayName) + return + } + const pending = senderAvatarLoading.get(sender) + if (pending) { + pending.then((result) => { + if (result) { + setSenderAvatarUrl(result.avatarUrl) + setSenderName(result.displayName) + } + }) + return + } + const request = window.electronAPI.chat.getContactAvatar(sender) + senderAvatarLoading.set(sender, request) + request.then((result: { avatarUrl?: string; displayName?: string } | null) => { + if (result) { + senderAvatarCache.set(sender, result) + setSenderAvatarUrl(result.avatarUrl) + setSenderName(result.displayName) + } + }).catch(() => {}).finally(() => { + senderAvatarLoading.delete(sender) + }) + } + }, [isGroupChat, isSent, message.senderUsername]) + + // 自动下载表情包 + useEffect(() => { + if (emojiLocalPath) return + if (isEmoji && message.emojiCdnUrl && !emojiLoading && !emojiError) { + downloadEmoji() + } + }, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError]) + + const requestImageDecrypt = useCallback(async (forceUpdate = false) => { + if (!isImage || imageLoading) return + setImageLoading(true) + setImageError(false) + try { + if (message.imageMd5 || message.imageDatName) { + const result = await window.electronAPI.image.decrypt({ + sessionId: session.username, + imageMd5: message.imageMd5 || undefined, + imageDatName: message.imageDatName, + force: forceUpdate + }) + if (result.success && result.localPath) { + imageDataUrlCache.set(imageCacheKey, result.localPath) + setImageLocalPath(result.localPath) + setImageHasUpdate(false) + return + } + } + + const fallback = await window.electronAPI.chat.getImageData(session.username, String(message.localId)) + if (fallback.success && fallback.data) { + const mime = detectImageMimeFromBase64(fallback.data) + const dataUrl = `data:${mime};base64,${fallback.data}` + imageDataUrlCache.set(imageCacheKey, dataUrl) + setImageLocalPath(dataUrl) + setImageHasUpdate(false) + return + } + setImageError(true) + } catch { + setImageError(true) + } finally { + setImageLoading(false) + } + }, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64]) + + const handleImageClick = useCallback(() => { + if (imageClickTimerRef.current) { + window.clearTimeout(imageClickTimerRef.current) + } + setImageClicked(true) + imageClickTimerRef.current = window.setTimeout(() => { + setImageClicked(false) + }, 800) + console.info('[UI] image decrypt click', { + sessionId: session.username, + imageMd5: message.imageMd5, + imageDatName: message.imageDatName, + localId: message.localId + }) + void requestImageDecrypt() + }, [message.imageDatName, message.imageMd5, message.localId, requestImageDecrypt, session.username]) + + useEffect(() => { + return () => { + if (imageClickTimerRef.current) { + window.clearTimeout(imageClickTimerRef.current) + } + } + }, []) + + useEffect(() => { + if (!isImage || imageLoading) return + if (!message.imageMd5 && !message.imageDatName) return + if (imageUpdateCheckedRef.current === imageCacheKey) return + imageUpdateCheckedRef.current = imageCacheKey + let cancelled = false + window.electronAPI.image.resolveCache({ + sessionId: session.username, + imageMd5: message.imageMd5 || undefined, + imageDatName: message.imageDatName + }).then((result) => { + if (cancelled) return + if (result.success && result.localPath) { + imageDataUrlCache.set(imageCacheKey, result.localPath) + if (!imageLocalPath || imageLocalPath !== result.localPath) { + setImageLocalPath(result.localPath) + setImageError(false) + } + setImageHasUpdate(Boolean(result.hasUpdate)) + } + }).catch(() => {}) + return () => { + cancelled = true + } + }, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username]) + + useEffect(() => { + if (!isImage) return + const unsubscribe = window.electronAPI.image.onUpdateAvailable((payload) => { + const matchesCacheKey = + payload.cacheKey === message.imageMd5 || + payload.cacheKey === message.imageDatName || + (payload.imageMd5 && payload.imageMd5 === message.imageMd5) || + (payload.imageDatName && payload.imageDatName === message.imageDatName) + if (matchesCacheKey) { + setImageHasUpdate(true) + } + }) + return () => { + unsubscribe?.() + } + }, [isImage, message.imageDatName, message.imageMd5]) + + useEffect(() => { + if (!isImage) return + const unsubscribe = window.electronAPI.image.onCacheResolved((payload) => { + const matchesCacheKey = + payload.cacheKey === message.imageMd5 || + payload.cacheKey === message.imageDatName || + (payload.imageMd5 && payload.imageMd5 === message.imageMd5) || + (payload.imageDatName && payload.imageDatName === message.imageDatName) + if (matchesCacheKey) { + imageDataUrlCache.set(imageCacheKey, payload.localPath) + setImageLocalPath(payload.localPath) + setImageError(false) + } + }) + return () => { + unsubscribe?.() + } + }, [isImage, imageCacheKey, message.imageDatName, message.imageMd5]) + + + useEffect(() => { + if (!isVoice) return + if (!voiceAudioRef.current) { + voiceAudioRef.current = new Audio() + } + const audio = voiceAudioRef.current + if (!audio) return + const handlePlay = () => setIsVoicePlaying(true) + const handlePause = () => setIsVoicePlaying(false) + const handleEnded = () => setIsVoicePlaying(false) + audio.addEventListener('play', handlePlay) + audio.addEventListener('pause', handlePause) + audio.addEventListener('ended', handleEnded) + return () => { + audio.pause() + audio.removeEventListener('play', handlePlay) + audio.removeEventListener('pause', handlePause) + audio.removeEventListener('ended', handleEnded) + } + }, [isVoice]) + + if (isSystem) { + return ( +
+
{message.parsedContent}
+
+ ) + } + + const bubbleClass = isSent ? 'sent' : 'received' + + // 头像逻辑: + // - 自己发的:使用 myAvatarUrl + // - 群聊中对方发的:使用发送者头像 + // - 私聊中对方发的:使用会话头像 + const avatarUrl = isSent + ? myAvatarUrl + : (isGroupChat ? senderAvatarUrl : session.avatarUrl) + const avatarLetter = isSent + ? '我' + : getAvatarLetter(isGroupChat ? (senderName || message.senderUsername || '?') : (session.displayName || session.username)) + + // 是否有引用消息 + const hasQuote = message.quotedContent && message.quotedContent.length > 0 + + // 解析混合文本和表情 + const renderTextWithEmoji = (text: string) => { + if (!text) return text + const parts = text.split(/\[(.*?)\]/g) + return parts.map((part, index) => { + // 奇数索引是捕获组的内容(即括号内的文字) + if (index % 2 === 1) { + // @ts-ignore + const path = getEmojiPath(part as any) + if (path) { + // path 例如 'assets/face/微笑.png',需要添加 base 前缀 + return ( + {`[${part}]`} + ) + } + return `[${part}]` + } + return part + }) + } + + // 渲染消息内容 + const renderContent = () => { + if (isImage) { + if (imageLoading) { + return ( +
+ +
+ ) + } + if (imageError || !imageLocalPath) { + return ( + + ) + } + return ( + <> +
+ 图片 setShowImagePreview(true)} + onLoad={() => setImageError(false)} + onError={() => setImageError(true)} + /> + {imageHasUpdate && ( + + )} +
+ {showImagePreview && ( +
setShowImagePreview(false)}> + 图片预览 e.stopPropagation()} /> + +
+ )} + + ) + } + + if (isVoice) { + const durationText = message.voiceDurationSeconds ? `${message.voiceDurationSeconds}"` : '' + const handleToggle = async () => { + if (voiceLoading) return + const audio = voiceAudioRef.current || new Audio() + if (!voiceAudioRef.current) { + voiceAudioRef.current = audio + } + if (isVoicePlaying) { + audio.pause() + audio.currentTime = 0 + return + } + if (!voiceDataUrl) { + setVoiceLoading(true) + setVoiceError(false) + try { + const result = await window.electronAPI.chat.getVoiceData(session.username, String(message.localId)) + if (result.success && result.data) { + const url = `data:audio/wav;base64,${result.data}` + voiceDataUrlCache.set(voiceCacheKey, url) + setVoiceDataUrl(url) + } else { + setVoiceError(true) + return + } + } catch { + setVoiceError(true) + return + } finally { + setVoiceLoading(false) + } + } + const source = voiceDataUrlCache.get(voiceCacheKey) || voiceDataUrl + if (!source) { + setVoiceError(true) + return + } + audio.src = source + try { + await audio.play() + } catch { + setVoiceError(true) + } + } + + const showDecryptHint = !voiceDataUrl && !voiceLoading && !isVoicePlaying + + return ( +
+ +
+ + + + + +
+
+ 语音 + {durationText && {durationText}} + {voiceLoading && 解码中...} + {showDecryptHint && 点击解密} + {voiceError && 播放失败} +
+
+ ) + } + + // 表情包消息 + if (isEmoji) { + // ... (keep existing emoji logic) + // 没有 cdnUrl 或加载失败,显示占位符 + if (!message.emojiCdnUrl || emojiError) { + return ( +
+ + + + + + + 表情包未缓存 +
+ ) + } + + // 显示加载中 + if (emojiLoading || !emojiLocalPath) { + return ( +
+ +
+ ) + } + + // 显示表情图片 + return ( + 表情 setEmojiError(true)} + /> + ) + } + // 带引用的消息 + if (hasQuote) { + return ( +
+
+ {message.quotedSender && {message.quotedSender}} + {renderTextWithEmoji(message.quotedContent || '')} +
+
{renderTextWithEmoji(message.parsedContent)}
+
+ ) + } + // 普通消息 + return
{renderTextWithEmoji(message.parsedContent)}
+ } + + return ( + <> + {showTime && ( +
+ {formatTime(message.createTime)} +
+ )} +
+
+ {avatarUrl ? ( + + ) : ( + {avatarLetter} + )} +
+
+ {/* 群聊中显示发送者名称 */} + {isGroupChat && !isSent && ( +
+ {senderName || message.senderUsername || '群成员'} +
+ )} + {renderContent()} +
+
+ + ) +} + +export default ChatPage diff --git a/src/pages/DataManagementPage.scss b/src/pages/DataManagementPage.scss new file mode 100644 index 0000000..2f26133 --- /dev/null +++ b/src/pages/DataManagementPage.scss @@ -0,0 +1,569 @@ +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + + h1 { + font-size: 24px; + font-weight: 600; + color: var(--text-primary); + margin: 0; + } + + .header-tabs { + display: flex; + gap: 8px; + + .tab-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + background: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 14px; + cursor: pointer; + border-radius: 9999px; + transition: all 0.2s; + + &:hover { + background: var(--border-color); + color: var(--text-primary); + } + + &.active { + background: var(--primary); + color: white; + } + } + } +} + +.page-scroll { + display: flex; + flex-direction: column; + gap: 24px; +} + +.page-section { + background: var(--bg-secondary); + border-radius: 16px; + padding: 20px 24px; + + h2 { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 4px; + } + + .section-desc { + font-size: 13px; + color: var(--text-tertiary); + margin: 0; + } + + .section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 20px; + + .section-actions { + display: flex; + gap: 10px; + } + } +} + +.btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + border-radius: 9999px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .spin { + animation: spin 1s linear infinite; + } +} + +.btn-primary { + background: var(--primary); + color: white; + + &:hover:not(:disabled) { + background: var(--primary-hover); + } +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + + &:hover:not(:disabled) { + background: var(--border-color); + } +} + +.btn-warning { + background: #f59e0b; + color: white; + + &:hover:not(:disabled) { + background: #d97706; + } +} + +.database-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.database-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--bg-primary); + border-radius: 12px; + transition: all 0.2s; + + &:hover { + background: var(--bg-tertiary); + } + + .status-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + + &.decrypted { + background: var(--primary); + color: white; + } + + &.needs-update { + background: #f59e0b; + color: white; + } + + &.pending { + background: var(--bg-tertiary); + color: var(--text-tertiary); + } + } + + .db-info { + flex: 1; + min-width: 0; + + .db-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .db-meta { + display: flex; + gap: 6px; + font-size: 12px; + color: var(--text-tertiary); + } + } + + .db-status { + padding: 4px 10px; + border-radius: 9999px; + font-size: 12px; + font-weight: 500; + flex-shrink: 0; + + &.decrypted { + background: rgba(34, 197, 94, 0.15); + color: #16a34a; + } + + &.needs-update { + background: rgba(245, 158, 11, 0.15); + color: #b45309; + } + + &.pending { + background: rgba(234, 179, 8, 0.15); + color: #b45309; + } + } +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 20px; + color: var(--text-tertiary); + + svg { + margin-bottom: 16px; + opacity: 0.5; + } + + p { + margin: 0; + font-size: 14px; + + &.hint { + margin-top: 6px; + font-size: 13px; + opacity: 0.7; + } + } +} + +.unavailable-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 20px; + color: var(--text-tertiary); + + svg { + margin-bottom: 20px; + opacity: 0.4; + } + + p { + margin: 0; + font-size: 15px; + color: var(--text-secondary); + + &.hint { + margin-top: 8px; + font-size: 13px; + color: var(--text-tertiary); + } + } +} + +.message-toast { + position: fixed; + top: 60px; + left: 50%; + transform: translateX(-50%); + padding: 10px 24px; + border-radius: 9999px; + font-size: 14px; + z-index: 100; + animation: slideDown 0.3s ease; + + &.success { + background: var(--primary); + color: white; + } + + &.error { + background: var(--danger); + color: white; + } +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateX(-50%) translateY(-10px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + + +.decrypt-progress-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + + .progress-card { + background: var(--bg-primary); + border-radius: 16px; + padding: 32px 40px; + min-width: 400px; + text-align: center; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + + h3 { + margin: 0 0 8px; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + } + + .progress-file { + margin: 0 0 20px; + font-size: 14px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .progress-bar { + height: 8px; + background: var(--bg-tertiary); + border-radius: 9999px; + overflow: hidden; + margin-bottom: 12px; + + .progress-fill { + height: 100%; + background: var(--primary); + border-radius: 9999px; + transition: width 0.2s ease; + } + } + + .progress-text { + margin: 0; + font-size: 13px; + color: var(--text-tertiary); + } + } +} + + +// 图片列表样式 +.current-dir { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: var(--bg-tertiary); + border-radius: 8px; + margin-bottom: 16px; + font-size: 13px; + + .dir-label { + color: var(--text-tertiary); + flex-shrink: 0; + } + + .dir-path { + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.image-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 8px; + max-height: 500px; + overflow-y: auto; + padding-right: 4px; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: var(--bg-tertiary); + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; + + &:hover { + background: var(--text-tertiary); + } + } +} + +.image-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: var(--bg-primary); + border-radius: 10px; + transition: all 0.2s; + + &:hover { + background: var(--bg-tertiary); + } + + &.clickable { + cursor: pointer; + + &:hover { + background: var(--bg-tertiary); + + .decrypt-hint { + opacity: 1; + } + } + } + + .status-icon { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + flex-shrink: 0; + + &.decrypted { + background: var(--primary); + color: white; + } + + &.pending { + background: var(--bg-tertiary); + color: var(--text-tertiary); + } + + .spin { + animation: spin 1s linear infinite; + } + } + + .img-info { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + + .img-name { + font-size: 13px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .img-meta { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + } + + .version-tag { + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + font-weight: 500; + + &.v3 { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; + } + + &.v4 { + background: rgba(168, 85, 247, 0.15); + color: #a855f7; + } + } + + .img-size { + font-size: 12px; + color: var(--text-tertiary); + flex-shrink: 0; + } + } + + .decrypt-hint { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + color: var(--text-tertiary); + opacity: 0; + transition: opacity 0.2s; + } +} + +.more-hint { + grid-column: 1 / -1; + text-align: center; + padding: 16px; + font-size: 13px; + color: var(--text-tertiary); +} + + +// 账号选择器 +.account-selector { + display: flex; + gap: 8px; + margin-bottom: 12px; + flex-wrap: wrap; + + .account-btn { + padding: 6px 14px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-secondary); + font-size: 13px; + border-radius: 9999px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--primary); + color: var(--primary); + } + + &.active { + background: var(--primary); + border-color: var(--primary); + color: white; + } + } +} diff --git a/src/pages/DataManagementPage.tsx b/src/pages/DataManagementPage.tsx new file mode 100644 index 0000000..86357bb --- /dev/null +++ b/src/pages/DataManagementPage.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react' +import * as configService from '../services/config' +import './DataManagementPage.scss' + +function DataManagementPage() { + const [dbPath, setDbPath] = useState(null) + const [wxid, setWxid] = useState(null) + + useEffect(() => { + const loadConfig = async () => { + const [path, id] = await Promise.all([ + configService.getDbPath(), + configService.getMyWxid() + ]) + setDbPath(path) + setWxid(id) + } + loadConfig() + }, []) + + return ( + <> +
+

数据管理

+
+ +
+
+
+
+

WCDB 直连模式

+

+ 当前版本通过 WCDB DLL 直接读取加密数据库,不再需要解密流程。 +

+
+
+ +
+
+
+
+ 数据库目录 +
+
{dbPath || '未配置'}
+
+
+
+
+
+ 微信ID +
+
{wxid || '未配置'}
+
+
+
+
+
+ + ) +} + +export default DataManagementPage diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss new file mode 100644 index 0000000..588535f --- /dev/null +++ b/src/pages/ExportPage.scss @@ -0,0 +1,657 @@ +.export-page { + display: flex; + height: calc(100% + 48px); + margin: -24px; + background: var(--bg-primary); + overflow: hidden; + + // 左侧会话选择面板 + .session-panel { + width: 380px; + min-width: 380px; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border-color); + background: var(--card-bg); + } + + .panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid var(--border-color); + + h2 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0; + } + + .icon-btn { + width: 32px; + height: 32px; + border: none; + background: var(--bg-tertiary); + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .spin { + animation: exportSpin 1s linear infinite; + } + } + } + + .search-bar { + display: flex; + align-items: center; + gap: 10px; + margin: 16px 20px; + padding: 10px 14px; + background: var(--bg-secondary); + border-radius: 10px; + border: 1px solid var(--border-color); + transition: border-color 0.2s; + + &:focus-within { + border-color: var(--primary); + } + + svg { + color: var(--text-tertiary); + flex-shrink: 0; + } + + input { + flex: 1; + border: none; + background: none; + outline: none; + font-size: 14px; + color: var(--text-primary); + + &::placeholder { + color: var(--text-tertiary); + } + } + + .clear-btn { + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: var(--text-tertiary); + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + } + + .select-actions { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px 12px; + + .select-all-btn { + background: none; + border: none; + padding: 6px 12px; + font-size: 13px; + color: var(--primary); + cursor: pointer; + border-radius: 6px; + + &:hover { + background: rgba(var(--primary-rgb), 0.1); + } + } + + .selected-count { + font-size: 13px; + color: var(--text-secondary); + padding: 4px 12px; + background: var(--bg-secondary); + border-radius: 12px; + } + } + + .loading-state, + .empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--text-tertiary); + font-size: 14px; + + .spin { + animation: exportSpin 1s linear infinite; + } + } + + .export-session-list { + flex: 1; + overflow-y: auto; + padding: 0 12px 12px; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + border-radius: 3px; + opacity: 0.3; + } + } + + .export-session-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 10px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + } + + &.selected { + background: rgba(var(--primary-rgb), 0.08); + + .check-box { + background: var(--primary); + border-color: var(--primary); + color: #fff; + } + } + + .check-box { + width: 20px; + height: 20px; + border: 2px solid var(--border-color); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.2s; + } + + .export-avatar { + width: 44px; + height: 44px; + border-radius: 10px; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: #fff; + font-size: 16px; + font-weight: 600; + } + } + + .export-session-info { + flex: 1; + min-width: 0; + } + + .export-session-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .export-session-summary { + font-size: 12px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 2px; + } + } + + // 右侧设置面板 + .settings-panel { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + } + + .settings-content { + flex: 1; + overflow-y: auto; + padding: 20px 24px; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + border-radius: 3px; + } + } + + .setting-section { + margin-bottom: 28px; + + h3 { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 0 0 14px; + } + } + + .format-options { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 12px; + } + + .format-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 20px 16px; + background: var(--bg-secondary); + border: 2px solid transparent; + border-radius: 12px; + cursor: pointer; + transition: all 0.2s; + text-align: center; + + &:hover { + background: var(--bg-hover); + } + + &.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.05); + + svg { + color: var(--primary); + } + } + + svg { + color: var(--text-secondary); + } + + .format-label { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + + .format-desc { + font-size: 11px; + color: var(--text-tertiary); + line-height: 1.4; + } + } + + .time-options { + display: flex; + flex-direction: column; + gap: 12px; + } + + .checkbox-item { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + font-size: 14px; + color: var(--text-primary); + + input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--primary); + cursor: pointer; + } + + svg { + color: var(--text-secondary); + } + + &.main-toggle { + padding: 12px 16px; + background: var(--bg-secondary); + border-radius: 10px; + } + } + + .date-range { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: var(--bg-secondary); + border-radius: 10px; + font-size: 14px; + color: var(--text-primary); + + svg { + color: var(--text-tertiary); + } + + span { + flex: 1; + } + + .change-btn { + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: var(--text-tertiary); + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: var(--text-primary); + } + } + } + + .media-options { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 12px; + padding-left: 28px; + } + + .folder-select { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: var(--bg-secondary); + border: 1px dashed var(--border-color); + border-radius: 10px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.02); + } + + svg { + color: var(--primary); + } + + .folder-path { + flex: 1; + font-size: 13px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .export-path-display { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: var(--bg-secondary); + border-radius: 10px; + font-size: 13px; + color: var(--text-primary); + + svg { + color: var(--primary); + flex-shrink: 0; + } + + span { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .path-hint { + font-size: 12px; + color: var(--text-tertiary); + margin: 8px 0 0; + } + + .export-action { + padding: 20px 24px; + border-top: 1px solid var(--border-color); + } + + .export-btn { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 14px 24px; + background: var(--primary); + color: #fff; + border: none; + border-radius: 12px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + + &:hover:not(:disabled) { + background: var(--primary-hover); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .spin { + animation: exportSpin 1s linear infinite; + } + } + + // 导出进度弹窗 + .export-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + + .export-progress-modal { + background: var(--card-bg); + padding: 32px 40px; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + text-align: center; + min-width: 320px; + + .progress-spinner { + margin-bottom: 20px; + color: var(--primary); + + .spin { + animation: exportSpin 1s linear infinite; + } + } + + h3 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 8px; + } + + .progress-text { + font-size: 14px; + color: var(--text-secondary); + margin: 0 0 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .progress-bar { + height: 6px; + background: var(--bg-secondary); + border-radius: 3px; + overflow: hidden; + margin-bottom: 12px; + + .progress-fill { + height: 100%; + background: var(--primary); + border-radius: 3px; + transition: width 0.3s ease; + } + } + + .progress-count { + font-size: 13px; + color: var(--text-tertiary); + margin: 0; + } + } + + .export-result-modal { + background: var(--card-bg); + padding: 32px 40px; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + text-align: center; + min-width: 320px; + + .result-icon { + margin-bottom: 16px; + + &.success { + color: #52c41a; + } + + &.error { + color: #ff4d4f; + } + } + + h3 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 8px; + } + + .result-text { + font-size: 14px; + color: var(--text-secondary); + margin: 0 0 24px; + + &.error { + color: #ff4d4f; + } + } + + .result-actions { + display: flex; + gap: 12px; + justify-content: center; + + button { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + } + + .open-folder-btn { + background: var(--primary); + color: #fff; + border: none; + + &:hover { + background: var(--primary-hover); + } + } + + .close-btn { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + + &:hover { + background: var(--bg-hover); + } + } + } + } +} + +@keyframes exportSpin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx new file mode 100644 index 0000000..e39dd07 --- /dev/null +++ b/src/pages/ExportPage.tsx @@ -0,0 +1,377 @@ +import { useState, useEffect, useCallback } from 'react' +import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react' +import * as configService from '../services/config' +import './ExportPage.scss' + +interface ChatSession { + username: string + displayName?: string + avatarUrl?: string + summary: string + lastTimestamp: number +} + +interface ExportOptions { + format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql' + dateRange: { start: Date; end: Date } | null + useAllTime: boolean +} + +interface ExportResult { + success: boolean + successCount?: number + failCount?: number + error?: string +} + +function ExportPage() { + const [sessions, setSessions] = useState([]) + const [filteredSessions, setFilteredSessions] = useState([]) + const [selectedSessions, setSelectedSessions] = useState>(new Set()) + const [isLoading, setIsLoading] = useState(true) + const [searchKeyword, setSearchKeyword] = useState('') + const [exportFolder, setExportFolder] = useState('') + const [isExporting, setIsExporting] = useState(false) + const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' }) + const [exportResult, setExportResult] = useState(null) + + const [options, setOptions] = useState({ + format: 'chatlab', + dateRange: { + start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + end: new Date() + }, + useAllTime: true + }) + + const loadSessions = useCallback(async () => { + setIsLoading(true) + try { + const result = await window.electronAPI.chat.connect() + if (!result.success) { + console.error('连接失败:', result.error) + setIsLoading(false) + return + } + const sessionsResult = await window.electronAPI.chat.getSessions() + if (sessionsResult.success && sessionsResult.sessions) { + setSessions(sessionsResult.sessions) + setFilteredSessions(sessionsResult.sessions) + } + } catch (e) { + console.error('加载会话失败:', e) + } finally { + setIsLoading(false) + } + }, []) + + const loadExportPath = useCallback(async () => { + try { + const savedPath = await configService.getExportPath() + if (savedPath) { + setExportFolder(savedPath) + } else { + const downloadsPath = await window.electronAPI.app.getDownloadsPath() + setExportFolder(downloadsPath) + } + } catch (e) { + console.error('加载导出路径失败:', e) + } + }, []) + + useEffect(() => { + loadSessions() + loadExportPath() + }, [loadSessions, loadExportPath]) + + useEffect(() => { + if (!searchKeyword.trim()) { + setFilteredSessions(sessions) + return + } + const lower = searchKeyword.toLowerCase() + setFilteredSessions(sessions.filter(s => + s.displayName?.toLowerCase().includes(lower) || + s.username.toLowerCase().includes(lower) + )) + }, [searchKeyword, sessions]) + + const toggleSession = (username: string) => { + const newSet = new Set(selectedSessions) + if (newSet.has(username)) { + newSet.delete(username) + } else { + newSet.add(username) + } + setSelectedSessions(newSet) + } + + const toggleSelectAll = () => { + if (selectedSessions.size === filteredSessions.length) { + setSelectedSessions(new Set()) + } else { + setSelectedSessions(new Set(filteredSessions.map(s => s.username))) + } + } + + const getAvatarLetter = (name: string) => { + if (!name) return '?' + return [...name][0] || '?' + } + + const formatDate = (date: Date) => { + return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }) + } + + const openExportFolder = async () => { + if (exportFolder) { + await window.electronAPI.shell.openPath(exportFolder) + } + } + + const startExport = async () => { + if (selectedSessions.size === 0 || !exportFolder) return + + setIsExporting(true) + setExportProgress({ current: 0, total: selectedSessions.size, currentName: '' }) + setExportResult(null) + + try { + const sessionList = Array.from(selectedSessions) + const exportOptions = { + format: options.format, + dateRange: options.useAllTime ? null : options.dateRange ? { + start: Math.floor(options.dateRange.start.getTime() / 1000), + end: Math.floor(options.dateRange.end.getTime() / 1000) + } : null + } + + if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json') { + const result = await window.electronAPI.export.exportSessions( + sessionList, + exportFolder, + exportOptions + ) + setExportResult(result) + } else { + setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式导出功能开发中...` }) + } + } catch (e) { + console.error('导出失败:', e) + setExportResult({ success: false, error: String(e) }) + } finally { + setIsExporting(false) + } + } + + const formatOptions = [ + { value: 'chatlab', label: 'ChatLab', icon: FileCode, desc: '标准格式,支持其他软件导入' }, + { value: 'chatlab-jsonl', label: 'ChatLab JSONL', icon: FileCode, desc: '流式格式,适合大量消息' }, + { value: 'json', label: 'JSON', icon: FileJson, desc: '详细格式,包含完整消息信息' }, + { value: 'html', label: 'HTML', icon: FileText, desc: '网页格式,可直接浏览' }, + { value: 'txt', label: 'TXT', icon: Table, desc: '纯文本,通用格式' }, + { value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' }, + { value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' } + ] + + return ( +
+
+
+

选择会话

+ +
+ +
+ + setSearchKeyword(e.target.value)} + /> + {searchKeyword && ( + + )} +
+ +
+ + 已选 {selectedSessions.size} 个 +
+ + {isLoading ? ( +
+ + 加载中... +
+ ) : filteredSessions.length === 0 ? ( +
+ 暂无会话 +
+ ) : ( +
+ {filteredSessions.map(session => ( +
toggleSession(session.username)} + > +
+ {selectedSessions.has(session.username) && } +
+
+ {session.avatarUrl ? ( + + ) : ( + {getAvatarLetter(session.displayName || session.username)} + )} +
+
+
{session.displayName || session.username}
+
{session.summary || '暂无消息'}
+
+
+ ))} +
+ )} +
+ +
+
+

导出设置

+
+ +
+
+

导出格式

+
+ {formatOptions.map(fmt => ( +
setOptions({ ...options, format: fmt.value as any })} + > + + {fmt.label} + {fmt.desc} +
+ ))} +
+
+ +
+

时间范围

+
+ + {!options.useAllTime && options.dateRange && ( +
+ + {formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)} + +
+ )} +
+
+ +
+

导出位置

+
+ + {exportFolder || '未设置'} +
+

可在设置页面修改导出目录

+
+
+ +
+ +
+
+ + {/* 导出进度弹窗 */} + {isExporting && ( +
+
+
+ +
+

正在导出

+

{exportProgress.currentName}

+
+
+
+

{exportProgress.current} / {exportProgress.total}

+
+
+ )} + + {/* 导出结果弹窗 */} + {exportResult && ( +
+
+
+ {exportResult.success ? : } +
+

{exportResult.success ? '导出完成' : '导出失败'}

+ {exportResult.success ? ( +

+ 成功导出 {exportResult.successCount} 个会话 + {exportResult.failCount ? `,${exportResult.failCount} 个失败` : ''} +

+ ) : ( +

{exportResult.error}

+ )} +
+ {exportResult.success && ( + + )} + +
+
+
+ )} +
+ ) +} + +export default ExportPage diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss new file mode 100644 index 0000000..7993c7a --- /dev/null +++ b/src/pages/GroupAnalyticsPage.scss @@ -0,0 +1,1167 @@ +.group-analytics-page { + display: flex; + height: 100%; + gap: 16px; + + &.standalone { + height: 100vh; + gap: 0; + background: var(--bg-gradient); + + .group-sidebar { + background: var(--card-bg); + border-right: 1px solid var(--border-color); + backdrop-filter: blur(20px); + -webkit-app-region: no-drag; + + .sidebar-header { + padding-top: 38px; + padding-bottom: 12px; + padding-left: 16px; + padding-right: 16px; + -webkit-app-region: drag; + + .search-row { + display: flex; + align-items: center; + gap: 8px; + -webkit-app-region: no-drag; + } + + .search-box { + flex: 1; + min-width: 0; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 8px 12px; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s; + + &:focus-within { + background: var(--bg-hover); + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-light); + } + + input { + flex: 1; + min-width: 0; + border: none; + background: transparent; + outline: none; + color: var(--text-primary); + font-size: 13px; + + &::placeholder { + color: var(--text-tertiary); + } + } + + svg { + color: var(--text-tertiary); + flex-shrink: 0; + } + + .close-search { + width: 18px; + height: 18px; + padding: 0; + border: none; + background: var(--bg-hover); + border-radius: 50%; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + &:hover { + background: var(--border-color); + color: var(--text-primary); + } + } + } + + .refresh-btn { + width: 32px; + height: 32px; + padding: 0; + border: none; + background: var(--bg-tertiary); + border-radius: 8px; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + flex-shrink: 0; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } + } + + .group-list { + flex: 1; + overflow-y: auto; + background: transparent; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + opacity: 0.3; + border-radius: 3px; + } + } + + .group-item { + border-bottom: 1px solid var(--border-color); + padding: 12px 16px; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + } + + &.active { + background: var(--primary-light); + border-left: 3px solid var(--primary); + padding-left: 13px; + } + } + } + + .resize-handle { + width: 4px; + margin-left: -2px; + margin-right: -2px; + background: transparent; + cursor: col-resize; + -webkit-app-region: no-drag; + transition: background 0.2s; + position: relative; + z-index: 10; + + &:hover { + background: var(--primary); + opacity: 0.4; + } + } + + &.resizing .resize-handle { + background: var(--primary); + } + + .detail-area { + flex: 1; + background: var(--chat-pattern); + background-color: var(--bg-secondary); + -webkit-app-region: no-drag; + display: flex; + flex-direction: column; + overflow: hidden; + } + } +} + + +.group-sidebar { + display: flex; + flex-direction: column; + min-width: 250px; + max-width: 450px; + background: var(--bg-secondary); + border-radius: 16px; + overflow: hidden; +} + +.sidebar-header { + padding: 16px 16px 12px; + display: flex; + align-items: center; + min-height: 56px; + + .search-row { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + } + + .search-box { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--bg-primary); + border-radius: 8px; + animation: searchExpand 0.25s ease-out; + + svg { + color: var(--text-tertiary); + flex-shrink: 0; + } + + input { + flex: 1; + min-width: 0; + border: none; + background: transparent; + outline: none; + font-size: 14px; + color: var(--text-primary); + + &::placeholder { + color: var(--text-tertiary); + } + } + + .close-search { + width: 20px; + height: 20px; + border: none; + background: var(--bg-tertiary); + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + flex-shrink: 0; + + &:hover { + background: var(--border-color); + color: var(--text-primary); + } + } + } + + .refresh-btn { + width: 32px; + height: 32px; + border: none; + background: transparent; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + flex-shrink: 0; + + &:hover { + background: var(--bg-hover); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .spin { + animation: spin 1s linear infinite; + } + } +} + +.group-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + + &:hover { + background: rgba(0, 0, 0, 0.3); + } + } +} + +.group-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + cursor: pointer; + transition: background 0.15s; + border-bottom: 1px solid var(--border-color); + + &:last-child { + border-bottom: none; + } + + &:hover { + background: var(--bg-hover); + } + + &.active { + background: var(--primary-light); + } + + .group-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .avatar-placeholder { + width: 100%; + height: 100%; + background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + } + } + + .group-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + + .group-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .group-members { + font-size: 12px; + color: var(--text-tertiary); + } + } +} + +.loading-groups { + padding: 8px 16px; +} + +.skeleton-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 0; + + .skeleton-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--bg-tertiary); + animation: pulse 1.5s infinite; + } + + .skeleton-content { + flex: 1; + + .skeleton-line { + height: 14px; + background: var(--bg-tertiary); + border-radius: 4px; + animation: pulse 1.5s infinite; + + &:first-child { + width: 50%; + margin-bottom: 8px; + } + + &:last-child { + width: 80%; + height: 12px; + } + } + } +} + +.empty-groups { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: var(--text-tertiary); + + svg { + opacity: 0.5; + margin-bottom: 12px; + } + + p { + font-size: 13px; + } +} + + +.placeholder { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + gap: 16px; + + svg { + opacity: 0.4; + } + + p { + font-size: 15px; + } +} + +.detail-area { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + background: var(--bg-secondary); + border-radius: 16px; + overflow: hidden; +} + +.resize-handle { + width: 4px; + cursor: col-resize; + background: transparent; + transition: background 0.2s; + flex-shrink: 0; + + &:hover { + background: var(--primary); + } +} + +.group-analytics-page.resizing { + user-select: none; + + .resize-handle { + background: var(--primary); + } +} + +.function-menu { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px; + + .selected-group-info { + text-align: center; + margin-bottom: 40px; + + .group-avatar.large { + width: 80px; + height: 80px; + border-radius: 50%; + overflow: hidden; + margin: 0 auto 16px; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .avatar-placeholder { + width: 100%; + height: 100%; + background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + } + } + + h2 { + font-size: 20px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; + } + + p { + color: var(--text-secondary); + font-size: 14px; + } + } + + .function-grid { + display: flex; + flex-wrap: wrap; + gap: 20px; + justify-content: center; + } + + .function-card { + width: 140px; + padding: 24px 16px; + background: rgba(255, 255, 255, 0.15); + border-radius: 16px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + cursor: pointer; + transition: all 0.2s; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.15); + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); + background: rgba(255, 255, 255, 0.25); + } + + svg { + color: var(--primary); + } + + span { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + } + } +} + +.function-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + + .content-header { + display: flex; + align-items: center; + gap: 12px; + padding: 0 24px; + padding-top: 38px; + padding-bottom: 12px; + background: var(--card-bg); + border-bottom: 1px solid var(--border-color); + -webkit-app-region: drag; + backdrop-filter: blur(10px); + position: relative; + z-index: 10; + + .back-btn { + background: var(--bg-tertiary); + border: none; + padding: 8px; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + -webkit-app-region: no-drag; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .header-info { + flex: 1; + -webkit-app-region: no-drag; + + h3 { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin: 0; + } + + .header-subtitle { + font-size: 11px; + color: var(--text-tertiary); + margin-top: 2px; + } + } + + .refresh-btn { + width: 32px; + height: 32px; + padding: 0; + border: none; + background: var(--bg-tertiary); + border-radius: 8px; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + -webkit-app-region: no-drag; + flex-shrink: 0; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } + } + + .content-body { + flex: 1; + overflow-y: auto; + padding: 20px 24px; + display: flex; + flex-direction: column; + + > .chart-container, + > .media-stats { + flex: 1; + min-height: 0; + } + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + opacity: 0.3; + border-radius: 3px; + } + } + + .content-loading { + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--text-tertiary); + } +} + + +.members-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 12px; + + .member-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 16px 8px; + background: transparent; + border-radius: 12px; + cursor: pointer; + transition: background 0.15s; + + &:hover { + background: rgba(255, 255, 255, 0.1); + } + + .member-avatar { + width: 48px; + height: 48px; + border-radius: 50%; + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .avatar-placeholder { + width: 100%; + height: 100%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + } + } + + .member-name { + font-size: 12px; + color: var(--text-primary); + text-align: center; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } +} + +.rankings-list { + display: flex; + flex-direction: column; + gap: 8px; + + .ranking-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: transparent; + border-radius: 12px; + + .rank { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + + &.top { + background: linear-gradient(135deg, #ffd700, #ffb800); + color: #fff; + } + } + + .contact-avatar { + position: relative; + width: 40px; + height: 40px; + + img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + } + + .avatar-placeholder { + width: 100%; + height: 100%; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + } + + .medal { + position: absolute; + bottom: -2px; + right: -2px; + width: 18px; + height: 18px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid var(--card-bg); + + &.medal-1 { + background: linear-gradient(135deg, #ffd700, #ffb800); + color: #fff; + } + &.medal-2 { + background: linear-gradient(135deg, #c0c0c0, #a8a8a8); + color: #fff; + } + &.medal-3 { + background: linear-gradient(135deg, #cd7f32, #b87333); + color: #fff; + } + } + } + + .contact-info { + flex: 1; + min-width: 0; + + .contact-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; + } + } + + .message-count { + font-size: 14px; + color: var(--text-secondary); + font-weight: 500; + } + } +} + +.chart-container { + background: transparent; + border-radius: 12px; + padding: 0; + height: 100%; + display: flex; + flex-direction: column; +} + +.media-stats { + height: 100%; + display: flex; + flex-direction: column; + + .media-layout { + flex: 1; + display: flex; + gap: 24px; + min-height: 0; + + @media (max-width: 800px) { + flex-direction: column; + } + + .chart-container { + flex: 1; + min-width: 0; + min-height: 300px; + } + + .media-legend { + width: 280px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + background: rgba(255, 255, 255, 0.08); + border-radius: 12px; + backdrop-filter: blur(8px); + + @media (max-width: 800px) { + width: 100%; + } + + .legend-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: 8px; + transition: background 0.15s; + + &:hover { + background: rgba(255, 255, 255, 0.1); + } + + .legend-color { + width: 12px; + height: 12px; + border-radius: 3px; + flex-shrink: 0; + } + + .legend-name { + flex: 1; + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + } + + .legend-count { + font-size: 13px; + color: var(--text-secondary); + font-weight: 500; + } + + .legend-percent { + font-size: 12px; + color: var(--text-tertiary); + width: 50px; + text-align: right; + } + } + + .legend-total { + display: flex; + justify-content: space-between; + padding: 12px; + margin-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + } + } + + .stats-cards { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 12px; + margin-bottom: 20px; + + .stat-card { + background: transparent; + border-radius: 12px; + padding: 16px; + text-align: center; + + .value { + display: block; + font-size: 24px; + font-weight: 600; + color: var(--primary); + margin-bottom: 4px; + } + + .label { + font-size: 13px; + color: var(--text-secondary); + } + } + } + + .chart-container { + flex: 1; + min-height: 0; + } +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes searchExpand { + from { + opacity: 0; + transform: scaleX(0.8); + transform-origin: right center; + } + to { + opacity: 1; + transform: scaleX(1); + transform-origin: right center; + } +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + + +// 暗色模式适配 +[data-mode="dark"] { + .function-card { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + + &:hover { + background: rgba(255, 255, 255, 0.1); + } + } + + .member-card:hover { + background: rgba(255, 255, 255, 0.05); + } + + .member-modal { + background: rgba(30, 30, 30, 0.95); + border: 1px solid rgba(255, 255, 255, 0.1); + } +} + +// 成员详情弹框 +.member-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); +} + +.member-modal { + background: rgba(255, 255, 255, 0.95); + border-radius: 20px; + padding: 32px; + min-width: 320px; + max-width: 400px; + position: relative; + backdrop-filter: blur(20px); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + + .modal-close { + position: absolute; + top: 16px; + right: 16px; + background: var(--bg-tertiary); + border: none; + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + transition: all 0.15s; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .modal-content { + display: flex; + flex-direction: column; + align-items: center; + } + + .member-avatar.large { + width: 96px; + height: 96px; + border-radius: 50%; + overflow: hidden; + margin-bottom: 16px; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .avatar-placeholder { + width: 100%; + height: 100%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + } + } + + .member-display-name { + font-size: 20px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 24px; + text-align: center; + } + + .member-details { + width: 100%; + display: flex; + flex-direction: column; + gap: 12px; + } + + .detail-row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--bg-tertiary); + border-radius: 12px; + + .detail-label { + font-size: 13px; + color: var(--text-tertiary); + width: 60px; + flex-shrink: 0; + } + + .detail-value { + flex: 1; + font-size: 14px; + color: var(--text-primary); + word-break: break-all; + } + + .copy-btn { + background: none; + border: none; + padding: 6px; + border-radius: 6px; + cursor: pointer; + color: var(--text-tertiary); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; + + &:hover { + background: var(--bg-hover); + color: var(--primary); + } + } + } +} diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx new file mode 100644 index 0000000..846b6e6 --- /dev/null +++ b/src/pages/GroupAnalyticsPage.tsx @@ -0,0 +1,521 @@ +import { useState, useEffect, useRef } from 'react' +import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react' +import ReactECharts from 'echarts-for-react' +import DateRangePicker from '../components/DateRangePicker' +import './GroupAnalyticsPage.scss' + +interface GroupChatInfo { + username: string + displayName: string + memberCount: number + avatarUrl?: string +} + +interface GroupMember { + username: string + displayName: string + avatarUrl?: string +} + +interface GroupMessageRank { + member: GroupMember + messageCount: number +} + +type AnalysisFunction = 'members' | 'ranking' | 'activeHours' | 'mediaStats' + +function GroupAnalyticsPage() { + const [groups, setGroups] = useState([]) + const [filteredGroups, setFilteredGroups] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [selectedGroup, setSelectedGroup] = useState(null) + const [selectedFunction, setSelectedFunction] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + + // 功能数据 + const [members, setMembers] = useState([]) + const [rankings, setRankings] = useState([]) + const [activeHours, setActiveHours] = useState>({}) + const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null) + const [functionLoading, setFunctionLoading] = useState(false) + + // 成员详情弹框 + const [selectedMember, setSelectedMember] = useState(null) + const [copiedField, setCopiedField] = useState(null) + + // 时间范围 + const [startDate, setStartDate] = useState('') + const [endDate, setEndDate] = useState('') + const [dateRangeReady, setDateRangeReady] = useState(false) + + // 拖动调整宽度 + const [sidebarWidth, setSidebarWidth] = useState(300) + const [isResizing, setIsResizing] = useState(false) + const containerRef = useRef(null) + + useEffect(() => { + loadGroups() + }, []) + + useEffect(() => { + if (searchQuery) { + setFilteredGroups(groups.filter(g => g.displayName.toLowerCase().includes(searchQuery.toLowerCase()))) + } else { + setFilteredGroups(groups) + } + }, [searchQuery, groups]) + + // 拖动调整宽度 + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizing || !containerRef.current) return + const containerRect = containerRef.current.getBoundingClientRect() + const newWidth = e.clientX - containerRect.left + setSidebarWidth(Math.max(250, Math.min(450, newWidth))) + } + const handleMouseUp = () => setIsResizing(false) + if (isResizing) { + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + } + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + }, [isResizing]) + + // 日期范围变化时自动刷新 + useEffect(() => { + if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members') { + setDateRangeReady(false) + loadFunctionData(selectedFunction) + } + }, [dateRangeReady]) + + const loadGroups = async () => { + setIsLoading(true) + try { + const result = await window.electronAPI.groupAnalytics.getGroupChats() + if (result.success && result.data) { + setGroups(result.data) + setFilteredGroups(result.data) + } + } catch (e) { + console.error(e) + } finally { + setIsLoading(false) + } + } + + const handleGroupSelect = (group: GroupChatInfo) => { + if (selectedGroup?.username !== group.username) { + setSelectedGroup(group) + setSelectedFunction(null) + } + } + + + const handleFunctionSelect = async (func: AnalysisFunction) => { + if (!selectedGroup) return + setSelectedFunction(func) + await loadFunctionData(func) + } + + const loadFunctionData = async (func: AnalysisFunction) => { + if (!selectedGroup) return + setFunctionLoading(true) + + // 计算时间戳 + const startTime = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined + const endTime = endDate ? Math.floor(new Date(endDate + 'T23:59:59').getTime() / 1000) : undefined + + try { + switch (func) { + case 'members': { + const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username) + if (result.success && result.data) setMembers(result.data) + break + } + case 'ranking': { + const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime) + if (result.success && result.data) setRankings(result.data) + break + } + case 'activeHours': { + const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(selectedGroup.username, startTime, endTime) + if (result.success && result.data) setActiveHours(result.data.hourlyDistribution) + break + } + case 'mediaStats': { + const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(selectedGroup.username, startTime, endTime) + if (result.success && result.data) setMediaStats(result.data) + break + } + } + } catch (e) { + console.error(e) + } finally { + setFunctionLoading(false) + } + } + + const formatNumber = (num: number) => { + if (num >= 10000) return (num / 10000).toFixed(1) + '万' + return num.toLocaleString() + } + + const getHourlyOption = () => { + const hours = Array.from({ length: 24 }, (_, i) => i) + const data = hours.map(h => activeHours[h] || 0) + return { + tooltip: { trigger: 'axis' }, + xAxis: { type: 'category', data: hours.map(h => `${h}时`) }, + yAxis: { type: 'value' }, + series: [{ type: 'bar', data, itemStyle: { color: '#07c160', borderRadius: [4, 4, 0, 0] } }] + } + } + + const getMediaOption = () => { + if (!mediaStats || mediaStats.typeCounts.length === 0) return {} + + // 定义颜色映射 + const colorMap: Record = { + 1: '#3b82f6', // 文本 - 蓝色 + 3: '#22c55e', // 图片 - 绿色 + 34: '#f97316', // 语音 - 橙色 + 43: '#a855f7', // 视频 - 紫色 + 47: '#ec4899', // 表情包 - 粉色 + 49: '#14b8a6', // 链接/文件 - 青色 + [-1]: '#6b7280', // 其他 - 灰色 + } + + const data = mediaStats.typeCounts.map(item => ({ + name: item.name, + value: item.count, + itemStyle: { color: colorMap[item.type] || '#6b7280' } + })) + + return { + tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }, + series: [{ + type: 'pie', + radius: ['40%', '70%'], + center: ['50%', '50%'], + itemStyle: { borderRadius: 8, borderColor: 'rgba(255,255,255,0.1)', borderWidth: 2 }, + label: { + show: true, + formatter: (params: { name: string; percent: number }) => { + // 只显示占比大于3%的标签 + return params.percent > 3 ? `${params.name}\n${params.percent.toFixed(1)}%` : '' + }, + color: '#fff' + }, + labelLine: { + show: true, + length: 10, + length2: 10 + }, + data + }] + } + } + + const handleRefresh = () => { + if (selectedFunction) { + loadFunctionData(selectedFunction) + } + } + + const handleDateRangeComplete = () => { + setDateRangeReady(true) + } + + const handleMemberClick = (member: GroupMember) => { + setSelectedMember(member) + setCopiedField(null) + } + + const handleCopy = async (text: string, field: string) => { + try { + await navigator.clipboard.writeText(text) + setCopiedField(field) + setTimeout(() => setCopiedField(null), 2000) + } catch (e) { + console.error('复制失败:', e) + } + } + + const renderMemberModal = () => { + if (!selectedMember) return null + + return ( +
setSelectedMember(null)}> +
e.stopPropagation()}> + +
+
+ {selectedMember.avatarUrl ? ( + + ) : ( +
+ )} +
+

{selectedMember.displayName}

+
+
+ 微信ID + {selectedMember.username} + +
+
+ 昵称 + {selectedMember.displayName} + +
+
+
+
+
+ ) + } + + const renderGroupList = () => ( +
+
+
+
+ + setSearchQuery(e.target.value)} + /> + {searchQuery && ( + + )} +
+ +
+
+
+ {isLoading ? ( +
+ {[1, 2, 3, 4, 5].map(i => ( +
+
+
+
+
+
+
+ ))} +
+ ) : filteredGroups.length === 0 ? ( +
+ +

{searchQuery ? '未找到匹配的群聊' : '暂无群聊数据'}

+
+ ) : ( + filteredGroups.map(group => ( +
handleGroupSelect(group)} + > +
+ {group.avatarUrl ? :
} +
+
+ {group.displayName} + {group.memberCount} 位成员 +
+
+ )) + )} +
+
+ ) + + + const renderFunctionMenu = () => ( +
+
+
+ {selectedGroup?.avatarUrl ? :
} +
+

{selectedGroup?.displayName}

+

{selectedGroup?.memberCount} 位成员

+
+
+
handleFunctionSelect('members')}> + + 群成员查看 +
+
handleFunctionSelect('ranking')}> + + 群聊发言排行 +
+
handleFunctionSelect('activeHours')}> + + 群聊活跃时段 +
+
handleFunctionSelect('mediaStats')}> + + 媒体内容统计 +
+
+
+ ) + + const renderFunctionContent = () => { + const getFunctionTitle = () => { + switch (selectedFunction) { + case 'members': return '群成员查看' + case 'ranking': return '群聊发言排行' + case 'activeHours': return '群聊活跃时段' + case 'mediaStats': return '媒体内容统计' + default: return '' + } + } + + const showDateRange = selectedFunction !== 'members' + + return ( +
+
+ +
+

{getFunctionTitle()}

+ {selectedGroup?.displayName} +
+ {showDateRange && ( + + )} + +
+
+ {functionLoading ? ( +
+ ) : ( + <> + {selectedFunction === 'members' && ( +
+ {members.map(member => ( +
handleMemberClick(member)}> +
+ {member.avatarUrl ? :
} +
+ {member.displayName} +
+ ))} +
+ )} + {selectedFunction === 'ranking' && ( +
+ {rankings.map((item, index) => ( +
+ {index + 1} +
+ {item.member.avatarUrl ? :
} + {index < 3 &&
} +
+
+ {item.member.displayName} +
+ {formatNumber(item.messageCount)} 条 +
+ ))} +
+ )} + {selectedFunction === 'activeHours' && ( +
+ +
+ )} + {selectedFunction === 'mediaStats' && mediaStats && ( +
+
+
+ +
+
+ {mediaStats.typeCounts.map(item => { + const colorMap: Record = { + 1: '#3b82f6', 3: '#22c55e', 34: '#f97316', + 43: '#a855f7', 47: '#ec4899', 49: '#14b8a6', [-1]: '#6b7280' + } + const percentage = mediaStats.total > 0 ? ((item.count / mediaStats.total) * 100).toFixed(1) : '0' + return ( +
+ + {item.name} + {formatNumber(item.count)} 条 + ({percentage}%) +
+ ) + })} +
+ 总计 + {formatNumber(mediaStats.total)} 条 +
+
+
+
+ )} + + )} +
+
+ ) + } + + + const renderDetailPanel = () => { + if (!selectedGroup) { + return ( +
+ +

请从左侧选择一个群聊进行分析

+
+ ) + } + if (!selectedFunction) { + return renderFunctionMenu() + } + return renderFunctionContent() + } + + return ( +
+ {renderGroupList()} +
setIsResizing(true)} /> +
+ {renderDetailPanel()} +
+ {renderMemberModal()} +
+ ) +} + +export default GroupAnalyticsPage diff --git a/src/pages/HomePage.scss b/src/pages/HomePage.scss new file mode 100644 index 0000000..6b12cb1 --- /dev/null +++ b/src/pages/HomePage.scss @@ -0,0 +1,112 @@ +.home-page { + height: 100%; + background: var(--bg-primary); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; +} + +.home-bg-blobs { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + filter: blur(80px); + z-index: 0; + opacity: 0.6; + pointer-events: none; +} + +.blob { + position: absolute; + border-radius: 50%; + animation: moveBlob 20s infinite alternate ease-in-out; +} + +.blob-1 { + width: 400px; + height: 400px; + background: rgba(139, 115, 85, 0.25); + top: -100px; + left: -50px; + animation-duration: 25s; +} + +.blob-2 { + width: 350px; + height: 350px; + background: rgba(139, 115, 85, 0.15); + bottom: -50px; + right: -50px; + animation-duration: 30s; + animation-delay: -5s; +} + +.blob-3 { + width: 300px; + height: 300px; + background: rgba(255, 255, 255, 0.1); + top: 40%; + left: 30%; + animation-duration: 22s; + animation-delay: -10s; +} + +[data-mode="dark"] .blob-3 { + background: rgba(255, 255, 255, 0.03); +} + +.home-content { + z-index: 1; + animation: fadeScaleUp 1s cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.hero { + text-align: center; +} + +.hero-title { + font-size: 64px; + font-weight: 800; + margin: 0 0 16px; + color: var(--text-primary); + letter-spacing: -2px; + background: linear-gradient(135deg, var(--text-primary) 0%, rgba(139, 115, 85, 0.8) 100%); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.hero-subtitle { + font-size: 18px; + color: var(--text-secondary); + max-width: 520px; + margin: 0 auto; + line-height: 1.6; + opacity: 0.8; +} + +@keyframes moveBlob { + from { + transform: translate(0, 0) scale(1); + } + + to { + transform: translate(100px, 50px) scale(1.1); + } +} + +@keyframes fadeScaleUp { + from { + opacity: 0; + transform: scale(0.95) translateY(20px); + } + + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} \ No newline at end of file diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx new file mode 100644 index 0000000..edca946 --- /dev/null +++ b/src/pages/HomePage.tsx @@ -0,0 +1,24 @@ +import { FolderOpen, ShieldCheck, Sparkles, Waves } from 'lucide-react' +import { useAppStore } from '../stores/appStore' +import './HomePage.scss' + +function HomePage() { + return ( +
+
+
+
+
+
+ +
+
+

WeFlow

+

每一条消息的背后,都藏着一段温暖的时光

+
+
+
+ ) +} + +export default HomePage diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss new file mode 100644 index 0000000..7f2f676 --- /dev/null +++ b/src/pages/SettingsPage.scss @@ -0,0 +1,769 @@ +.settings-page { + display: flex; + flex-direction: column; + height: 100%; + margin: -24px; + padding: 24px; + overflow: hidden; +} + +.settings-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + flex-shrink: 0; + + h1 { + font-size: 24px; + font-weight: 600; + color: var(--text-primary); + margin: 0; + } +} + +.settings-actions { + display: flex; + gap: 12px; +} + +.settings-tabs { + display: flex; + gap: 4px; + padding: 4px; + background: var(--bg-tertiary); + border-radius: 12px; + margin-bottom: 20px; + flex-shrink: 0; + width: fit-content; +} + +.tab-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 18px; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + background: transparent; + color: var(--text-secondary); + + &:hover { + color: var(--text-primary); + background: var(--bg-secondary); + } + + &.active { + background: var(--card-bg); + color: var(--primary); + box-shadow: var(--shadow-sm); + } +} + +.settings-body { + flex: 1; + overflow-y: auto; + padding-right: 8px; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; + } +} + +.tab-content { + background: var(--bg-secondary); + border-radius: 16px; + padding: 24px; + + .section-desc { + font-size: 13px; + color: var(--text-tertiary); + margin: 0 0 20px; + } +} + +.divider { + height: 1px; + background: var(--border-color); + margin: 20px 0; +} + +.unavailable-notice { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 18px; + background: var(--bg-tertiary); + border-radius: 10px; + margin-bottom: 20px; + color: var(--text-secondary); + + p { + margin: 0; + font-size: 14px; + } +} + +.form-group.disabled { + opacity: 0.5; + pointer-events: none; +} + +.form-group { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + + label { + display: block; + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 2px; + + .optional { + font-weight: 400; + color: var(--text-tertiary); + } + } + + .form-hint { + display: block; + font-size: 12px; + color: var(--text-tertiary); + margin-bottom: 8px; + } + + .status-text { + margin-top: 6px; + color: var(--text-secondary); + } + + .manual-prompt { + background: rgba(139, 115, 85, 0.1); + border: 1px dashed rgba(139, 115, 85, 0.3); + padding: 12px 14px; + border-radius: 14px; + display: flex; + flex-direction: column; + gap: 10px; + margin: 6px 0 8px; + + .prompt-text { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + margin: 0; + } + } + + .key-status { + display: block; + font-size: 13px; + color: var(--primary); + margin-bottom: 10px; + animation: pulse 1.5s ease-in-out infinite; + } + + input { + width: 100%; + padding: 10px 16px; + border: 1px solid var(--border-color); + border-radius: 9999px; + font-size: 14px; + background: var(--bg-primary); + color: var(--text-primary); + margin-bottom: 10px; + + &:focus { + outline: none; + border-color: var(--primary); + } + + &::placeholder { + color: var(--text-tertiary); + } + + &:read-only { + cursor: pointer; + } + } + + .input-with-toggle { + position: relative; + display: flex; + align-items: center; + margin-bottom: 10px; + + input { + margin-bottom: 0; + padding-right: 70px; + } + + .toggle-visibility { + position: absolute; + right: 12px; + padding: 4px 10px; + border: none; + border-radius: 9999px; + font-size: 12px; + background: var(--bg-tertiary); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--border-color); + color: var(--text-primary); + } + } + } +} + +.log-toggle-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 6px; +} + +.log-status { + font-size: 13px; + color: var(--text-secondary); +} + +.switch { + position: relative; + width: 46px; + height: 24px; + display: inline-block; + user-select: none; +} + +.switch-input { + opacity: 0; + width: 0; + height: 0; +} + +.switch-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 999px; + transition: all 0.2s ease; +} + +.switch-slider::before { + content: ''; + position: absolute; + height: 18px; + width: 18px; + left: 3px; + top: 2px; + background: var(--text-tertiary); + border-radius: 50%; + transition: all 0.2s ease; +} + +.switch-input:checked + .switch-slider { + background: var(--primary); + border-color: var(--primary); +} + +.switch-input:checked + .switch-slider::before { + transform: translateX(22px); + background: #ffffff; +} + +.log-actions { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; + flex-wrap: wrap; +} + +.log-actions .btn { + padding: 8px 16px; + font-size: 13px; +} + +.btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 20px; + border: none; + border-radius: 9999px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +.btn-primary { + background: var(--primary); + color: white; + + &:hover:not(:disabled) { + background: var(--primary-hover); + } +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + + &:hover:not(:disabled) { + background: var(--border-color); + } +} + +.btn-danger { + background: var(--danger); + color: white; + + &:hover:not(:disabled) { + opacity: 0.9; + } +} + +.btn-sm { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + font-size: 13px; +} + +.btn-row { + display: flex; + gap: 10px; +} + +.message-toast { + position: fixed; + top: 60px; + left: 50%; + transform: translateX(-50%); + padding: 10px 24px; + border-radius: 9999px; + font-size: 14px; + z-index: 100; + animation: slideDown 0.3s ease; + + &.success { + background: var(--primary); + color: white; + } + + &.error { + background: var(--danger); + color: white; + } +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateX(-50%) translateY(-10px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } +} + +// 主题选择器 +.theme-mode-toggle { + display: flex; + gap: 8px; + margin-bottom: 16px; + padding: 4px; + background: var(--bg-tertiary); + border-radius: 12px; + width: fit-content; + + .mode-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + background: transparent; + color: var(--text-secondary); + + &:hover { + color: var(--text-primary); + } + + &.active { + background: var(--card-bg); + color: var(--primary); + box-shadow: var(--shadow-sm); + } + } +} + +.theme-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 12px; +} + +.theme-card { + position: relative; + border: 2px solid var(--border-color); + border-radius: 12px; + padding: 8px; + cursor: pointer; + transition: all 0.2s; + background: var(--bg-primary); + + &:hover { + border-color: var(--text-tertiary); + } + + &.active { + border-color: var(--primary); + + .theme-preview { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + } + + .theme-preview { + height: 60px; + border-radius: 8px; + margin-bottom: 8px; + position: relative; + overflow: hidden; + + .theme-accent { + position: absolute; + bottom: 8px; + right: 8px; + width: 24px; + height: 24px; + border-radius: 50%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + } + } + + .theme-info { + display: flex; + flex-direction: column; + gap: 2px; + + .theme-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + } + + .theme-desc { + font-size: 11px; + color: var(--text-tertiary); + } + } + + .theme-check { + position: absolute; + top: 8px; + right: 8px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--primary); + color: white; + display: flex; + align-items: center; + justify-content: center; + } +} + + +// 关于页面 +.about-tab { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + text-align: center; +} + +.about-card { + display: flex; + flex-direction: column; + align-items: center; + padding: 40px; + + .about-logo { + width: 96px; + height: 96px; + border-radius: 24px; + overflow: hidden; + background: var(--bg-tertiary); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); + margin-bottom: 20px; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + .about-name { + margin: 0; + font-size: 28px; + font-weight: 700; + color: var(--text-primary); + } + + .about-slogan { + margin: 4px 0 0; + font-size: 14px; + color: var(--text-tertiary); + letter-spacing: 2px; + } + + .about-version { + margin: 16px 0 0; + padding: 4px 12px; + font-size: 13px; + color: var(--text-secondary); + background: var(--bg-tertiary); + border-radius: 20px; + } + + .about-update { + margin-top: 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + + .update-hint { + margin: 0; + font-size: 14px; + color: var(--primary); + } + + .download-progress { + display: flex; + align-items: center; + gap: 12px; + width: 200px; + + .progress-bar { + flex: 1; + height: 6px; + background: var(--bg-tertiary); + border-radius: 3px; + overflow: hidden; + + .progress-fill { + height: 100%; + background: var(--primary); + border-radius: 3px; + transition: width 0.2s ease; + } + } + + span { + font-size: 12px; + color: var(--text-secondary); + min-width: 35px; + } + } + } +} + +.about-footer { + margin-top: auto; + padding-top: 24px; + text-align: center; + + .about-desc { + margin: 0; + font-size: 14px; + color: var(--text-secondary); + } + + .about-links { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 12px; + font-size: 14px; + + a { + color: var(--primary); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + span { + color: var(--text-tertiary); + } + } + + .copyright { + margin: 16px 0 0; + font-size: 12px; + color: var(--text-tertiary); + } +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + + +// 协议弹窗 +.agreement-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.agreement-modal { + width: 500px; + max-height: 70vh; + background: var(--bg-primary); + border-radius: 16px; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2); + + .agreement-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid var(--border-color); + + h2 { + margin: 0; + font-size: 17px; + font-weight: 600; + color: var(--text-primary); + } + + .close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } + } + } + + .agreement-body { + flex: 1; + padding: 24px; + overflow-y: auto; + + h4 { + margin: 0 0 8px; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + + &:not(:first-child) { + margin-top: 20px; + } + } + + p { + margin: 0; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.7; + } + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; + } + } +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..418faaa --- /dev/null +++ b/src/pages/SettingsPage.tsx @@ -0,0 +1,683 @@ +import { useState, useEffect } from 'react' +import { useAppStore } from '../stores/appStore' +import { useThemeStore, themes } from '../stores/themeStore' +import { dialog } from '../services/ipc' +import * as configService from '../services/config' +import { + Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, + RotateCcw, Trash2, Save, Plug, Check, Sun, Moon, + Palette, Database, Download, HardDrive, Info, RefreshCw +} from 'lucide-react' +import './SettingsPage.scss' + +type SettingsTab = 'appearance' | 'database' | 'export' | 'cache' | 'about' + +const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ + { id: 'appearance', label: '外观', icon: Palette }, + { id: 'database', label: '数据库连接', icon: Database }, + { id: 'export', label: '导出', icon: Download }, + { id: 'cache', label: '缓存', icon: HardDrive }, + { id: 'about', label: '关于', icon: Info } +] + +function SettingsPage() { + const { setDbConnected, setLoading, reset } = useAppStore() + const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() + + const [activeTab, setActiveTab] = useState('appearance') + const [decryptKey, setDecryptKey] = useState('') + const [imageXorKey, setImageXorKey] = useState('') + const [imageAesKey, setImageAesKey] = useState('') + const [dbPath, setDbPath] = useState('') + const [wxid, setWxid] = useState('') + const [cachePath, setCachePath] = useState('') + const [exportPath, setExportPath] = useState('') + const [defaultExportPath, setDefaultExportPath] = useState('') + const [logEnabled, setLogEnabled] = useState(false) + + const [isLoading, setIsLoadingState] = useState(false) + const [isTesting, setIsTesting] = useState(false) + const [isDetectingPath, setIsDetectingPath] = useState(false) + const [isFetchingDbKey, setIsFetchingDbKey] = useState(false) + const [isFetchingImageKey, setIsFetchingImageKey] = useState(false) + const [isCheckingUpdate, setIsCheckingUpdate] = useState(false) + const [isDownloading, setIsDownloading] = useState(false) + const [downloadProgress, setDownloadProgress] = useState(0) + const [appVersion, setAppVersion] = useState('') + const [updateInfo, setUpdateInfo] = useState<{ hasUpdate: boolean; version?: string; releaseNotes?: string } | null>(null) + const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null) + const [showDecryptKey, setShowDecryptKey] = useState(false) + const [dbKeyStatus, setDbKeyStatus] = useState('') + const [imageKeyStatus, setImageKeyStatus] = useState('') + const [isManualStartPrompt, setIsManualStartPrompt] = useState(false) + + useEffect(() => { + loadConfig() + loadDefaultExportPath() + loadAppVersion() + }, []) + + useEffect(() => { + const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { + setDbKeyStatus(payload.message) + }) + const removeImage = window.electronAPI.key.onImageKeyStatus((payload) => { + setImageKeyStatus(payload.message) + }) + return () => { + removeDb?.() + removeImage?.() + } + }, []) + + const loadConfig = async () => { + try { + const savedKey = await configService.getDecryptKey() + const savedPath = await configService.getDbPath() + const savedWxid = await configService.getMyWxid() + const savedCachePath = await configService.getCachePath() + const savedExportPath = await configService.getExportPath() + const savedLogEnabled = await configService.getLogEnabled() + const savedImageXorKey = await configService.getImageXorKey() + const savedImageAesKey = await configService.getImageAesKey() + + if (savedKey) setDecryptKey(savedKey) + if (savedPath) setDbPath(savedPath) + if (savedWxid) setWxid(savedWxid) + if (savedCachePath) setCachePath(savedCachePath) + if (savedExportPath) setExportPath(savedExportPath) + if (savedImageXorKey != null) { + setImageXorKey(`0x${savedImageXorKey.toString(16).toUpperCase().padStart(2, '0')}`) + } + if (savedImageAesKey) setImageAesKey(savedImageAesKey) + setLogEnabled(savedLogEnabled) + } catch (e) { + console.error('加载配置失败:', e) + } + } + + const loadDefaultExportPath = async () => { + try { + const downloadsPath = await window.electronAPI.app.getDownloadsPath() + setDefaultExportPath(downloadsPath) + } catch (e) { + console.error('获取默认导出路径失败:', e) + } + } + + const loadAppVersion = async () => { + try { + const version = await window.electronAPI.app.getVersion() + setAppVersion(version) + } catch (e) { + console.error('获取版本号失败:', e) + } + } + + // 监听下载进度 + useEffect(() => { + const removeListener = window.electronAPI.app.onDownloadProgress?.((progress: number) => { + setDownloadProgress(progress) + }) + return () => removeListener?.() + }, []) + + const handleCheckUpdate = async () => { + setIsCheckingUpdate(true) + setUpdateInfo(null) + try { + const result = await window.electronAPI.app.checkForUpdates() + if (result.hasUpdate) { + setUpdateInfo(result) + showMessage(`发现新版本 ${result.version}`, true) + } else { + showMessage('当前已是最新版本', true) + } + } catch (e) { + showMessage(`检查更新失败: ${e}`, false) + } finally { + setIsCheckingUpdate(false) + } + } + + const handleUpdateNow = async () => { + setIsDownloading(true) + setDownloadProgress(0) + try { + showMessage('正在下载更新...', true) + await window.electronAPI.app.downloadAndInstall() + } catch (e) { + showMessage(`更新失败: ${e}`, false) + setIsDownloading(false) + } + } + + const showMessage = (text: string, success: boolean) => { + setMessage({ text, success }) + setTimeout(() => setMessage(null), 3000) + } + + const handleAutoDetectPath = async () => { + if (isDetectingPath) return + setIsDetectingPath(true) + try { + const result = await window.electronAPI.dbPath.autoDetect() + if (result.success && result.path) { + setDbPath(result.path) + await configService.setDbPath(result.path) + showMessage(`自动检测成功:${result.path}`, true) + + const wxids = await window.electronAPI.dbPath.scanWxids(result.path) + if (wxids.length === 1) { + setWxid(wxids[0].wxid) + await configService.setMyWxid(wxids[0].wxid) + showMessage(`已检测到账号:${wxids[0].wxid}`, true) + } else if (wxids.length > 1) { + showMessage(`检测到 ${wxids.length} 个账号,请手动选择`, true) + } + } else { + showMessage(result.error || '未能自动检测到数据库目录', false) + } + } catch (e) { + showMessage(`自动检测失败: ${e}`, false) + } finally { + setIsDetectingPath(false) + } + } + + const handleSelectDbPath = async () => { + try { + const result = await dialog.openFile({ title: '选择微信数据库根目录', properties: ['openDirectory'] }) + if (!result.canceled && result.filePaths.length > 0) { + setDbPath(result.filePaths[0]) + showMessage('已选择数据库目录', true) + } + } catch (e) { + showMessage('选择目录失败', false) + } + } + + const handleScanWxid = async (silent = false) => { + if (!dbPath) { + if (!silent) showMessage('请先选择数据库目录', false) + return + } + try { + const wxids = await window.electronAPI.dbPath.scanWxids(dbPath) + if (wxids.length === 1) { + setWxid(wxids[0].wxid) + await configService.setMyWxid(wxids[0].wxid) + if (!silent) showMessage(`已检测到账号:${wxids[0].wxid}`, true) + } else if (wxids.length > 1) { + if (!silent) showMessage(`检测到 ${wxids.length} 个账号,请手动选择`, true) + } else { + if (!silent) showMessage('未检测到账号目录,请检查路径', false) + } + } catch (e) { + if (!silent) showMessage(`扫描失败: ${e}`, false) + } + } + + const handleSelectCachePath = async () => { + try { + const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] }) + if (!result.canceled && result.filePaths.length > 0) { + setCachePath(result.filePaths[0]) + showMessage('已选择缓存目录', true) + } + } catch (e) { + showMessage('选择目录失败', false) + } + } + + const handleSelectExportPath = async () => { + try { + const result = await dialog.openFile({ title: '选择导出目录', properties: ['openDirectory'] }) + if (!result.canceled && result.filePaths.length > 0) { + setExportPath(result.filePaths[0]) + await configService.setExportPath(result.filePaths[0]) + showMessage('已设置导出目录', true) + } + } catch (e) { + showMessage('选择目录失败', false) + } + } + + const handleAutoGetDbKey = async () => { + if (isFetchingDbKey) return + setIsFetchingDbKey(true) + setIsManualStartPrompt(false) + setDbKeyStatus('正在连接微信进程...') + try { + const result = await window.electronAPI.key.autoGetDbKey() + if (result.success && result.key) { + setDecryptKey(result.key) + setDbKeyStatus('密钥获取成功') + showMessage('已自动获取解密密钥', true) + await handleScanWxid(true) + } else { + if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) { + setIsManualStartPrompt(true) + setDbKeyStatus('需要手动启动微信') + } else { + showMessage(result.error || '自动获取密钥失败', false) + } + } + } catch (e) { + showMessage(`自动获取密钥失败: ${e}`, false) + } finally { + setIsFetchingDbKey(false) + } + } + + const handleManualConfirm = async () => { + setIsManualStartPrompt(false) + handleAutoGetDbKey() + } + + const handleAutoGetImageKey = async () => { + if (isFetchingImageKey) return + if (!dbPath) { + showMessage('请先选择数据库目录', false) + return + } + setIsFetchingImageKey(true) + setImageKeyStatus('正在准备获取图片密钥...') + try { + const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath + const result = await window.electronAPI.key.autoGetImageKey(accountPath) + if (result.success && result.aesKey) { + if (typeof result.xorKey === 'number') { + setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`) + } + setImageAesKey(result.aesKey) + setImageKeyStatus('已获取图片密钥') + showMessage('已自动获取图片密钥', true) + } else { + showMessage(result.error || '自动获取图片密钥失败', false) + } + } catch (e) { + showMessage(`自动获取图片密钥失败: ${e}`, false) + } finally { + setIsFetchingImageKey(false) + } + } + + const handleResetExportPath = async () => { + try { + const downloadsPath = await window.electronAPI.app.getDownloadsPath() + setExportPath(downloadsPath) + await configService.setExportPath(downloadsPath) + showMessage('已恢复为下载目录', true) + } catch (e) { + showMessage('恢复默认失败', false) + } + } + + const handleTestConnection = async () => { + if (!dbPath) { showMessage('请先选择数据库目录', false); return } + if (!decryptKey) { showMessage('请先输入解密密钥', false); return } + if (decryptKey.length !== 64) { showMessage('密钥长度必须为64个字符', false); return } + if (!wxid) { showMessage('请先输入或扫描 wxid', false); return } + + setIsTesting(true) + try { + const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid) + if (result.success) { + showMessage('连接测试成功!数据库可正常访问', true) + } else { + showMessage(result.error || '连接测试失败', false) + } + } catch (e) { + showMessage(`连接测试失败: ${e}`, false) + } finally { + setIsTesting(false) + } + } + + const handleSaveConfig = async () => { + if (!decryptKey) { showMessage('请输入解密密钥', false); return } + if (decryptKey.length !== 64) { showMessage('密钥长度必须为64个字符', false); return } + if (!dbPath) { showMessage('请选择数据库目录', false); return } + if (!wxid) { showMessage('请输入 wxid', false); return } + + setIsLoadingState(true) + setLoading(true, '正在保存配置...') + + try { + await configService.setDecryptKey(decryptKey) + await configService.setDbPath(dbPath) + await configService.setMyWxid(wxid) + await configService.setCachePath(cachePath) + if (imageXorKey) { + const parsed = parseInt(imageXorKey.replace(/^0x/i, ''), 16) + if (!Number.isNaN(parsed)) { + await configService.setImageXorKey(parsed) + } + } else { + await configService.setImageXorKey(0) + } + if (imageAesKey) { + await configService.setImageAesKey(imageAesKey) + } else { + await configService.setImageAesKey('') + } + await configService.setOnboardingDone(true) + + showMessage('配置保存成功,正在测试连接...', true) + const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid) + + if (result.success) { + setDbConnected(true, dbPath) + showMessage('配置保存成功!数据库连接正常', true) + } else { + showMessage(result.error || '数据库连接失败,请检查配置', false) + } + } catch (e) { + showMessage(`保存配置失败: ${e}`, false) + } finally { + setIsLoadingState(false) + setLoading(false) + } + } + + const handleClearConfig = async () => { + const confirmed = window.confirm('确定要清除当前配置吗?清除后需要重新完成首次配置。') + if (!confirmed) return + setIsLoadingState(true) + setLoading(true, '正在清除配置...') + try { + await window.electronAPI.wcdb.close() + await configService.clearConfig() + reset() + setDecryptKey('') + setImageXorKey('') + setImageAesKey('') + setDbPath('') + setWxid('') + setCachePath('') + setExportPath('') + setLogEnabled(false) + setDbConnected(false) + await window.electronAPI.window.openOnboardingWindow() + } catch (e) { + showMessage(`清除配置失败: ${e}`, false) + } finally { + setIsLoadingState(false) + setLoading(false) + } + } + + const handleOpenLog = async () => { + try { + const logPath = await window.electronAPI.log.getPath() + await window.electronAPI.shell.openPath(logPath) + } catch (e) { + showMessage(`打开日志失败: ${e}`, false) + } + } + + const handleCopyLog = async () => { + try { + const result = await window.electronAPI.log.read() + if (!result.success) { + showMessage(result.error || '读取日志失败', false) + return + } + await navigator.clipboard.writeText(result.content || '') + showMessage('日志已复制到剪贴板', true) + } catch (e) { + showMessage(`复制日志失败: ${e}`, false) + } + } + + const renderAppearanceTab = () => ( +
+
+ + +
+
+ {themes.map((theme) => ( +
setTheme(theme.id)}> +
+
+
+
+ {theme.name} + {theme.description} +
+ {currentTheme === theme.id &&
} +
+ ))} +
+
+ ) + + const renderDatabaseTab = () => ( +
+
+ + 64位十六进制密钥 +
+ setDecryptKey(e.target.value)} /> + +
+ {isManualStartPrompt ? ( +
+

未能自动启动微信,请手动启动并登录后点击下方确认

+ +
+ ) : ( + + )} + {dbKeyStatus &&
{dbKeyStatus}
} +
+ +
+ + xwechat_files 目录 + setDbPath(e.target.value)} /> +
+ + +
+
+ +
+ + 微信账号标识 + setWxid(e.target.value)} /> + +
+ +
+ + 用于解密图片缓存 + setImageXorKey(e.target.value)} /> +
+ +
+ + 16 位密钥 + setImageAesKey(e.target.value)} /> + + {imageKeyStatus &&
{imageKeyStatus}
} +
+ +
+ + 留空使用默认目录 + setCachePath(e.target.value)} /> +
+ + +
+
+ +
+ + 开启后写入 WCDB 调试日志,便于排查连接问题 +
+ {logEnabled ? '已开启' : '已关闭'} + +
+
+ + +
+
+
+ ) + + const renderExportTab = () => ( +
+
+ + 聊天记录导出的默认保存位置 + setExportPath(e.target.value)} /> +
+ + +
+
+
+ ) + + const renderCacheTab = () => ( +
+

管理应用缓存数据

+
+ + + +
+
+

清除当前配置并重新开始首次引导

+
+ +
+
+ ) + + const renderAboutTab = () => ( +
+
+
+ WeFlow +
+

WeFlow

+

WeFlow

+

v{appVersion || '...'}

+ +
+ {updateInfo?.hasUpdate ? ( + <> +

新版本 v{updateInfo.version} 可用

+ {isDownloading ? ( +
+
+
+
+ {downloadProgress.toFixed(0)}% +
+ ) : ( + + )} + + ) : ( + + )} +
+
+ + +
+ ) + + return ( +
+ {message &&
{message.text}
} + +
+

设置

+
+ + +
+
+ +
+ {tabs.map(tab => ( + + ))} +
+ +
+ {activeTab === 'appearance' && renderAppearanceTab()} + {activeTab === 'database' && renderDatabaseTab()} + {activeTab === 'export' && renderExportTab()} + {activeTab === 'cache' && renderCacheTab()} + {activeTab === 'about' && renderAboutTab()} +
+
+ ) +} + +export default SettingsPage diff --git a/src/pages/WelcomePage.scss b/src/pages/WelcomePage.scss new file mode 100644 index 0000000..fd3f586 --- /dev/null +++ b/src/pages/WelcomePage.scss @@ -0,0 +1,493 @@ +.welcome-page { + min-height: 100vh; + background: radial-gradient(circle at top left, rgba(255, 255, 255, 0.6), transparent 55%), + radial-gradient(circle at 80% 20%, rgba(139, 115, 85, 0.18), transparent 45%), + var(--bg-gradient); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; +} + +.welcome-page.is-standalone { + width: 100%; + height: 100%; + border-radius: 22px; + padding: 20px; + -webkit-app-region: drag; +} + +.welcome-page.is-standalone .welcome-shell { + -webkit-app-region: no-drag; +} + +.welcome-page.is-standalone .window-controls { + position: absolute; + top: 18px; + right: 18px; + display: inline-flex; + gap: 8px; + padding: 6px; + border-radius: 999px; + background: rgba(25, 25, 25, 0.45); + border: 1px solid rgba(255, 255, 255, 0.08); + backdrop-filter: blur(10px); + z-index: 3; + -webkit-app-region: no-drag; +} + +.welcome-page.is-standalone .window-btn { + width: 28px; + height: 28px; + border-radius: 999px; + border: none; + display: grid; + place-items: center; + color: rgba(255, 255, 255, 0.85); + background: rgba(255, 255, 255, 0.08); + cursor: pointer; + transition: transform 0.18s ease, background 0.18s ease; +} + +.welcome-page.is-standalone .window-btn:hover { + transform: translateY(-1px); + background: rgba(255, 255, 255, 0.18); +} + +.welcome-page.is-standalone .window-btn.is-close:hover { + background: rgba(219, 92, 92, 0.35); +} + +.welcome-page.is-closing { + animation: fadeOut 0.45s ease forwards; +} + +.welcome-page::before, +.welcome-page::after { + content: ''; + position: absolute; + border-radius: 999px; + background: rgba(255, 255, 255, 0.3); + filter: blur(0px); + opacity: 0.5; + pointer-events: none; +} + +.welcome-page::before { + width: 320px; + height: 320px; + top: -120px; + right: 10%; + background: rgba(139, 115, 85, 0.15); +} + +.welcome-page::after { + width: 220px; + height: 220px; + bottom: -80px; + left: 12%; +} + +.welcome-shell { + width: min(980px, 92vw); + display: grid; + grid-template-columns: 0.95fr 1.05fr; + gap: 28px; + z-index: 1; + animation: fadeUp 0.6s ease-out; +} + +.welcome-panel, +.setup-card { + background: var(--card-bg); + border-radius: 24px; + box-shadow: var(--shadow-md); + border: 1px solid var(--border-color); + backdrop-filter: blur(16px); +} + +.welcome-panel { + padding: 28px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.panel-header { + display: flex; + gap: 16px; + align-items: center; +} + +.panel-logo { + width: 56px; + height: 56px; + border-radius: 16px; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08); +} + +.panel-kicker { + font-size: 12px; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--text-tertiary); + margin: 0 0 4px; +} + +.panel-subtitle { + font-size: 14px; + color: var(--text-secondary); + margin: 6px 0 0; +} + +.welcome-panel h1 { + margin: 0; + font-size: 24px; + color: var(--text-primary); +} + +.step-list { + display: flex; + flex-direction: column; + gap: 14px; +} + +.step-item { + display: flex; + gap: 12px; + align-items: center; + padding: 12px 14px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.55); + transition: transform 0.2s ease, background 0.2s ease; +} + +[data-mode="dark"] .step-item { + background: rgba(255, 255, 255, 0.06); +} + +.step-item.active { + background: var(--primary-light); + transform: translateX(4px); +} + +.step-item.done { + opacity: 0.85; +} + +.step-index { + width: 28px; + height: 28px; + border-radius: 10px; + display: grid; + place-items: center; + background: var(--primary-gradient); + color: #fff; + font-size: 12px; + font-weight: 600; +} + +.step-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.step-desc { + font-size: 12px; + color: var(--text-tertiary); +} + +.panel-foot { + display: flex; + align-items: center; + gap: 10px; + font-size: 12px; + color: var(--text-tertiary); + padding-top: 8px; + border-top: 1px dashed var(--border-color); +} + +.setup-card { + padding: 28px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.setup-header { + display: flex; + gap: 14px; + align-items: center; +} + +.setup-header h2 { + margin: 0; + font-size: 22px; + color: var(--text-primary); +} + +.setup-header p { + margin: 6px 0 0; + color: var(--text-secondary); + font-size: 13px; +} + +.setup-icon { + width: 44px; + height: 44px; + border-radius: 16px; + display: grid; + place-items: center; + background: var(--primary-light); + color: var(--primary); +} + +.setup-body { + display: flex; + flex-direction: column; + gap: 12px; +} + +.intro-card { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 16px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.6); + color: var(--text-secondary); +} + +[data-mode="dark"] .intro-card { + background: rgba(255, 255, 255, 0.06); +} + +.intro-card h3 { + margin: 0 0 4px; + font-size: 16px; + color: var(--text-primary); +} + +.intro-card p { + margin: 0; + font-size: 13px; +} + +.field-label { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.field-input { + width: 100%; + padding: 12px 16px; + border-radius: 14px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 14px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.field-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-light); +} + +.field-hint { + font-size: 12px; + color: var(--text-tertiary); +} + +.status-text { + color: var(--text-secondary); +} + +.wxid-options { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 2px; +} + +.wxid-option { + border: 1px solid var(--border-color); + background: var(--bg-tertiary); + border-radius: 14px; + padding: 10px 14px; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + width: 100%; + min-height: 44px; + cursor: pointer; + transition: transform 0.18s ease, border-color 0.2s ease, box-shadow 0.2s ease; + text-align: left; +} + +.wxid-option:hover { + transform: translateY(-1px); + border-color: rgba(139, 115, 85, 0.4); + box-shadow: 0 8px 16px rgba(15, 15, 15, 0.08); +} + +.wxid-option.is-selected { + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-light); +} + +.wxid-option-name { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.wxid-option-time { + font-size: 11px; + color: var(--text-tertiary); + align-self: flex-end; + text-align: right; + white-space: nowrap; +} + +.field-with-toggle { + position: relative; +} + +.toggle-btn { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; +} + +.button-row { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.welcome-page .btn { + padding: 10px 18px; + border-radius: 999px; + border: none; + cursor: pointer; + font-size: 13px; + font-weight: 600; + display: inline-flex; + gap: 8px; + align-items: center; + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.welcome-page .btn:disabled { + opacity: 0.6; + cursor: not-allowed; + box-shadow: none; +} + +.welcome-page .btn-primary { + color: #fff; + background: var(--primary-gradient); + box-shadow: 0 10px 18px rgba(139, 115, 85, 0.25); +} + +.welcome-page .btn-primary:hover:not(:disabled) { + transform: translateY(-1px); +} + +.welcome-page .btn-secondary { + color: var(--text-primary); + background: var(--bg-tertiary); +} + +.welcome-page .btn-tertiary { + color: var(--text-secondary); + background: transparent; + border: 1px solid var(--border-color); +} + +.welcome-page .btn-inline { + align-self: flex-start; +} + +.welcome-page .btn-full { + width: 100%; + justify-content: center; +} + +.setup-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-top: 8px; +} + +.error-message { + background: rgba(250, 81, 81, 0.1); + color: var(--danger); + padding: 10px 14px; + border-radius: 12px; + font-size: 13px; +} + +.manual-prompt { + background: rgba(139, 115, 85, 0.1); + border: 1px dashed rgba(139, 115, 85, 0.3); + padding: 16px; + border-radius: 16px; + display: flex; + flex-direction: column; + gap: 12px; + margin: 4px 0; + + .prompt-text { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + margin: 0; + } + + .btn { + width: 100%; + justify-content: center; + } +} + +@media (max-width: 900px) { + .welcome-shell { + grid-template-columns: 1fr; + } +} + +@keyframes fadeUp { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeOut { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.98); + } +} diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx new file mode 100644 index 0000000..d7a08de --- /dev/null +++ b/src/pages/WelcomePage.tsx @@ -0,0 +1,561 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAppStore } from '../stores/appStore' +import { dialog } from '../services/ipc' +import * as configService from '../services/config' +import { + ArrowLeft, ArrowRight, CheckCircle2, Database, Eye, EyeOff, + FolderOpen, FolderSearch, KeyRound, ShieldCheck, Sparkles, + UserRound, Wand2, Minus, X, HardDrive, RotateCcw +} from 'lucide-react' +import './WelcomePage.scss' + +const steps = [ + { id: 'intro', title: '欢迎', desc: '准备开始你的本地数据探索' }, + { id: 'db', title: '数据库目录', desc: '定位 xwechat_files 目录' }, + { id: 'cache', title: '缓存目录', desc: '设置本地缓存存储位置(可选)' }, + { id: 'key', title: '解密密钥', desc: '获取密钥与自动识别账号' }, + { id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' } +] + +interface WelcomePageProps { + standalone?: boolean +} + +function WelcomePage({ standalone = false }: WelcomePageProps) { + const navigate = useNavigate() + const { isDbConnected, setDbConnected, setLoading } = useAppStore() + + const [stepIndex, setStepIndex] = useState(0) + const [dbPath, setDbPath] = useState('') + const [decryptKey, setDecryptKey] = useState('') + const [imageXorKey, setImageXorKey] = useState('') + const [imageAesKey, setImageAesKey] = useState('') + const [cachePath, setCachePath] = useState('') + const [wxid, setWxid] = useState('') + const [wxidOptions, setWxidOptions] = useState>([]) + const [error, setError] = useState('') + const [isConnecting, setIsConnecting] = useState(false) + const [isDetectingPath, setIsDetectingPath] = useState(false) + const [isScanningWxid, setIsScanningWxid] = useState(false) + const [isFetchingDbKey, setIsFetchingDbKey] = useState(false) + const [isFetchingImageKey, setIsFetchingImageKey] = useState(false) + const [showDecryptKey, setShowDecryptKey] = useState(false) + const [isClosing, setIsClosing] = useState(false) + const [dbKeyStatus, setDbKeyStatus] = useState('') + const [imageKeyStatus, setImageKeyStatus] = useState('') + const [isManualStartPrompt, setIsManualStartPrompt] = useState(false) + + useEffect(() => { + const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { + setDbKeyStatus(payload.message) + }) + const removeImage = window.electronAPI.key.onImageKeyStatus((payload) => { + setImageKeyStatus(payload.message) + }) + return () => { + removeDb?.() + removeImage?.() + } + }, []) + + useEffect(() => { + if (isDbConnected && !standalone) { + navigate('/home') + } + }, [isDbConnected, standalone, navigate]) + + useEffect(() => { + setWxidOptions([]) + setWxid('') + }, [dbPath]) + + const currentStep = steps[stepIndex] + const rootClassName = `welcome-page${isClosing ? ' is-closing' : ''}${standalone ? ' is-standalone' : ''}` + const showWindowControls = standalone + + const handleMinimize = () => { + window.electronAPI.window.minimize() + } + + const handleCloseWindow = () => { + window.electronAPI.window.close() + } + + const handleSelectPath = async () => { + try { + const result = await dialog.openFile({ + title: '选择微信数据库目录', + properties: ['openDirectory'] + }) + + if (!result.canceled && result.filePaths.length > 0) { + setDbPath(result.filePaths[0]) + setError('') + } + } catch (e) { + setError('选择目录失败') + } + } + + const handleAutoDetectPath = async () => { + if (isDetectingPath) return + setIsDetectingPath(true) + setError('') + try { + const result = await window.electronAPI.dbPath.autoDetect() + if (result.success && result.path) { + setDbPath(result.path) + setError('') + } else { + setError(result.error || '未能检测到数据库目录') + } + } catch (e) { + setError(`自动检测失败: ${e}`) + } finally { + setIsDetectingPath(false) + } + } + + const handleSelectCachePath = async () => { + try { + const result = await dialog.openFile({ + title: '选择缓存目录', + properties: ['openDirectory'] + }) + + if (!result.canceled && result.filePaths.length > 0) { + setCachePath(result.filePaths[0]) + setError('') + } + } catch (e) { + setError('选择缓存目录失败') + } + } + + const handleScanWxid = async (silent = false) => { + if (!dbPath) { + if (!silent) setError('请先选择数据库目录') + return + } + if (isScanningWxid) return + setIsScanningWxid(true) + if (!silent) setError('') + try { + const wxids = await window.electronAPI.dbPath.scanWxids(dbPath) + setWxidOptions(wxids) + if (wxids.length > 0) { + // scanWxids 已经按时间排过序了,直接取第一个 + setWxid(wxids[0].wxid) + if (!silent) setError('') + } else { + if (!silent) setError('未检测到账号目录,请检查路径') + } + } catch (e) { + if (!silent) setError(`扫描失败: ${e}`) + } finally { + setIsScanningWxid(false) + } + } + + const handleAutoGetDbKey = async () => { + if (isFetchingDbKey) return + setIsFetchingDbKey(true) + setError('') + setIsManualStartPrompt(false) + setDbKeyStatus('正在连接微信进程...') + try { + const result = await window.electronAPI.key.autoGetDbKey() + if (result.success && result.key) { + setDecryptKey(result.key) + setDbKeyStatus('密钥获取成功') + setError('') + // 获取成功后自动扫描并填入 wxid + await handleScanWxid(true) + } else { + if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) { + setIsManualStartPrompt(true) + setDbKeyStatus('需要手动启动微信') + } else { + setError(result.error || '自动获取密钥失败') + } + } + } catch (e) { + setError(`自动获取密钥失败: ${e}`) + } finally { + setIsFetchingDbKey(false) + } + } + + const handleManualConfirm = async () => { + setIsManualStartPrompt(false) + handleAutoGetDbKey() + } + + const handleAutoGetImageKey = async () => { + if (isFetchingImageKey) return + if (!dbPath) { + setError('请先选择数据库目录') + return + } + setIsFetchingImageKey(true) + setError('') + setImageKeyStatus('正在准备获取图片密钥...') + try { + // 拼接完整的账号目录,确保 KeyService 能准确找到模板文件 + const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath + const result = await window.electronAPI.key.autoGetImageKey(accountPath) + if (result.success && result.aesKey) { + if (typeof result.xorKey === 'number') { + setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`) + } + setImageAesKey(result.aesKey) + setImageKeyStatus('已获取图片密钥') + } else { + setError(result.error || '自动获取图片密钥失败') + } + } catch (e) { + setError(`自动获取图片密钥失败: ${e}`) + } finally { + setIsFetchingImageKey(false) + } + } + + const canGoNext = () => { + if (currentStep.id === 'intro') return true + if (currentStep.id === 'db') return Boolean(dbPath) + if (currentStep.id === 'cache') return true + if (currentStep.id === 'key') return decryptKey.length === 64 && Boolean(wxid) + if (currentStep.id === 'image') return true + return false + } + + const handleNext = () => { + if (!canGoNext()) { + if (currentStep.id === 'db' && !dbPath) setError('请先选择数据库目录') + if (currentStep.id === 'key') { + if (decryptKey.length !== 64) setError('密钥长度必须为 64 个字符') + else if (!wxid) setError('未能自动识别 wxid,请尝试重新获取或检查目录') + } + return + } + setError('') + setStepIndex((prev) => Math.min(prev + 1, steps.length - 1)) + } + + const handleBack = () => { + setError('') + setStepIndex((prev) => Math.max(prev - 1, 0)) + } + + const handleConnect = async () => { + if (!dbPath) { setError('请先选择数据库目录'); return } + if (!wxid) { setError('请填写微信ID'); return } + if (!decryptKey || decryptKey.length !== 64) { setError('请填写 64 位解密密钥'); return } + + setIsConnecting(true) + setError('') + setLoading(true, '正在连接数据库...') + + try { + const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid) + if (!result.success) { + setError(result.error || 'WCDB 连接失败') + setLoading(false) + return + } + + await configService.setDbPath(dbPath) + await configService.setDecryptKey(decryptKey) + await configService.setMyWxid(wxid) + await configService.setCachePath(cachePath) + if (imageXorKey) { + const parsed = parseInt(imageXorKey.replace(/^0x/i, ''), 16) + if (!Number.isNaN(parsed)) { + await configService.setImageXorKey(parsed) + } + } + if (imageAesKey) { + await configService.setImageAesKey(imageAesKey) + } + await configService.setOnboardingDone(true) + + setDbConnected(true, dbPath) + setLoading(false) + + if (standalone) { + setIsClosing(true) + setTimeout(() => { + window.electronAPI.window.completeOnboarding() + }, 450) + } else { + navigate('/home') + } + } catch (e) { + setError(`连接失败: ${e}`) + setLoading(false) + } finally { + setIsConnecting(false) + } + } + + const formatModifiedTime = (time: number) => { + if (!time) return '未知时间' + const date = new Date(time) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}` + } + + if (isDbConnected) { + return ( +
+ {showWindowControls && ( +
+ + +
+ )} +
+
+
+ WeFlow +
+

WeFlow

+

已连接数据库

+
+
+
+ + 配置已完成,可直接进入首页 +
+ +
+
+
+ ) + } + + return ( +
+ {showWindowControls && ( +
+ + +
+ )} +
+
+
+ WeFlow +
+

首次配置

+

WeFlow 初始引导

+

一步一步完成数据库与密钥设置

+
+
+
+ {steps.map((step, index) => ( +
+
{index < stepIndex ? : index + 1}
+
+
{step.title}
+
{step.desc}
+
+
+ ))} +
+
+ + 数据仅在本地处理,不上传服务器 +
+
+ +
+
+
+ {currentStep.id === 'intro' && } + {currentStep.id === 'db' && } + {currentStep.id === 'cache' && } + {currentStep.id === 'key' && } + {currentStep.id === 'image' && } +
+
+

{currentStep.title}

+

{currentStep.desc}

+
+
+ + {currentStep.id === 'intro' && ( +
+
+ +
+

准备好了吗?

+

接下来只需配置数据库目录和获取解密密钥。

+
+
+
+ )} + + {currentStep.id === 'db' && ( +
+ + setDbPath(e.target.value)} + /> +
+ + +
+
建议选择包含 xwechat_files 的目录
+
+ )} + + {currentStep.id === 'cache' && ( +
+ + setCachePath(e.target.value)} + /> +
+ + +
+
用于头像、表情与图片缓存,留空使用默认目录
+
+ )} + + {currentStep.id === 'key' && ( +
+ + setWxid(e.target.value)} + /> + +
+ setDecryptKey(e.target.value.trim())} + /> + +
+ + {isManualStartPrompt ? ( +
+

未能自动启动微信,请手动启动并登录后点击下方确认

+ +
+ ) : ( + + )} + + {dbKeyStatus &&
{dbKeyStatus}
} +
获取密钥会自动识别最近登录的账号
+
+ )} + + {currentStep.id === 'image' && ( +
+ + setImageXorKey(e.target.value)} + /> + + setImageAesKey(e.target.value)} + /> + + {imageKeyStatus &&
{imageKeyStatus}
} +
如获取失败,请先打开朋友圈图片再重试
+
+ )} + + {error &&
{error}
} + +
+ + {stepIndex < steps.length - 1 ? ( + + ) : ( + + )} +
+
+
+
+ ) +} + +export default WelcomePage + diff --git a/src/services/config.ts b/src/services/config.ts new file mode 100644 index 0000000..19b6a1d --- /dev/null +++ b/src/services/config.ts @@ -0,0 +1,172 @@ +// 配置服务 - 封装 Electron Store +import { config } from './ipc' + +// 配置键名 +export const CONFIG_KEYS = { + DECRYPT_KEY: 'decryptKey', + DB_PATH: 'dbPath', + MY_WXID: 'myWxid', + THEME: 'theme', + THEME_ID: 'themeId', + LAST_SESSION: 'lastSession', + WINDOW_BOUNDS: 'windowBounds', + CACHE_PATH: 'cachePath', + EXPORT_PATH: 'exportPath', + AGREEMENT_ACCEPTED: 'agreementAccepted', + LOG_ENABLED: 'logEnabled', + ONBOARDING_DONE: 'onboardingDone', + IMAGE_XOR_KEY: 'imageXorKey', + IMAGE_AES_KEY: 'imageAesKey' +} as const + +// 获取解密密钥 +export async function getDecryptKey(): Promise { + const value = await config.get(CONFIG_KEYS.DECRYPT_KEY) + return value as string | null +} + +// 设置解密密钥 +export async function setDecryptKey(key: string): Promise { + await config.set(CONFIG_KEYS.DECRYPT_KEY, key) +} + +// 获取数据库路径 +export async function getDbPath(): Promise { + const value = await config.get(CONFIG_KEYS.DB_PATH) + return value as string | null +} + +// 设置数据库路径 +export async function setDbPath(path: string): Promise { + await config.set(CONFIG_KEYS.DB_PATH, path) +} + +// 获取当前用户 wxid +export async function getMyWxid(): Promise { + const value = await config.get(CONFIG_KEYS.MY_WXID) + return value as string | null +} + +// 设置当前用户 wxid +export async function setMyWxid(wxid: string): Promise { + await config.set(CONFIG_KEYS.MY_WXID, wxid) +} + +// 获取主题 +export async function getTheme(): Promise<'light' | 'dark'> { + const value = await config.get(CONFIG_KEYS.THEME) + return (value as 'light' | 'dark') || 'light' +} + +// 设置主题 +export async function setTheme(theme: 'light' | 'dark'): Promise { + await config.set(CONFIG_KEYS.THEME, theme) +} + +// 获取主题配色 +export async function getThemeId(): Promise { + const value = await config.get(CONFIG_KEYS.THEME_ID) + return (value as string) || null +} + +// 设置主题配色 +export async function setThemeId(themeId: string): Promise { + await config.set(CONFIG_KEYS.THEME_ID, themeId) +} + +// 获取上次打开的会话 +export async function getLastSession(): Promise { + const value = await config.get(CONFIG_KEYS.LAST_SESSION) + return value as string | null +} + +// 设置上次打开的会话 +export async function setLastSession(sessionId: string): Promise { + await config.set(CONFIG_KEYS.LAST_SESSION, sessionId) +} + + +// 获取缓存路径 +export async function getCachePath(): Promise { + const value = await config.get(CONFIG_KEYS.CACHE_PATH) + return value as string | null +} + +// 设置缓存路径 +export async function setCachePath(path: string): Promise { + await config.set(CONFIG_KEYS.CACHE_PATH, path) +} + + +// 获取导出路径 +export async function getExportPath(): Promise { + const value = await config.get(CONFIG_KEYS.EXPORT_PATH) + return value as string | null +} + +// 设置导出路径 +export async function setExportPath(path: string): Promise { + await config.set(CONFIG_KEYS.EXPORT_PATH, path) +} + + +// 获取协议同意状态 +export async function getAgreementAccepted(): Promise { + const value = await config.get(CONFIG_KEYS.AGREEMENT_ACCEPTED) + return value === true +} + +// 设置协议同意状态 +export async function setAgreementAccepted(accepted: boolean): Promise { + await config.set(CONFIG_KEYS.AGREEMENT_ACCEPTED, accepted) +} + +// 获取日志开关 +export async function getLogEnabled(): Promise { + const value = await config.get(CONFIG_KEYS.LOG_ENABLED) + return value === true +} + +// 设置日志开关 +export async function setLogEnabled(enabled: boolean): Promise { + await config.set(CONFIG_KEYS.LOG_ENABLED, enabled) +} + +// 清除所有配置 +export async function clearConfig(): Promise { + await config.clear() +} + +// 获取图片 XOR 密钥 +export async function getImageXorKey(): Promise { + const value = await config.get(CONFIG_KEYS.IMAGE_XOR_KEY) + if (typeof value === 'number' && Number.isFinite(value)) return value + return null +} + +// 设置图片 XOR 密钥 +export async function setImageXorKey(key: number): Promise { + await config.set(CONFIG_KEYS.IMAGE_XOR_KEY, key) +} + +// 获取图片 AES 密钥 +export async function getImageAesKey(): Promise { + const value = await config.get(CONFIG_KEYS.IMAGE_AES_KEY) + return (value as string) || null +} + +// 设置图片 AES 密钥 +export async function setImageAesKey(key: string): Promise { + await config.set(CONFIG_KEYS.IMAGE_AES_KEY, key) +} + +// 获取是否完成首次配置引导 +export async function getOnboardingDone(): Promise { + const value = await config.get(CONFIG_KEYS.ONBOARDING_DONE) + return value === true +} + +// 设置首次配置引导完成 +export async function setOnboardingDone(done: boolean): Promise { + await config.set(CONFIG_KEYS.ONBOARDING_DONE, done) +} diff --git a/src/services/ipc.ts b/src/services/ipc.ts new file mode 100644 index 0000000..76ab511 --- /dev/null +++ b/src/services/ipc.ts @@ -0,0 +1,23 @@ +// Electron IPC 通信封装 + +// 配置 +export const config = { + get: (key: string) => window.electronAPI.config.get(key), + set: (key: string, value: unknown) => window.electronAPI.config.set(key, value), + clear: () => window.electronAPI.config.clear() +} + +// 对话框 +export const dialog = { + openFile: (options?: Electron.OpenDialogOptions) => + window.electronAPI.dialog.openFile(options), + saveFile: (options?: Electron.SaveDialogOptions) => + window.electronAPI.dialog.saveFile(options) +} + +// 窗口控制 +export const windowControl = { + minimize: () => window.electronAPI.window.minimize(), + maximize: () => window.electronAPI.window.maximize(), + close: () => window.electronAPI.window.close() +} diff --git a/src/stores/analyticsStore.ts b/src/stores/analyticsStore.ts new file mode 100644 index 0000000..98ae4a1 --- /dev/null +++ b/src/stores/analyticsStore.ts @@ -0,0 +1,70 @@ +import { create } from 'zustand' + +interface ChatStatistics { + totalMessages: number + textMessages: number + imageMessages: number + voiceMessages: number + videoMessages: number + emojiMessages: number + otherMessages: number + sentMessages: number + receivedMessages: number + firstMessageTime: number | null + lastMessageTime: number | null + activeDays: number + messageTypeCounts: Record +} + +interface ContactRanking { + username: string + displayName: string + avatarUrl?: string + messageCount: number + sentCount: number + receivedCount: number + lastMessageTime: number | null +} + +interface TimeDistribution { + hourlyDistribution: Record + monthlyDistribution: Record +} + +interface AnalyticsState { + // 数据 + statistics: ChatStatistics | null + rankings: ContactRanking[] + timeDistribution: TimeDistribution | null + + // 状态 + isLoaded: boolean + lastLoadTime: number | null + + // Actions + setStatistics: (data: ChatStatistics) => void + setRankings: (data: ContactRanking[]) => void + setTimeDistribution: (data: TimeDistribution) => void + markLoaded: () => void + clearCache: () => void +} + +export const useAnalyticsStore = create((set) => ({ + statistics: null, + rankings: [], + timeDistribution: null, + isLoaded: false, + lastLoadTime: null, + + setStatistics: (data) => set({ statistics: data }), + setRankings: (data) => set({ rankings: data }), + setTimeDistribution: (data) => set({ timeDistribution: data }), + markLoaded: () => set({ isLoaded: true, lastLoadTime: Date.now() }), + clearCache: () => set({ + statistics: null, + rankings: [], + timeDistribution: null, + isLoaded: false, + lastLoadTime: null + }), +})) diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts new file mode 100644 index 0000000..f479f9e --- /dev/null +++ b/src/stores/appStore.ts @@ -0,0 +1,46 @@ +import { create } from 'zustand' + +export interface AppState { + // 数据库状态 + isDbConnected: boolean + dbPath: string | null + myWxid: string | null + + // 加载状态 + isLoading: boolean + loadingText: string + + // 操作 + setDbConnected: (connected: boolean, path?: string) => void + setMyWxid: (wxid: string) => void + setLoading: (loading: boolean, text?: string) => void + reset: () => void +} + +export const useAppStore = create((set) => ({ + isDbConnected: false, + dbPath: null, + myWxid: null, + isLoading: false, + loadingText: '', + + setDbConnected: (connected, path) => set({ + isDbConnected: connected, + dbPath: path ?? null + }), + + setMyWxid: (wxid) => set({ myWxid: wxid }), + + setLoading: (loading, text) => set({ + isLoading: loading, + loadingText: text ?? '' + }), + + reset: () => set({ + isDbConnected: false, + dbPath: null, + myWxid: null, + isLoading: false, + loadingText: '' + }) +})) diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts new file mode 100644 index 0000000..c296059 --- /dev/null +++ b/src/stores/chatStore.ts @@ -0,0 +1,116 @@ +import { create } from 'zustand' +import type { ChatSession, Message, Contact } from '../types/models' + +export interface ChatState { + // 连接状态 + isConnected: boolean + isConnecting: boolean + connectionError: string | null + + // 会话列表 + sessions: ChatSession[] + filteredSessions: ChatSession[] + currentSessionId: string | null + isLoadingSessions: boolean + + // 消息 + messages: Message[] + isLoadingMessages: boolean + isLoadingMore: boolean + hasMoreMessages: boolean + + // 联系人缓存 + contacts: Map + + // 搜索 + searchKeyword: string + + // 操作 + setConnected: (connected: boolean) => void + setConnecting: (connecting: boolean) => void + setConnectionError: (error: string | null) => void + setSessions: (sessions: ChatSession[]) => void + setFilteredSessions: (sessions: ChatSession[]) => void + setCurrentSession: (sessionId: string | null) => void + setLoadingSessions: (loading: boolean) => void + setMessages: (messages: Message[]) => void + appendMessages: (messages: Message[], prepend?: boolean) => void + setLoadingMessages: (loading: boolean) => void + setLoadingMore: (loading: boolean) => void + setHasMoreMessages: (hasMore: boolean) => void + setContacts: (contacts: Contact[]) => void + addContact: (contact: Contact) => void + setSearchKeyword: (keyword: string) => void + reset: () => void +} + +export const useChatStore = create((set, get) => ({ + isConnected: false, + isConnecting: false, + connectionError: null, + sessions: [], + filteredSessions: [], + currentSessionId: null, + isLoadingSessions: false, + messages: [], + isLoadingMessages: false, + isLoadingMore: false, + hasMoreMessages: true, + contacts: new Map(), + searchKeyword: '', + + setConnected: (connected) => set({ isConnected: connected }), + setConnecting: (connecting) => set({ isConnecting: connecting }), + setConnectionError: (error) => set({ connectionError: error }), + + setSessions: (sessions) => set({ sessions, filteredSessions: sessions }), + setFilteredSessions: (sessions) => set({ filteredSessions: sessions }), + + setCurrentSession: (sessionId) => set({ + currentSessionId: sessionId, + messages: [], + hasMoreMessages: true + }), + + setLoadingSessions: (loading) => set({ isLoadingSessions: loading }), + + setMessages: (messages) => set({ messages }), + + appendMessages: (newMessages, prepend = false) => set((state) => ({ + messages: prepend + ? [...newMessages, ...state.messages] + : [...state.messages, ...newMessages] + })), + + setLoadingMessages: (loading) => set({ isLoadingMessages: loading }), + setLoadingMore: (loading) => set({ isLoadingMore: loading }), + setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }), + + setContacts: (contacts) => set({ + contacts: new Map(contacts.map(c => [c.username, c])) + }), + + addContact: (contact) => set((state) => { + const newContacts = new Map(state.contacts) + newContacts.set(contact.username, contact) + return { contacts: newContacts } + }), + + setSearchKeyword: (keyword) => set({ searchKeyword: keyword }), + + reset: () => set({ + isConnected: false, + isConnecting: false, + connectionError: null, + sessions: [], + filteredSessions: [], + currentSessionId: null, + isLoadingSessions: false, + messages: [], + isLoadingMessages: false, + isLoadingMore: false, + hasMoreMessages: true, + contacts: new Map(), + searchKeyword: '' + }) +})) diff --git a/src/stores/imageStore.ts b/src/stores/imageStore.ts new file mode 100644 index 0000000..6266b82 --- /dev/null +++ b/src/stores/imageStore.ts @@ -0,0 +1,173 @@ +import { create } from 'zustand' + +export interface ImageFileInfo { + fileName: string + filePath: string + fileSize: number + isDecrypted: boolean + decryptedPath?: string + version: number + isDecrypting?: boolean +} + +export interface ImageDirectory { + wxid: string + path: string +} + +/** + * 检测图片质量(原图/缩略图) + * 逻辑来自原项目 app_state.dart 的 _detectImageQuality + */ +function detectImageQuality(img: ImageFileInfo): 'original' | 'thumbnail' { + const fileNameLower = img.fileName.toLowerCase() + const fileSize = img.fileSize + + // 小于 50KB 是缩略图 + if (fileSize < 50 * 1024) return 'thumbnail' + // 大于 500KB 是原图 + if (fileSize > 500 * 1024) return 'original' + + // 文件名包含 thumb/small 关键词 + if (fileNameLower.includes('thumb') || fileNameLower.includes('small')) { + return 'thumbnail' + } + + // 文件名以 _thumb.dat 或 _small.dat 结尾 + if (fileNameLower.endsWith('_thumb.dat') || fileNameLower.endsWith('_small.dat')) { + return 'thumbnail' + } + + // 路径层级判断(通过 filePath 中的分隔符数量) + const pathParts = img.filePath.split(/[/\\]/) + // 找到账号目录后的相对路径层级 + // 如果层级太深,可能是缩略图 + if (pathParts.length > 10) return 'thumbnail' + + return 'original' +} + +interface ImageState { + // 图片列表 + images: ImageFileInfo[] + // 目录列表 + directories: ImageDirectory[] + // 当前选中的目录 + selectedDir: ImageDirectory | null + // 扫描状态 + isScanning: boolean + scanCompleted: boolean + // 错误信息 + error: string | null + + // 统计 + originalCount: number + thumbnailCount: number + decryptedCount: number + + // 操作 + setDirectories: (dirs: ImageDirectory[]) => void + setSelectedDir: (dir: ImageDirectory | null) => void + setScanning: (scanning: boolean) => void + setScanCompleted: (completed: boolean) => void + setError: (error: string | null) => void + addImages: (newImages: ImageFileInfo[]) => void + clearImages: () => void + updateImage: (index: number, updates: Partial) => void + updateStats: () => void + reset: () => void +} + +export const useImageStore = create((set, get) => ({ + images: [], + directories: [], + selectedDir: null, + isScanning: false, + scanCompleted: false, + error: null, + originalCount: 0, + thumbnailCount: 0, + decryptedCount: 0, + + setDirectories: (dirs) => set({ directories: dirs }), + + setSelectedDir: (dir) => set({ selectedDir: dir }), + + setScanning: (scanning) => set({ isScanning: scanning }), + + setScanCompleted: (completed) => set({ scanCompleted: completed }), + + setError: (error) => set({ error }), + + addImages: (newImages) => { + set((state) => { + const updated = [...state.images, ...newImages] + // 计算统计 + let original = 0 + let thumbnail = 0 + let decrypted = 0 + for (const img of updated) { + if (detectImageQuality(img) === 'original') { + original++ + } else { + thumbnail++ + } + if (img.isDecrypted) decrypted++ + } + return { + images: updated, + originalCount: original, + thumbnailCount: thumbnail, + decryptedCount: decrypted + } + }) + }, + + clearImages: () => set({ + images: [], + originalCount: 0, + thumbnailCount: 0, + decryptedCount: 0, + scanCompleted: false + }), + + updateImage: (index, updates) => { + set((state) => { + const images = [...state.images] + if (index >= 0 && index < images.length) { + images[index] = { ...images[index], ...updates } + } + // 重新计算已解密数量 + const decryptedCount = images.filter(img => img.isDecrypted).length + return { images, decryptedCount } + }) + }, + + updateStats: () => { + const { images } = get() + let original = 0 + let thumbnail = 0 + let decrypted = 0 + for (const img of images) { + if (detectImageQuality(img) === 'original') { + original++ + } else { + thumbnail++ + } + if (img.isDecrypted) decrypted++ + } + set({ originalCount: original, thumbnailCount: thumbnail, decryptedCount: decrypted }) + }, + + reset: () => set({ + images: [], + directories: [], + selectedDir: null, + isScanning: false, + scanCompleted: false, + error: null, + originalCount: 0, + thumbnailCount: 0, + decryptedCount: 0 + }) +})) diff --git a/src/stores/themeStore.ts b/src/stores/themeStore.ts new file mode 100644 index 0000000..55e0947 --- /dev/null +++ b/src/stores/themeStore.ts @@ -0,0 +1,79 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water' +export type ThemeMode = 'light' | 'dark' + +export interface ThemeInfo { + id: ThemeId + name: string + description: string + primaryColor: string + bgColor: string +} + +export const themes: ThemeInfo[] = [ + { + id: 'cloud-dancer', + name: '云上舞白', + description: 'Pantone 2026 年度色', + primaryColor: '#8B7355', + bgColor: '#F0EEE9' + }, + { + id: 'corundum-blue', + name: '刚玉蓝', + description: 'RAL 220 40 10', + primaryColor: '#4A6670', + bgColor: '#E8EEF0' + }, + { + id: 'kiwi-green', + name: '冰猕猴桃汁绿', + description: 'RAL 120 90 20', + primaryColor: '#7A9A5C', + bgColor: '#E8F0E4' + }, + { + id: 'spicy-red', + name: '辛辣红', + description: 'RAL 030 40 40', + primaryColor: '#8B4049', + bgColor: '#F0E8E8' + }, + { + id: 'teal-water', + name: '明水鸭色', + description: 'RAL 180 80 10', + primaryColor: '#5A8A8A', + bgColor: '#E4F0F0' + } +] + +interface ThemeState { + currentTheme: ThemeId + themeMode: ThemeMode + setTheme: (theme: ThemeId) => void + setThemeMode: (mode: ThemeMode) => void + toggleThemeMode: () => void +} + +export const useThemeStore = create()( + persist( + (set, get) => ({ + currentTheme: 'cloud-dancer', + themeMode: 'light', + setTheme: (theme) => set({ currentTheme: theme }), + setThemeMode: (mode) => set({ themeMode: mode }), + toggleThemeMode: () => set({ themeMode: get().themeMode === 'light' ? 'dark' : 'light' }) + }), + { + name: 'echotrace-theme' + } + ) +) + +// 获取当前主题信息 +export const getThemeInfo = (themeId: ThemeId): ThemeInfo => { + return themes.find(t => t.id === themeId) || themes[0] +} diff --git a/src/styles/chat-patterns.scss b/src/styles/chat-patterns.scss new file mode 100644 index 0000000..faf6bb8 --- /dev/null +++ b/src/styles/chat-patterns.scss @@ -0,0 +1,18 @@ +// 聊天背景涂鸦图案 +// 使用 SVG data URI 实现 Telegram 风格的涂鸦背景 +// 更大的画布 + 随机分布 + 不同大小和角度 + +// 浅色模式涂鸦图案 +$pattern-light: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'%3E%3Cdefs%3E%3Cstyle%3E.a%7Bfill:none;stroke:%23000;stroke-width:1.2;opacity:0.045%7D.b%7Bfill:none;stroke:%23000;stroke-width:1;opacity:0.035%7D.c%7Bfill:none;stroke:%23000;stroke-width:0.8;opacity:0.04%7D%3C/style%3E%3C/defs%3E%3Cg transform='translate(45,35) rotate(-8)'%3E%3Ccircle class='a' cx='0' cy='0' r='16'/%3E%3Ccircle class='a' cx='-5' cy='-4' r='2.5'/%3E%3Ccircle class='a' cx='5' cy='-4' r='2.5'/%3E%3Cpath class='a' d='M-8 4 Q0 12 8 4'/%3E%3C/g%3E%3Cg transform='translate(320,28) rotate(15) scale(0.7)'%3E%3Cpath class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/%3E%3C/g%3E%3Cg transform='translate(180,55) rotate(12)'%3E%3Cpath class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/%3E%3C/g%3E%3Cg transform='translate(95,120) rotate(-5) scale(1.1)'%3E%3Cpath class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/%3E%3C/g%3E%3Cg transform='translate(355,95) rotate(8)'%3E%3Cpath class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/%3E%3Cellipse class='c' cx='-4' cy='20' rx='6' ry='4'/%3E%3Cellipse class='c' cx='14' cy='16' rx='6' ry='4'/%3E%3C/g%3E%3Cg transform='translate(250,110) rotate(-12) scale(0.9)'%3E%3Crect class='b' x='0' y='0' width='26' height='18' rx='2'/%3E%3Cpath class='b' d='M0 2 L13 11 L26 2'/%3E%3C/g%3E%3Cg transform='translate(28,195) rotate(6)'%3E%3Ccircle class='a' cx='0' cy='0' r='11'/%3E%3Cpath class='a' d='M-5 11 L5 11 M-4 14 L4 14'/%3E%3Cpath class='c' d='M-3 -2 L0 -6 L3 -2'/%3E%3C/g%3E%3Cg transform='translate(155,175) rotate(-3) scale(0.85)'%3E%3Cpath class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/%3E%3Cpath class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/%3E%3C/g%3E%3Cg transform='translate(340,185) rotate(-20) scale(1.2)'%3E%3Cpath class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/%3E%3C/g%3E%3Cg transform='translate(70,280) rotate(5)'%3E%3Crect class='b' x='0' y='5' width='30' height='22' rx='4'/%3E%3Ccircle class='b' cx='15' cy='16' r='7'/%3E%3Crect class='b' x='8' y='0' width='14' height='6' rx='2'/%3E%3C/g%3E%3Cg transform='translate(230,250) rotate(-8) scale(1.1)'%3E%3Crect class='a' x='0' y='6' width='22' height='18' rx='2'/%3E%3Crect class='a' x='-3' y='0' width='28' height='7' rx='2'/%3E%3Cpath class='a' d='M11 0 L11 24 M-3 13 L25 13'/%3E%3C/g%3E%3Cg transform='translate(365,280) rotate(10)'%3E%3Cellipse class='b' cx='0' cy='0' rx='10' ry='14'/%3E%3Cpath class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/%3E%3C/g%3E%3Cg transform='translate(145,310) rotate(-6)'%3E%3Cpath class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/%3E%3Cpath class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/%3E%3Cpath class='c' d='M8 8 Q10 4 12 8'/%3E%3C/g%3E%3Cg transform='translate(310,340) rotate(5) scale(0.9)'%3E%3Cpath class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/%3E%3Cpath class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/%3E%3C/g%3E%3Cg transform='translate(55,365) rotate(25) scale(1.15)'%3E%3Cpath class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/%3E%3Ccircle class='c' cx='12' cy='-2' r='2'/%3E%3C/g%3E%3Cg transform='translate(200,375) rotate(-4)'%3E%3Cpath class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/%3E%3Cpath class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/%3E%3Cpath class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/%3E%3C/g%3E%3Cg transform='translate(380,375) rotate(-10)'%3E%3Ccircle class='a' cx='0' cy='0' r='8'/%3E%3Cpath class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/%3E%3C/g%3E%3Cg transform='translate(280,45) rotate(-15) scale(0.6)'%3E%3Cpath class='c' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/%3E%3C/g%3E%3Cg transform='translate(15,85) rotate(8) scale(0.6)'%3E%3Cpath class='c' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/%3E%3C/g%3E%3Cg transform='translate(120,65) rotate(5) scale(0.8)'%3E%3Ccircle class='c' cx='0' cy='0' r='12'/%3E%3Cpath class='c' d='M-5 -3 L-3 -3'/%3E%3Ccircle class='c' cx='5' cy='-3' r='2'/%3E%3Cpath class='c' d='M-6 4 Q0 9 6 4'/%3E%3C/g%3E%3Cg transform='translate(390,145) rotate(-5)'%3E%3Cpath class='b' d='M0 0 Q0 -10 8 -10 Q16 -10 16 0 L18 8 L-2 8 Z'/%3E%3Ccircle class='b' cx='8' cy='10' r='3'/%3E%3Ccircle class='c' cx='8' cy='-12' r='2'/%3E%3C/g%3E%3Cg transform='translate(20,320) rotate(20)'%3E%3Ccircle class='b' cx='0' cy='0' r='10'/%3E%3Cpath class='b' d='M0 10 L0 30'/%3E%3Cpath class='c' d='M-7 -7 Q0 0 7 -7 M-7 0 Q0 7 7 0'/%3E%3C/g%3E%3Cg transform='translate(295,165) rotate(-3) scale(0.9)'%3E%3Ccircle class='b' cx='10' cy='10' r='12'/%3E%3Cpath class='b' d='M0 4 L4 -6 L8 4 M12 4 L16 -6 L20 4'/%3E%3Ccircle class='c' cx='6' cy='8' r='2'/%3E%3Ccircle class='c' cx='14' cy='8' r='2'/%3E%3Cpath class='c' d='M8 13 L10 15 L12 13'/%3E%3Cpath class='c' d='M-4 10 L2 12 M18 12 L24 10'/%3E%3C/g%3E%3Cg transform='translate(175,260) rotate(8)'%3E%3Cellipse class='b' cx='12' cy='6' rx='14' ry='10'/%3E%3Cpath class='b' d='M6 14 L6 26 L18 26 L18 14'/%3E%3Ccircle class='c' cx='8' cy='4' r='3'/%3E%3Ccircle class='c' cx='16' cy='2' r='2'/%3E%3C/g%3E%3Cg transform='translate(85,175) rotate(-12)'%3E%3Cpath class='b' d='M12 0 A12 12 0 1 1 12 24 A8 8 0 1 0 12 0'/%3E%3C/g%3E%3Cg transform='translate(340,235) rotate(10)'%3E%3Cpath class='a' d='M10 0 L4 12 L10 12 L4 24 L16 10 L10 10 Z'/%3E%3C/g%3E%3Cg transform='translate(15,250) rotate(-25)'%3E%3Cpath class='b' d='M0 20 Q0 0 20 0 Q0 8 0 20'/%3E%3Cpath class='c' d='M2 18 Q8 10 18 2'/%3E%3C/g%3E%3C/svg%3E"); + +// 深色模式涂鸦图案 +$pattern-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'%3E%3Cdefs%3E%3Cstyle%3E.a%7Bfill:none;stroke:%23fff;stroke-width:1.2;opacity:0.055%7D.b%7Bfill:none;stroke:%23fff;stroke-width:1;opacity:0.045%7D.c%7Bfill:none;stroke:%23fff;stroke-width:0.8;opacity:0.05%7D%3C/style%3E%3C/defs%3E%3Cg transform='translate(45,35) rotate(-8)'%3E%3Ccircle class='a' cx='0' cy='0' r='16'/%3E%3Ccircle class='a' cx='-5' cy='-4' r='2.5'/%3E%3Ccircle class='a' cx='5' cy='-4' r='2.5'/%3E%3Cpath class='a' d='M-8 4 Q0 12 8 4'/%3E%3C/g%3E%3Cg transform='translate(320,28) rotate(15) scale(0.7)'%3E%3Cpath class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/%3E%3C/g%3E%3Cg transform='translate(180,55) rotate(12)'%3E%3Cpath class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/%3E%3C/g%3E%3Cg transform='translate(95,120) rotate(-5) scale(1.1)'%3E%3Cpath class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/%3E%3C/g%3E%3Cg transform='translate(355,95) rotate(8)'%3E%3Cpath class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/%3E%3Cellipse class='c' cx='-4' cy='20' rx='6' ry='4'/%3E%3Cellipse class='c' cx='14' cy='16' rx='6' ry='4'/%3E%3C/g%3E%3Cg transform='translate(250,110) rotate(-12) scale(0.9)'%3E%3Crect class='b' x='0' y='0' width='26' height='18' rx='2'/%3E%3Cpath class='b' d='M0 2 L13 11 L26 2'/%3E%3C/g%3E%3Cg transform='translate(28,195) rotate(6)'%3E%3Ccircle class='a' cx='0' cy='0' r='11'/%3E%3Cpath class='a' d='M-5 11 L5 11 M-4 14 L4 14'/%3E%3Cpath class='c' d='M-3 -2 L0 -6 L3 -2'/%3E%3C/g%3E%3Cg transform='translate(155,175) rotate(-3) scale(0.85)'%3E%3Cpath class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/%3E%3Cpath class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/%3E%3C/g%3E%3Cg transform='translate(340,185) rotate(-20) scale(1.2)'%3E%3Cpath class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/%3E%3C/g%3E%3Cg transform='translate(70,280) rotate(5)'%3E%3Crect class='b' x='0' y='5' width='30' height='22' rx='4'/%3E%3Ccircle class='b' cx='15' cy='16' r='7'/%3E%3Crect class='b' x='8' y='0' width='14' height='6' rx='2'/%3E%3C/g%3E%3Cg transform='translate(230,250) rotate(-8) scale(1.1)'%3E%3Crect class='a' x='0' y='6' width='22' height='18' rx='2'/%3E%3Crect class='a' x='-3' y='0' width='28' height='7' rx='2'/%3E%3Cpath class='a' d='M11 0 L11 24 M-3 13 L25 13'/%3E%3C/g%3E%3Cg transform='translate(365,280) rotate(10)'%3E%3Cellipse class='b' cx='0' cy='0' rx='10' ry='14'/%3E%3Cpath class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/%3E%3C/g%3E%3Cg transform='translate(145,310) rotate(-6)'%3E%3Cpath class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/%3E%3Cpath class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/%3E%3Cpath class='c' d='M8 8 Q10 4 12 8'/%3E%3C/g%3E%3Cg transform='translate(310,340) rotate(5) scale(0.9)'%3E%3Cpath class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/%3E%3Cpath class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/%3E%3C/g%3E%3Cg transform='translate(55,365) rotate(25) scale(1.15)'%3E%3Cpath class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/%3E%3Ccircle class='c' cx='12' cy='-2' r='2'/%3E%3C/g%3E%3Cg transform='translate(200,375) rotate(-4)'%3E%3Cpath class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/%3E%3Cpath class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/%3E%3Cpath class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/%3E%3C/g%3E%3Cg transform='translate(380,375) rotate(-10)'%3E%3Ccircle class='a' cx='0' cy='0' r='8'/%3E%3Cpath class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/%3E%3C/g%3E%3Cg transform='translate(280,45) rotate(-15) scale(0.6)'%3E%3Cpath class='c' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/%3E%3C/g%3E%3Cg transform='translate(15,85) rotate(8) scale(0.6)'%3E%3Cpath class='c' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/%3E%3C/g%3E%3Cg transform='translate(120,65) rotate(5) scale(0.8)'%3E%3Ccircle class='c' cx='0' cy='0' r='12'/%3E%3Cpath class='c' d='M-5 -3 L-3 -3'/%3E%3Ccircle class='c' cx='5' cy='-3' r='2'/%3E%3Cpath class='c' d='M-6 4 Q0 9 6 4'/%3E%3C/g%3E%3Cg transform='translate(390,145) rotate(-5)'%3E%3Cpath class='b' d='M0 0 Q0 -10 8 -10 Q16 -10 16 0 L18 8 L-2 8 Z'/%3E%3Ccircle class='b' cx='8' cy='10' r='3'/%3E%3Ccircle class='c' cx='8' cy='-12' r='2'/%3E%3C/g%3E%3Cg transform='translate(20,320) rotate(20)'%3E%3Ccircle class='b' cx='0' cy='0' r='10'/%3E%3Cpath class='b' d='M0 10 L0 30'/%3E%3Cpath class='c' d='M-7 -7 Q0 0 7 -7 M-7 0 Q0 7 7 0'/%3E%3C/g%3E%3Cg transform='translate(295,165) rotate(-3) scale(0.9)'%3E%3Ccircle class='b' cx='10' cy='10' r='12'/%3E%3Cpath class='b' d='M0 4 L4 -6 L8 4 M12 4 L16 -6 L20 4'/%3E%3Ccircle class='c' cx='6' cy='8' r='2'/%3E%3Ccircle class='c' cx='14' cy='8' r='2'/%3E%3Cpath class='c' d='M8 13 L10 15 L12 13'/%3E%3Cpath class='c' d='M-4 10 L2 12 M18 12 L24 10'/%3E%3C/g%3E%3Cg transform='translate(175,260) rotate(8)'%3E%3Cellipse class='b' cx='12' cy='6' rx='14' ry='10'/%3E%3Cpath class='b' d='M6 14 L6 26 L18 26 L18 14'/%3E%3Ccircle class='c' cx='8' cy='4' r='3'/%3E%3Ccircle class='c' cx='16' cy='2' r='2'/%3E%3C/g%3E%3Cg transform='translate(85,175) rotate(-12)'%3E%3Cpath class='b' d='M12 0 A12 12 0 1 1 12 24 A8 8 0 1 0 12 0'/%3E%3C/g%3E%3Cg transform='translate(340,235) rotate(10)'%3E%3Cpath class='a' d='M10 0 L4 12 L10 12 L4 24 L16 10 L10 10 Z'/%3E%3C/g%3E%3Cg transform='translate(15,250) rotate(-25)'%3E%3Cpath class='b' d='M0 20 Q0 0 20 0 Q0 8 0 20'/%3E%3Cpath class='c' d='M2 18 Q8 10 18 2'/%3E%3C/g%3E%3C/svg%3E"); + +// 导出 CSS 变量 +:root { + --chat-pattern: #{$pattern-light}; +} + +[data-mode="dark"] { + --chat-pattern: #{$pattern-dark}; +} diff --git a/src/styles/main.scss b/src/styles/main.scss new file mode 100644 index 0000000..31cb00b --- /dev/null +++ b/src/styles/main.scss @@ -0,0 +1,309 @@ +// CSS 变量 - 主题 +@use './chat-patterns.scss'; + +:root { + // 颜色 + --primary: #8B7355; + --primary-hover: #7A6548; + --primary-light: rgba(139, 115, 85, 0.1); + --danger: #dc3545; + --warning: #ffc107; + + // 背景 + --bg-primary: #F0EEE9; + --bg-secondary: rgba(255, 255, 255, 0.7); + --bg-tertiary: rgba(0, 0, 0, 0.03); + --bg-hover: rgba(0, 0, 0, 0.05); + + // 文字 + --text-primary: #3d3d3d; + --text-secondary: #666666; + --text-tertiary: #999999; + + // 边框 + --border-color: rgba(0, 0, 0, 0.08); + --border-radius: 9999px; + + // 阴影 + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + + // 侧边栏 + --sidebar-width: 220px; + + // 主题渐变 + --bg-gradient: linear-gradient(135deg, #F0EEE9 0%, #E8E6E1 100%); + --primary-gradient: linear-gradient(135deg, #8B7355 0%, #A68B5B 100%); + + // 卡片背景 + --card-bg: rgba(255, 255, 255, 0.7); +} + +// ==================== 浅色主题 ==================== + +// 云上舞白主题 (默认) +[data-theme="cloud-dancer"][data-mode="light"], +[data-theme="cloud-dancer"]:not([data-mode]) { + --primary: #8B7355; + --primary-hover: #7A6548; + --primary-light: rgba(139, 115, 85, 0.1); + --bg-primary: #F0EEE9; + --bg-secondary: rgba(255, 255, 255, 0.7); + --bg-tertiary: rgba(0, 0, 0, 0.03); + --bg-hover: rgba(0, 0, 0, 0.05); + --text-primary: #3d3d3d; + --text-secondary: #666666; + --text-tertiary: #999999; + --border-color: rgba(0, 0, 0, 0.08); + --bg-gradient: linear-gradient(135deg, #F0EEE9 0%, #E8E6E1 100%); + --primary-gradient: linear-gradient(135deg, #8B7355 0%, #A68B5B 100%); + --card-bg: rgba(255, 255, 255, 0.7); +} + +// 刚玉蓝主题 +[data-theme="corundum-blue"][data-mode="light"], +[data-theme="corundum-blue"]:not([data-mode]) { + --primary: #4A6670; + --primary-hover: #3D565E; + --primary-light: rgba(74, 102, 112, 0.1); + --bg-primary: #E8EEF0; + --bg-secondary: rgba(255, 255, 255, 0.7); + --bg-tertiary: rgba(0, 0, 0, 0.03); + --bg-hover: rgba(0, 0, 0, 0.05); + --text-primary: #3d3d3d; + --text-secondary: #666666; + --text-tertiary: #999999; + --border-color: rgba(0, 0, 0, 0.08); + --bg-gradient: linear-gradient(135deg, #E8EEF0 0%, #D8E4E8 100%); + --primary-gradient: linear-gradient(135deg, #4A6670 0%, #5A7A86 100%); + --card-bg: rgba(255, 255, 255, 0.7); +} + +// 冰猕猴桃汁绿主题 +[data-theme="kiwi-green"][data-mode="light"], +[data-theme="kiwi-green"]:not([data-mode]) { + --primary: #7A9A5C; + --primary-hover: #6A8A4C; + --primary-light: rgba(122, 154, 92, 0.1); + --bg-primary: #E8F0E4; + --bg-secondary: rgba(255, 255, 255, 0.7); + --bg-tertiary: rgba(0, 0, 0, 0.03); + --bg-hover: rgba(0, 0, 0, 0.05); + --text-primary: #3d3d3d; + --text-secondary: #666666; + --text-tertiary: #999999; + --border-color: rgba(0, 0, 0, 0.08); + --bg-gradient: linear-gradient(135deg, #E8F0E4 0%, #D8E8D0 100%); + --primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #8AAA6C 100%); + --card-bg: rgba(255, 255, 255, 0.7); +} + +// 辛辣红主题 +[data-theme="spicy-red"][data-mode="light"], +[data-theme="spicy-red"]:not([data-mode]) { + --primary: #8B4049; + --primary-hover: #7A3540; + --primary-light: rgba(139, 64, 73, 0.1); + --bg-primary: #F0E8E8; + --bg-secondary: rgba(255, 255, 255, 0.7); + --bg-tertiary: rgba(0, 0, 0, 0.03); + --bg-hover: rgba(0, 0, 0, 0.05); + --text-primary: #3d3d3d; + --text-secondary: #666666; + --text-tertiary: #999999; + --border-color: rgba(0, 0, 0, 0.08); + --bg-gradient: linear-gradient(135deg, #F0E8E8 0%, #E8D8D8 100%); + --primary-gradient: linear-gradient(135deg, #8B4049 0%, #A05058 100%); + --card-bg: rgba(255, 255, 255, 0.7); +} + +// 明水鸭色主题 +[data-theme="teal-water"][data-mode="light"], +[data-theme="teal-water"]:not([data-mode]) { + --primary: #5A8A8A; + --primary-hover: #4A7A7A; + --primary-light: rgba(90, 138, 138, 0.1); + --bg-primary: #E4F0F0; + --bg-secondary: rgba(255, 255, 255, 0.7); + --bg-tertiary: rgba(0, 0, 0, 0.03); + --bg-hover: rgba(0, 0, 0, 0.05); + --text-primary: #3d3d3d; + --text-secondary: #666666; + --text-tertiary: #999999; + --border-color: rgba(0, 0, 0, 0.08); + --bg-gradient: linear-gradient(135deg, #E4F0F0 0%, #D4E8E8 100%); + --primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #6A9A9A 100%); + --card-bg: rgba(255, 255, 255, 0.7); +} + +// ==================== 深色主题 ==================== + +// 云上舞白 - 深色 +[data-theme="cloud-dancer"][data-mode="dark"] { + --primary: #C9A86C; + --primary-hover: #D9B87C; + --primary-light: rgba(201, 168, 108, 0.15); + --bg-primary: #1a1816; + --bg-secondary: rgba(40, 36, 32, 0.9); + --bg-tertiary: rgba(255, 255, 255, 0.05); + --bg-hover: rgba(255, 255, 255, 0.08); + --text-primary: #F0EEE9; + --text-secondary: #b3b0aa; + --text-tertiary: #807d78; + --border-color: rgba(255, 255, 255, 0.1); + --bg-gradient: linear-gradient(135deg, #1a1816 0%, #252220 100%); + --primary-gradient: linear-gradient(135deg, #8B7355 0%, #C9A86C 100%); + --card-bg: rgba(40, 36, 32, 0.9); +} + +// 刚玉蓝 - 深色 +[data-theme="corundum-blue"][data-mode="dark"] { + --primary: #6A9AAA; + --primary-hover: #7AAABA; + --primary-light: rgba(106, 154, 170, 0.15); + --bg-primary: #141a1c; + --bg-secondary: rgba(30, 40, 44, 0.9); + --bg-tertiary: rgba(255, 255, 255, 0.05); + --bg-hover: rgba(255, 255, 255, 0.08); + --text-primary: #E8EEF0; + --text-secondary: #a8b4b8; + --text-tertiary: #6a7a80; + --border-color: rgba(255, 255, 255, 0.1); + --bg-gradient: linear-gradient(135deg, #141a1c 0%, #1e282c 100%); + --primary-gradient: linear-gradient(135deg, #4A6670 0%, #6A9AAA 100%); + --card-bg: rgba(30, 40, 44, 0.9); +} + +// 冰猕猴桃汁绿 - 深色 +[data-theme="kiwi-green"][data-mode="dark"] { + --primary: #9ABA7C; + --primary-hover: #AACA8C; + --primary-light: rgba(154, 186, 124, 0.15); + --bg-primary: #161a14; + --bg-secondary: rgba(34, 42, 30, 0.9); + --bg-tertiary: rgba(255, 255, 255, 0.05); + --bg-hover: rgba(255, 255, 255, 0.08); + --text-primary: #E8F0E4; + --text-secondary: #a8b4a0; + --text-tertiary: #6a7a60; + --border-color: rgba(255, 255, 255, 0.1); + --bg-gradient: linear-gradient(135deg, #161a14 0%, #222a1e 100%); + --primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #9ABA7C 100%); + --card-bg: rgba(34, 42, 30, 0.9); +} + +// 辛辣红 - 深色 +[data-theme="spicy-red"][data-mode="dark"] { + --primary: #C06068; + --primary-hover: #D07078; + --primary-light: rgba(192, 96, 104, 0.15); + --bg-primary: #1a1416; + --bg-secondary: rgba(42, 32, 34, 0.9); + --bg-tertiary: rgba(255, 255, 255, 0.05); + --bg-hover: rgba(255, 255, 255, 0.08); + --text-primary: #F0E8E8; + --text-secondary: #b4a8aa; + --text-tertiary: #7a6a6c; + --border-color: rgba(255, 255, 255, 0.1); + --bg-gradient: linear-gradient(135deg, #1a1416 0%, #2a2022 100%); + --primary-gradient: linear-gradient(135deg, #8B4049 0%, #C06068 100%); + --card-bg: rgba(42, 32, 34, 0.9); +} + +// 明水鸭色 - 深色 +[data-theme="teal-water"][data-mode="dark"] { + --primary: #7ABAAA; + --primary-hover: #8ACABA; + --primary-light: rgba(122, 186, 170, 0.15); + --bg-primary: #121a1a; + --bg-secondary: rgba(28, 42, 42, 0.9); + --bg-tertiary: rgba(255, 255, 255, 0.05); + --bg-hover: rgba(255, 255, 255, 0.08); + --text-primary: #E4F0F0; + --text-secondary: #a0b4b4; + --text-tertiary: #607a7a; + --border-color: rgba(255, 255, 255, 0.1); + --bg-gradient: linear-gradient(135deg, #121a1a 0%, #1c2a2a 100%); + --primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #7ABAAA 100%); + --card-bg: rgba(28, 42, 42, 0.9); +} + +// 重置样式 +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + font-family: 'HarmonyOS Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + color: var(--text-primary); + background: var(--bg-primary); + -webkit-font-smoothing: antialiased; + user-select: none; +} + + +#app { + height: 100%; +} + +// 滚动条样式 +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + border-radius: 3px; + + &:hover { + background: var(--text-secondary); + } +} + +// 按钮基础样式 +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + border: none; + border-radius: var(--border-radius); + font-size: 14px; + cursor: pointer; + transition: all 0.2s; + + &-primary { + background: var(--primary); + color: white; + + &:hover { + background: var(--primary-hover); + } + } + + &-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + + &:hover { + background: var(--border-color); + } + } +} + +// 卡片样式 +.card { + background: var(--bg-secondary); + border-radius: 16px; + box-shadow: var(--shadow-sm); + padding: 16px; +} diff --git a/src/types/analytics.ts b/src/types/analytics.ts new file mode 100644 index 0000000..fbbd60d --- /dev/null +++ b/src/types/analytics.ts @@ -0,0 +1,91 @@ +// 分析数据类型定义 + +// 聊天统计数据 +export interface ChatStatistics { + totalMessages: number + textMessages: number + imageMessages: number + voiceMessages: number + videoMessages: number + emojiMessages: number + otherMessages: number + sentMessages: number + receivedMessages: number + firstMessageTime: number | null // Unix timestamp + lastMessageTime: number | null + activeDays: number + messageTypeCounts: Record +} + +// 时间分布统计 +export interface TimeDistribution { + hourlyDistribution: Record // 0-23 + weekdayDistribution: Record // 1-7 + monthlyDistribution: Record // YYYY-MM +} + +// 联系人排名 +export interface ContactRanking { + username: string + displayName: string + avatarUrl?: string + messageCount: number + sentCount: number + receivedCount: number + lastMessageTime: number | null +} + +// 消息类型标签映射 +export const MESSAGE_TYPE_LABELS: Record = { + 1: '文本', + 244813135921: '文本', + 3: '图片', + 34: '语音', + 43: '视频', + 47: '表情', + 48: '位置', + 49: '链接/文件', + 42: '名片', + 10000: '系统消息', +} + +// 星期几名称 +export const WEEKDAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] + +// 获取消息类型分布(用于图表) +export function getMessageTypeDistribution(stats: ChatStatistics): Record { + if (Object.keys(stats.messageTypeCounts).length > 0) { + const distribution: Record = {} + + for (const [type, count] of Object.entries(stats.messageTypeCounts)) { + const typeNum = parseInt(type) + const label = MESSAGE_TYPE_LABELS[typeNum] || '其他' + distribution[label] = (distribution[label] || 0) + count + } + + return distribution + } + + return { + '文本': stats.textMessages, + '图片': stats.imageMessages, + '语音': stats.voiceMessages, + '视频': stats.videoMessages, + '表情': stats.emojiMessages, + '其他': stats.otherMessages, + } +} + +// 计算聊天时长(天数) +export function getChatDurationDays(stats: ChatStatistics): number { + if (!stats.firstMessageTime || !stats.lastMessageTime) return 0 + const diffMs = (stats.lastMessageTime - stats.firstMessageTime) * 1000 + return Math.floor(diffMs / (1000 * 60 * 60 * 24)) + 1 +} + +// 平均每天消息数 +export function getAverageMessagesPerDay(stats: ChatStatistics): number { + const days = getChatDurationDays(stats) + if (days === 0) return 0 + return stats.totalMessages / days +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts new file mode 100644 index 0000000..eb5c106 --- /dev/null +++ b/src/types/electron.d.ts @@ -0,0 +1,329 @@ +import type { ChatSession, Message, Contact } from './models' + +export interface ElectronAPI { + window: { + minimize: () => void + maximize: () => void + close: () => void + openAgreementWindow: () => Promise + completeOnboarding: () => Promise + openOnboardingWindow: () => Promise + setTitleBarOverlay: (options: { symbolColor: string }) => void + } + config: { + get: (key: string) => Promise + set: (key: string, value: unknown) => Promise + clear: () => Promise + } + dialog: { + openFile: (options?: Electron.OpenDialogOptions) => Promise + openDirectory: (options?: Electron.OpenDialogOptions) => Promise + saveFile: (options?: Electron.SaveDialogOptions) => Promise + } + shell: { + openPath: (path: string) => Promise + openExternal: (url: string) => Promise + } + app: { + getDownloadsPath: () => Promise + getVersion: () => Promise + checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }> + downloadAndInstall: () => Promise + onDownloadProgress: (callback: (progress: number) => void) => () => void + onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void + } + log: { + getPath: () => Promise + read: () => Promise<{ success: boolean; content?: string; error?: string }> + } + dbPath: { + autoDetect: () => Promise<{ success: boolean; path?: string; error?: string }> + scanWxids: (rootPath: string) => Promise + getDefault: () => Promise + } + wcdb: { + testConnection: (dbPath: string, hexKey: string, wxid: string) => Promise<{ success: boolean; error?: string; sessionCount?: number }> + open: (dbPath: string, hexKey: string, wxid: string) => Promise + close: () => Promise + } + key: { + autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }> + autoGetImageKey: (manualDir?: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }> + onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => () => void + onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void + } + chat: { + connect: () => Promise<{ success: boolean; error?: string }> + getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> + getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{ + success: boolean; + messages?: Message[]; + hasMore?: boolean; + error?: string + }> + getLatestMessages: (sessionId: string, limit?: number) => Promise<{ + success: boolean + messages?: Message[] + error?: string + }> + getContact: (username: string) => Promise + getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null> + getMyAvatarUrl: () => Promise<{ success: boolean; avatarUrl?: string; error?: string }> + downloadEmoji: (cdnUrl: string, md5?: string) => Promise<{ success: boolean; localPath?: string; error?: string }> + close: () => Promise + getSessionDetail: (sessionId: string) => Promise<{ + success: boolean + detail?: { + wxid: string + displayName: string + remark?: string + nickName?: string + alias?: string + avatarUrl?: string + messageCount: number + firstMessageTime?: number + latestMessageTime?: number + messageTables: { dbName: string; tableName: string; count: number }[] + } + error?: string + }> + getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> + getVoiceData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> + } + image: { + decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }> + resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> + preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise + onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void + onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void + } + analytics: { + getOverallStatistics: () => Promise<{ + success: boolean + data?: { + totalMessages: number + textMessages: number + imageMessages: number + voiceMessages: number + videoMessages: number + emojiMessages: number + otherMessages: number + sentMessages: number + receivedMessages: number + firstMessageTime: number | null + lastMessageTime: number | null + activeDays: number + messageTypeCounts: Record + } + error?: string + }> + getContactRankings: (limit?: number) => Promise<{ + success: boolean + data?: Array<{ + username: string + displayName: string + avatarUrl?: string + messageCount: number + sentCount: number + receivedCount: number + lastMessageTime: number | null + }> + error?: string + }> + getTimeDistribution: () => Promise<{ + success: boolean + data?: { + hourlyDistribution: Record + weekdayDistribution: Record + monthlyDistribution: Record + } + error?: string + }> + onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void + } + groupAnalytics: { + getGroupChats: () => Promise<{ + success: boolean + data?: Array<{ + username: string + displayName: string + memberCount: number + avatarUrl?: string + }> + error?: string + }> + getGroupMembers: (chatroomId: string) => Promise<{ + success: boolean + data?: Array<{ + username: string + displayName: string + avatarUrl?: string + }> + error?: string + }> + getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => Promise<{ + success: boolean + data?: Array<{ + member: { + username: string + displayName: string + avatarUrl?: string + } + messageCount: number + }> + error?: string + }> + getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => Promise<{ + success: boolean + data?: { + hourlyDistribution: Record + } + error?: string + }> + getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => Promise<{ + success: boolean + data?: { + typeCounts: Array<{ + type: number + name: string + count: number + }> + total: number + } + error?: string + }> + } + annualReport: { + getAvailableYears: () => Promise<{ + success: boolean + data?: number[] + error?: string + }> + generateReport: (year: number) => Promise<{ + success: boolean + data?: { + year: number + totalMessages: number + totalFriends: number + coreFriends: Array<{ + username: string + displayName: string + avatarUrl?: string + messageCount: number + sentCount: number + receivedCount: number + }> + monthlyTopFriends: Array<{ + month: number + displayName: string + avatarUrl?: string + messageCount: number + }> + peakDay: { + date: string + messageCount: number + topFriend?: string + topFriendCount?: number + } | null + longestStreak: { + friendName: string + days: number + startDate: string + endDate: string + } | null + activityHeatmap: { + data: number[][] + } + midnightKing: { + displayName: string + count: number + percentage: number + } | null + selfAvatarUrl?: string + mutualFriend: { + displayName: string + avatarUrl?: string + sentCount: number + receivedCount: number + ratio: number + } | null + socialInitiative: { + initiatedChats: number + receivedChats: number + initiativeRate: number + } | null + responseSpeed: { + avgResponseTime: number + fastestFriend: string + fastestTime: number + } | null + topPhrases: Array<{ + phrase: string + count: number + }> + } + error?: string + }> + exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => Promise<{ + success: boolean + dir?: string + error?: string + }> + onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void + } + export: { + exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{ + success: boolean + successCount?: number + failCount?: number + error?: string + }> + exportSession: (sessionId: string, outputPath: string, options: ExportOptions) => Promise<{ + success: boolean + error?: string + }> + } +} + +export interface ExportOptions { + format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql' + dateRange?: { start: number; end: number } | null + exportMedia?: boolean + exportAvatars?: boolean +} + +export interface WxidInfo { + wxid: string + modifiedTime: number +} + +declare global { + interface Window { + electronAPI: ElectronAPI + } + + // Electron 类型声明 + namespace Electron { + interface OpenDialogOptions { + title?: string + defaultPath?: string + filters?: { name: string; extensions: string[] }[] + properties?: ('openFile' | 'openDirectory' | 'multiSelections' | 'createDirectory')[] + } + interface OpenDialogReturnValue { + canceled: boolean + filePaths: string[] + } + interface SaveDialogOptions { + title?: string + defaultPath?: string + filters?: { name: string; extensions: string[] }[] + } + interface SaveDialogReturnValue { + canceled: boolean + filePath?: string + } + } +} + +export { } diff --git a/src/types/models.ts b/src/types/models.ts new file mode 100644 index 0000000..09f6e41 --- /dev/null +++ b/src/types/models.ts @@ -0,0 +1,55 @@ +// 聊天会话 +export interface ChatSession { + username: string + type: number + unreadCount: number + summary: string + sortTimestamp: number // 用于排序 + lastTimestamp: number // 用于显示时间 + lastMsgType: number + displayName?: string + avatarUrl?: string +} + +// 联系人 +export interface Contact { + id: number + username: string + localType: number + alias: string + remark: string + nickName: string + bigHeadUrl: string + smallHeadUrl: string +} + +// 消息 +export interface Message { + localId: number + serverId: number + localType: number + createTime: number + sortSeq: number + isSend: number | null + senderUsername: string | null + parsedContent: string + imageMd5?: string + imageDatName?: string + emojiCdnUrl?: string + emojiMd5?: string + voiceDurationSeconds?: number + // 引用消息 + quotedContent?: string + quotedSender?: string +} + +// 分析数据 +export interface AnalyticsData { + totalMessages: number + totalDays: number + myMessages: number + otherMessages: number + messagesByType: Record + messagesByHour: number[] + messagesByDay: number[] +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e96f9bd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + }, + "baseUrl": "." + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..4f7d977 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "target": "ES2022", + "lib": [ + "ES2022" + ] + }, + "include": [ + "vite.config.ts", + "electron/**/*.ts" + ] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..53df4ae --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,75 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import electron from 'vite-plugin-electron' +import renderer from 'vite-plugin-electron-renderer' +import { resolve } from 'path' + +export default defineConfig({ + base: './', + server: { + port: 3000, + strictPort: false // 如果3000被占用,自动尝试下一个 + }, + plugins: [ + react(), + electron([ + { + entry: 'electron/main.ts', + vite: { + build: { + outDir: 'dist-electron', + rollupOptions: { + external: ['better-sqlite3', 'koffi'] + } + } + } + }, + { + entry: 'electron/annualReportWorker.ts', + vite: { + build: { + outDir: 'dist-electron', + rollupOptions: { + external: ['koffi'], + output: { + entryFileNames: 'annualReportWorker.js', + inlineDynamicImports: true + } + } + } + } + }, + { + entry: 'electron/imageSearchWorker.ts', + vite: { + build: { + outDir: 'dist-electron', + rollupOptions: { + output: { + entryFileNames: 'imageSearchWorker.js', + inlineDynamicImports: true + } + } + } + } + }, + { + entry: 'electron/preload.ts', + onstart(options) { + options.reload() + }, + vite: { + build: { + outDir: 'dist-electron' + } + } + } + ]), + renderer() + ], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + } +})